|
| 1 | +# columnar |
| 2 | + |
| 3 | +> elastic tabstop alignment for every language. |
| 4 | +
|
| 5 | +## why this exists |
| 6 | + |
| 7 | +i spent a while writing go, and somewhere along the way i got used to code that looked like this: |
| 8 | + |
| 9 | +```go |
| 10 | +var ( |
| 11 | + name = "Alice" |
| 12 | + age = 30 |
| 13 | + isActive = true |
| 14 | + emailAddr = "alice@example.com" |
| 15 | +) |
| 16 | +``` |
| 17 | + |
| 18 | +then i'd switch to a python or typescript file and everything would go back to looking like unaligned prose: |
| 19 | + |
| 20 | +```python |
| 21 | +name = "Alice" |
| 22 | +age = 30 |
| 23 | +is_active = True |
| 24 | +email_address = "alice@example.com" |
| 25 | +acceleration_due_to_gravity = 9.8 |
| 26 | +``` |
| 27 | + |
| 28 | +it's the same idea as gofmt's elastic tabstops, but language-agnostic. point it at almost any file and it lines up the columns: |
| 29 | + |
| 30 | +```python |
| 31 | +name = "Alice" |
| 32 | +age = 30 |
| 33 | +is_active = True |
| 34 | +email_address = "alice@example.com" |
| 35 | +acceleration_due_to_gravity = 9.8 |
| 36 | +``` |
| 37 | + |
| 38 | +that's it. that's the whole pitch. |
| 39 | + |
| 40 | +## what it does |
| 41 | + |
| 42 | +`columnar` reads a file, groups consecutive lines that share the same indent, splits each line into tab-separated cells, and runs them through go's [`text/tabwriter`](https://pkg.go.dev/text/tabwriter) to pad columns to a uniform width. blank lines and indent changes break groups so nested blocks don't stretch outer columns. |
| 43 | + |
| 44 | +it works across: |
| 45 | + |
| 46 | +- **c family** — java, javascript, typescript, c, c++, c#, kotlin, swift, dart, php |
| 47 | +- **scripting** — python, ruby, lua, shell (`bash`/`zsh`/`sh`) |
| 48 | +- **systems** — go, rust |
| 49 | +- **config** — makefile, toml, yaml, ini, `.properties`, `.env` |
| 50 | + |
| 51 | +…and falls back to plain text for anything else. |
| 52 | + |
| 53 | +the tokenizer is deliberately dumb — it only knows about comments, string literals, brackets, and whitespace. it never parses syntax, never joins or splits lines, never touches string contents, never rewrites comment text. the only thing it changes is horizontal whitespace. |
| 54 | + |
| 55 | +## examples |
| 56 | + |
| 57 | +**imports line up by their `from` clause:** |
| 58 | + |
| 59 | +```js |
| 60 | +// before |
| 61 | +import { useState, useEffect, useCallback } from "react"; |
| 62 | +import { Button, Input, Card } from "./components"; |
| 63 | +import { fetchUsers, createUser, deleteUser } from "./api"; |
| 64 | +import { formatDate } from "./utils"; |
| 65 | + |
| 66 | +// after |
| 67 | +import { useState, useEffect, useCallback } from "react"; |
| 68 | +import { Button, Input, Card } from "./components"; |
| 69 | +import { fetchUsers, createUser, deleteUser } from "./api"; |
| 70 | +import { formatDate } from "./utils"; |
| 71 | +``` |
| 72 | + |
| 73 | +**struct fields, map entries, switch cases, enum members, trailing comments** — all fall into columns the same way. poke around [`testdata/`](testdata/) for the full gallery. |
| 74 | + |
| 75 | +## installation |
| 76 | + |
| 77 | +depending on how you want to use it: |
| 78 | + |
| 79 | +- **homebrew (cli binary)** — see [`homebrew-columnar`](https://github.com/mudittt/homebrew-columnar). one-liner tap + install. |
| 80 | +- **vs code** — see [`vscode-columnar`](https://github.com/mudittt/vscode-columnar). format-on-save, keybindings, the usual. |
| 81 | +- **from source** — `go install github.com/mudittt/columnar@latest` |
| 82 | + |
| 83 | +## usage |
| 84 | + |
| 85 | +```bash |
| 86 | +columnar format path/to/file.py # print formatted output to stdout |
| 87 | +columnar format -w path/to/file.py # write in place |
| 88 | +columnar format -d path/to/file.py # check mode — exit 1 if it would change |
| 89 | +columnar format -l python path/to/file # force a language |
| 90 | +columnar format -c .columnar.json file.py # use a specific config |
| 91 | +``` |
| 92 | + |
| 93 | +if a `.columnar.json` exists in the working directory it's picked up automatically. |
| 94 | + |
| 95 | +## configuration |
| 96 | + |
| 97 | +a `.columnar.json` lets you toggle alignment features and tune spacing. all fields are optional — defaults turn everything on. |
| 98 | + |
| 99 | +```json |
| 100 | +{ |
| 101 | + "minColumnGap": 1, |
| 102 | + "maxColumnWidth": 80, |
| 103 | + "indentSize": 4, |
| 104 | + "alignAssignments": true, |
| 105 | + "alignOperators": true, |
| 106 | + "alignComments": true, |
| 107 | + "alignMethodChains": true, |
| 108 | + "alignTernary": true, |
| 109 | + "alignEnums": true, |
| 110 | + "alignSwitchCases": true, |
| 111 | + "alignMapEntries": true, |
| 112 | + "alignStructFields": true, |
| 113 | + "alignImports": true, |
| 114 | + "alignFunctionParams": true, |
| 115 | + "alignArrayColumns": true, |
| 116 | + "formatMultilineStrings": false, |
| 117 | + "languages": { |
| 118 | + "python": { "commentToken": "#" } |
| 119 | + } |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +## how it works, briefly |
| 124 | + |
| 125 | +the pipeline is deliberately small: |
| 126 | + |
| 127 | +1. [`cmd/format.go`](cmd/format.go) reads the file and detects the language from its extension (or `-l`). |
| 128 | +2. [`formatter.Format()`](internal/formatter/formatter.go) walks lines, tracking indent and multi-line-string state. |
| 129 | +3. [`formatter.Tokenize()`](internal/formatter/tokenizer.go) splits each line into cells using only generic rules (brackets are atomic, strings are atomic, line comments become the trailing cell). |
| 130 | +4. consecutive same-indent lines are grouped into a block, then flushed through `text/tabwriter`, which handles the elastic padding. |
| 131 | + |
| 132 | +language-specific group-break heuristics live in [`group_cfamily.go`](internal/formatter/group_cfamily.go), [`group_python.go`](internal/formatter/group_python.go), [`group_ruby.go`](internal/formatter/group_ruby.go), [`group_lua.go`](internal/formatter/group_lua.go), and [`group_shell.go`](internal/formatter/group_shell.go). they decide when two adjacent same-indent lines shouldn't share a column block (e.g. an `if` header shouldn't align with the assignment above it). |
| 133 | + |
| 134 | +**one invariant** runs through all of it: the formatter only changes whitespace. never the text, never the token order, never string or comment content. if `columnar` ever alters a character that isn't a space or tab, that's a bug. |
| 135 | + |
| 136 | +## development |
| 137 | + |
| 138 | +```bash |
| 139 | +go build ./... # build |
| 140 | +go test ./... # run all tests |
| 141 | +go run main.go format testdata/case01_assignments/before.py |
| 142 | +``` |
| 143 | + |
| 144 | +tests are fixture-based: drop a `before.<ext>` and `expected.<ext>` into a new `testdata/case*/` directory and the runner picks it up. every case is also run twice to verify idempotency — formatting an already-formatted file must be a no-op. |
| 145 | + |
| 146 | +## license |
| 147 | + |
| 148 | +[mit](LICENSE). |
0 commit comments