Skip to content

bhandras/llformat

Repository files navigation

llformat

llformat is a focused Go source formatter for controlled adoption in large Go repositories. It does not try to become a general-purpose pretty printer. Instead, it applies a small set of column-limit-aware rewrites that are hard to get consistently from gofmt alone:

  • conservative comment reflow
  • log/printf-style call formatting
  • structured logging key/value pair packing
  • multiline non-log call and method-chain formatting
  • selected long-expression breaks
  • function, function-literal, and interface-method signature formatting
  • vertical spacing rules around signatures, returns, cases, and control blocks

The project name comes from the Lightning Labs / LND readability conventions that originally motivated the formatter:

Goals

  • Targeted changes only: touch known formatting targets and preserve the rest of the file.
  • Idempotent output: repeated runs should converge quickly.
  • Parse-safe rewrites: generated output must remain valid Go and is normalized with gofmt.
  • Conservative comments: preserve comments that already fit, preserve preformatted blocks, and avoid directives such as //go:, cgo pragmas, and //nolint.
  • Adoptable at repo scale: provide diagnostics for finding formatter gaps before introducing a large baseline commit.

Non-Goals

  • Replacing gofmt.
  • Formatting generated files by default.
  • Guaranteeing every line fits under the column limit.
  • Rewriting arbitrary code for subjective style preferences.
  • Competing with unrelated whitespace linters that cannot express the same policy. In target repos, let one formatter own vertical spacing.

Quick Start

Build the formatter:

make build

Format one file to stdout:

./bin/llformat path/to/file.go

Write one file in place:

./bin/llformat -w path/to/file.go

Format a repository recursively while skipping common generated files:

find . -name '*.go' \
  ! -name '*.pb.go' \
  ! -name '*.pb.gw.go' \
  ! -name '*.sql.go' \
  ! -path './db/sqlc/*' \
  -print0 |
  xargs -0 -n 1 /path/to/llformat/bin/llformat -w

Most adopting repositories should wrap this in their own make fmt target so the generated-file excludes match the local codebase.

Default Style

The CLI default is the current "next" profile. Important defaults include:

  • column limit 80, tab stop 8
  • comments mode overflow, which only wraps overflowing prose comments
  • fitting comment blocks are preserved as-is
  • Go example // Output: and // Unordered output: blocks are preserved
  • trailing inline comments are not hoisted unless --wrap-inline-comments is set
  • log/printf calls are formatted with compact string splitting
  • structured logging calls keep the message/preamble compact, then pack key/value arguments in pairs
  • multiline function signatures keep a blank separator before the body
  • collapsed single-line signatures do not keep an extra body separator
  • single-return control blocks stay compact
  • multiline control blocks with multiple statements keep a readability separator

These examples show the subset of Lightning Labs-style formatting that llformat owns. They are not a complete Go style guide. The examples use neutral names and mirror shapes covered by focused tests or golden fixtures.

Comments

Fitting comments are preserved:

Before:

// LoadStore opens the store and verifies the schema version.
func LoadStore(path string) (*Store, error) {
	return openStore(path)
}

After:

// LoadStore opens the store and verifies the schema version.
func LoadStore(path string) (*Store, error) {
	return openStore(path)
}

Overflowing prose comments are wrapped:

Before:

// LoadStore opens the store, verifies the schema version, and prepares the background cache that is used by later request handlers.
func LoadStore(path string) (*Store, error) {
	return openStore(path)
}

After:

// LoadStore opens the store, verifies the schema version, and prepares the
// background cache that is used by later request handlers.
func LoadStore(path string) (*Store, error) {
	return openStore(path)
}

Preformatted output blocks are preserved:

Before:

func ExampleRouter() {
	fmt.Println("ready")

	// Output:
	// ready
}

After:

func ExampleRouter() {
	fmt.Println("ready")

	// Output:
	// ready
}

Log and Error Calls

Long printf-style messages are split while keeping the call compact:

Before:

func loadSession(log Logger, sessionID string, attempt int, err error) error {
	return fmt.Errorf("unable to load session %s on attempt %d from the primary store: %w", sessionID, attempt, err)
}

After:

func loadSession(log Logger, sessionID string, attempt int, err error) error {
	return fmt.Errorf("unable to load session %s on attempt %d from "+
		"the primary store: %w", sessionID, attempt, err)
}

Structured Logging

Structured log calls keep the message/preamble compact, then pack key/value pairs:

Before:

func f(log Logger, sessionID string, count int, retry bool, reason string) {
	log.InfoS("processed session with updated state", "session_id", sessionID, "count", count, "retry", retry, "reason", reason)
}

After:

func f(log Logger, sessionID string, count int, retry bool, reason string) {
	log.InfoS("processed session with updated state",
		"session_id", sessionID, "count", count, "retry", retry,
		"reason", reason,
	)
}

Error-style structured logs keep the error and message together:

Before:

func f(log Logger, err error, sessionID string, attempt int) {
	log.ErrorS(err, "failed to process session", "session_id", sessionID, "attempt", attempt)
}

After:

func f(log Logger, err error, sessionID string, attempt int) {
	log.ErrorS(err, "failed to process session",
		"session_id", sessionID, "attempt", attempt,
	)
}

Function Signatures

Multiline signatures keep a separator:

Before:

func processBundle(store Store, bundle PackageBundle, policy ValidationPolicy, clock Clock) error {
	return store.Save(bundle, policy, clock)
}

After:

func processBundle(store Store, bundle PackageBundle, policy ValidationPolicy,
	clock Clock) error {

	return store.Save(bundle, policy, clock)
}

Collapsed signatures stay compact:

Before:

func processBundle(
	store Store,
	bundle PackageBundle) error {

	return store.Save(bundle)
}

After:

func processBundle(store Store, bundle PackageBundle) error {
	return store.Save(bundle)
}

Small return lists stay inline when they fit:

Before:

func loadBundle(store Store, id BundleID) (
	*PackageBundle,
	error) {

	return store.Load(id)
}

After:

func loadBundle(store Store, id BundleID) (*PackageBundle, error) {
	return store.Load(id)
}

Function Literals

Multiline function literal signatures also keep a body separator:

Before:

func f() {
	requestParserForInitialization := func(req *OpenRequest) (*InitMessage, error) {
		_ = req
		return nil, nil
	}

	_ = requestParserForInitialization
}

After:

func f() {
	requestParserForInitialization := func(req *OpenRequest) (*InitMessage,
		error) {

		_ = req

		return nil, nil
	}

	_ = requestParserForInitialization
}

Already-multiline closure signatures keep their separator:

Before:

func f() {
	alreadyFormatted := func(
		first SomeRidiculouslyLongParameterTypeNameThatForcesLineBreakUnder80Columns,
		second AnotherRidiculouslyLongParameterTypeNameThatAlsoForcesLineBreak) {
		_ = first
		_ = second
	}

	_ = alreadyFormatted
}

After:

func f() {
	alreadyFormatted := func(
		first SomeRidiculouslyLongParameterTypeNameThatForcesLineBreakUnder80Columns,
		second AnotherRidiculouslyLongParameterTypeNameThatAlsoForcesLineBreak) {

		_ = first
		_ = second
	}

	_ = alreadyFormatted
}

Single-Return Control Blocks

Single-return control blocks stay tight:

Before:

if missingConfig &&
	allowDefault {

	return defaultConfig(), nil
}

After:

if missingConfig &&
	allowDefault {
	return defaultConfig(), nil
}

Blocks with additional work keep a separator after a multiline header:

Before:

if missingConfig &&
	allowDefault {
	log.Debug("using default config")
	return defaultConfig(), nil
}

After:

if missingConfig &&
	allowDefault {

	log.Debug("using default config")

	return defaultConfig(), nil
}

Calls and Chains

Long calls are packed instead of forced into one-argument-per-line layout:

Before:

result := buildPackage(sessionID, requestID, previousState, nextState, retryPolicy, clock)

After:

result := buildPackage(
	sessionID, requestID, previousState, nextState, retryPolicy,
	clock,
)

Method chains break at selector boundaries:

Before:

return client.Session(sessionID).WithPolicy(policy).WithClock(clock).Load(ctx)

After:

return client.
	Session(sessionID).
	WithPolicy(policy).
	WithClock(clock).
	Load(ctx)

Intentional No-Ops

Directive comments and unsafe-to-reflow regions are preserved:

Before:

//go:generate go run ./internal/tool
func generatedHook() {}

After:

//go:generate go run ./internal/tool
func generatedHook() {}

Comment-heavy expressions may be skipped rather than rewritten:

Before:

value := computeValue(
	input, // keep attached to input
	options,
)

After:

value := computeValue(
	input, // keep attached to input
	options,
)

CLI Flags

llformat [-w] [--wrap-inline-comments] [--comments MODE] [--col N] [--tab N] [--multiline-exclude FUNCS] [--logcalls-min-tail-len N] [--logcalls-selector-names NAMES] [--logcalls-selector-prefixes PREFIXES] [--fixpoint-iters N] <path>
llformat --print-plan
llformat --print-logcalls-patterns

Useful flags:

  • -w, --write: write the formatted result back to the source file
  • --col N: column limit, default 80
  • --tab N: tab stop width, default 8
  • --comments MODE: overflow, prose, or off, default overflow
  • --wrap-inline-comments: hoist trailing inline comments above statements so they can be wrapped safely
  • --multiline-exclude a,b,c: exclude function-name substrings from generic multiline call formatting
  • --logcalls-min-tail-len N: avoid leaving tiny tails when splitting long printf/log strings
  • --logcalls-selector-names n1,n2: override selector or identifier names to treat as printf/log calls
  • --logcalls-selector-prefixes p1,p2: restrict compact log/printf formatting to matching receiver expression prefixes
  • --fixpoint-iters N: repeat the full pipeline until stable, default 3
  • --print-plan: print the resolved stage plan and exit
  • --print-logcalls-patterns: print the active log/printf matching patterns and exit
  • --trace-dsl: trace applied DSL edits to stderr
  • --trace-dsl-reasons: trace DSL skip/apply reasons to stderr

Adoption Workflow

For a large repository, avoid starting with a blind formatting commit. A safer loop is:

  1. Build llformat.
  2. Run corpus diagnostics against the target repo.
  3. Inspect overflows, AST diffs, non-idempotence, and changed-line clusters.
  4. Convert clear failures into small formatter rules with neutral regression tests.
  5. Re-run diagnostics and compare reports.
  6. Once the remaining output is acceptable, introduce one mechanical baseline commit in the target repo.
  7. Put formatter invocation and generated-file excludes behind the target repo's normal make fmt / make fmt-check workflow.

This loop is intentionally designed to keep target-repo source out of formatter tests and reports. Regression tests in this repo should use neutral, synthetic examples.

Corpus Diagnostics

The corpus checker formats one or more repositories and writes redacted Markdown/JSON reports:

go run ./tools/corpus_check -repo /path/to/repo -out .corpus_reports/latest

The default adoption profile keeps normal safety excludes and skips common generated-file suffixes so reports focus on hand-maintained source. Use -profile all only when you intentionally want a broader scan.

The diagnostics are useful for:

  • finding new or moved long lines
  • identifying formatter-introduced overflows
  • spotting AST changes
  • finding non-idempotent formatting loops
  • ranking formatter bugs by frequency and review impact

How It Works

The formatter runs a pipeline of targeted stages, then runs gofmt:

  1. Comments
  2. Compact log/printf and string calls
  3. Selected expression rewrites
  4. Multiline non-log calls and method chains
  5. Function signatures
  6. Blank-line rules
  7. gofmt normalization

Most stages are implemented through an internal formatting DSL engine. The DSL applies deterministic AST-selected rewrites, tracks stage ownership boundaries, uses rewrite budgets, and detects cycles to avoid stage fighting.

Rules are conservative around comments. If rewriting a span would risk dropping or moving comments incorrectly, the formatter usually skips the edit.

For a deeper walkthrough, see ARCHITECTURE.md.

Tests

Run unit tests:

make unit

Run the full local check used before formatter commits:

make self-check
make lint

Golden Fixtures

Golden fixtures are authoritative and live at:

  • testdata/*/input.go -> testdata/*/output_next.go

Do not rewrite these fixtures as part of ordinary formatter work. If a behavior change appears to require golden updates, treat that as a spec change and get explicit maintainer direction first.

Candidate Goldens

For local experiments, generate candidate next outputs into a scratch directory:

make gen-next-goldens

This writes to .next_goldens/ and is not a substitute for reviewing or maintaining the authoritative golden fixtures.

Code Layout

  • cmd/llformat/main.go: CLI
  • formatter/: pipeline and formatting stages
  • dsl/: DSL engine, AST conditions, and rewrite actions
  • tools/corpus_check/: adoption diagnostics
  • tools/overflow_report/: focused overflow reporting
  • testdata/: authoritative golden fixtures

Status

The repository is next-only: legacy modes and legacy goldens have been removed. Behavior is specified through focused unit tests, corpus-derived regression tests, and golden fixtures.

The formatter is intended for controlled rollout. Run it in CI, inspect the diffs, and keep target-repo lint rules aligned with the formatter's ownership of spacing.

Disclaimer

This project was built with substantial AI assistance. It is provided as-is, without warranty of any kind, express or implied. The author assumes no responsibility for issues, bugs, or unintended behavior that may arise from using this software. Use at your own risk.

See LICENSE for the full MIT license terms.

About

Opinionated Go code formatter

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages