Skip to content

Commit 55e14dd

Browse files
committed
feat: add testcases, readme.md
1 parent a422d6b commit 55e14dd

34 files changed

Lines changed: 324 additions & 1 deletion

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
*todo*.md
2-
*testdata/*
32
columnar
43
*.py
54
*.java

readme.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const name = "Alice";
2+
const age = 30;
3+
const isActive = true;
4+
const emailAddress = "alice@example.com";
5+
let maxRetryAttempts = 5;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
val name = "Alice"
2+
val age = 30
3+
val isActive = true
4+
val emailAddress = "alice@example.com"
5+
var maxRetryAttempts = 5
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const name = "Alice";
2+
const age = 30;
3+
const isActive = true;
4+
const emailAddress = "alice@example.com";
5+
let maxRetryAttempts = 5;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
val name = "Alice"
2+
val age = 30
3+
val isActive = true
4+
val emailAddress = "alice@example.com"
5+
var maxRetryAttempts = 5

testdata/case04_ternary/before.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const status = isActive ? "Active" : "Inactive";
2+
const role = isAdmin ? "Administrator" : "User";
3+
const access = hasPermission ? "Granted" : "Denied";
4+
const label = isImportant ? "URGENT" : "normal";
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const status = isActive ? "Active" : "Inactive";
2+
const role = isAdmin ? "Administrator" : "User";
3+
const access = hasPermission ? "Granted" : "Denied";
4+
const label = isImportant ? "URGENT" : "normal";

testdata/case05_comments/before.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const width = 100; // default width
2+
const height = 200; // default height
3+
const depthOfFieldFactor = 50; // affects blur
4+
const maxParticleCount = 1000; // performance limit
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const width = 100; // default width
2+
const height = 200; // default height
3+
const depthOfFieldFactor = 50; // affects blur
4+
const maxParticleCount = 1000; // performance limit

0 commit comments

Comments
 (0)