Skip to content

Commit 4ade0bd

Browse files
committed
feat(fmt): add 'rad fmt' canonical formatter
Add a gofmt-style formatter exposed as `rad fmt`: it reparses a script and re-emits it in one canonical style, so layout stops being a thing anyone argues about and can be enforced in CI. Modes follow gofmt/prettier convention: rewrite in place by default, --stdout to preview, --check to exit non-zero when a file isn't already formatted, and read from stdin when piped (for editor integration). The formatter works on the tree-sitter CST, not the typed AST, because the AST drops comments and the shebang and so can't round-trip. Layout is built with a Prettier-style Doc IR that wraps long calls and collections to fit a width target; construct formatters turn CST nodes into Docs. Safety is the central constraint - a formatter must never corrupt code. Three layers enforce it: a no-op on parse errors, a panic recover, and a structural-equivalence guard that reparses the output and refuses it unless the named-node tree and comment count are unchanged. Anything the guard rejects degrades to returning the input untouched. This ships the common constructs (assignments, expressions, calls, paths, collections, if/for/while, comments, blank lines). Constructs not yet handled - string quote-normalization, args/rad/cmd blocks, functions, switch - fall back to emitting their original source verbatim, which the guard proves safe, so the rest can land incrementally without risking valid scripts. Wired in like the existing `check` command: an embedded Rad script drives IO and flags over a pure `_rad_fmt` builtin backed by the new rts/radfmt package. DESIGN.md vendors the design-time research.
1 parent 60fdaa5 commit 4ade0bd

26 files changed

Lines changed: 2750 additions & 0 deletions

core/cmds.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
EmbCmdGenId = "gen-id"
1919
EmbCmdStash = "stash"
2020
EmbCmdCheck = "check"
21+
EmbCmdFmt = "fmt"
2122
EmbCmdExplain = "explain"
2223
)
2324

@@ -43,6 +44,7 @@ func init() {
4344
createEmbeddedCmd(EmbCmdNew),
4445
createEmbeddedCmd(EmbCmdDocs),
4546
createEmbeddedCmd(EmbCmdCheck),
47+
createEmbeddedCmd(EmbCmdFmt),
4648
createEmbeddedCmd(EmbCmdHome),
4749
createEmbeddedCmd(EmbCmdGenId),
4850
createEmbeddedCmd(EmbCmdStash),

core/embedded/fmt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env rad
2+
---
3+
Formats Rad scripts.
4+
5+
Rewrites each script in place into Rad's canonical style. Use --stdout to
6+
preview without writing, or --check to verify formatting (e.g. in CI).
7+
---
8+
args:
9+
*paths str # Paths of Rad scripts to format.
10+
stdout bool # Print formatted output to stdout instead of writing files.
11+
check bool # Don't write; exit non-zero if any file isn't formatted.
12+
13+
stdout excludes check
14+
15+
// No paths + piped stdin: format stdin to stdout (editor / pipe friendly).
16+
if not paths and has_stdin():
17+
result = _rad_fmt(read_stdin())
18+
if not result.ok:
19+
print_err("stdin: could not parse.")
20+
exit(1)
21+
if check:
22+
// In check mode, don't emit the formatted text; just signal via exit code.
23+
exit(result.changed)
24+
print(result.formatted, end="")
25+
exit()
26+
27+
if not paths:
28+
print_err("Error: provide at least one path to format (or pipe a script via stdin).")
29+
exit(1)
30+
31+
any_unformatted = false
32+
any_error = false
33+
34+
for path in paths:
35+
file = read_file(path) catch:
36+
pass
37+
if type_of(file) == "error":
38+
print_err("{path}: {file}")
39+
any_error = true
40+
continue
41+
42+
result = _rad_fmt(file.content)
43+
if not result.ok:
44+
print_err("{path}: could not parse, skipping.")
45+
any_error = true
46+
continue
47+
48+
if stdout:
49+
print(result.formatted, end="")
50+
else if check:
51+
if result.changed:
52+
print(path)
53+
any_unformatted = true
54+
else if result.changed:
55+
write = write_file(path, result.formatted) catch:
56+
pass
57+
if type_of(write) == "error":
58+
print_err("{path}: {write}")
59+
any_error = true
60+
continue
61+
print("Formatted {path}")
62+
63+
if any_error:
64+
exit(1)
65+
if check and any_unformatted:
66+
exit(1)

core/funcs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const (
137137
INTERNAL_FUNC_GET_STASH_ID = "_rad_get_stash_id"
138138
INTERNAL_FUNC_DELETE_STASH = "_rad_delete_stash"
139139
INTERNAL_FUNC_RUN_CHECK = "_rad_run_check"
140+
INTERNAL_FUNC_FMT = "_rad_fmt"
140141
INTERNAL_FUNC_CHECK_FROM_LOGS = "_rad_check_from_logs"
141142
INTERNAL_FUNC_EXPLAIN = "_rad_explain"
142143
INTERNAL_FUNC_EXPLAIN_LIST = "_rad_explain_list"

core/funcs_internal.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
com "github.com/amterp/rad/core/common"
99
"github.com/amterp/rad/rts/check"
10+
radfmt "github.com/amterp/rad/rts/radfmt"
1011
"github.com/amterp/rad/rts/rl"
1112

1213
"github.com/amterp/rad/rts"
@@ -124,6 +125,19 @@ func AddInternalFuncs() {
124125
return newRadValues(f.i, f.callNode, radMap)
125126
},
126127
},
128+
{
129+
Name: INTERNAL_FUNC_FMT,
130+
Execute: func(f FuncInvocation) RadValue {
131+
// Format normalizes line endings itself, so pass the raw source.
132+
formatted, changed, ok := radfmt.Format(f.GetStr("_src").Plain())
133+
134+
radMap := NewRadMap()
135+
radMap.SetPrimitiveStr("formatted", formatted)
136+
radMap.SetPrimitiveBool("changed", changed)
137+
radMap.SetPrimitiveBool("ok", ok)
138+
return newRadValues(f.i, f.callNode, radMap)
139+
},
140+
},
127141
FuncInternalCheckFromLogs,
128142
{
129143
Name: INTERNAL_FUNC_EXPLAIN,

core/testing/snapshots/args/cmds.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ Commands:
421421
completion Generate shell tab-completion scripts.
422422
docs Opens rad's documentation website.
423423
explain Explains Rad error codes with detailed documentation.
424+
fmt Formats Rad scripts.
424425
gen-id Generates a unique string ID. Useful for e.g. rad stash IDs.
425426
home Prints out rad's home directory.
426427
new Sets up a new Rad script.

core/testing/snapshots/misc/misc.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Commands:
134134
completion Generate shell tab-completion scripts.
135135
docs Opens rad's documentation website.
136136
explain Explains Rad error codes with detailed documentation.
137+
fmt Formats Rad scripts.
137138
gen-id Generates a unique string ID. Useful for e.g. rad stash IDs.
138139
home Prints out rad's home directory.
139140
new Sets up a new Rad script.
@@ -223,6 +224,7 @@ Commands:
223224
completion Generate shell tab-completion scripts.
224225
docs Opens rad's documentation website.
225226
explain Explains Rad error codes with detailed documentation.
227+
fmt Formats Rad scripts.
226228
gen-id Generates a unique string ID. Useful for e.g. rad stash IDs.
227229
home Prints out rad's home directory.
228230
new Sets up a new Rad script.

0 commit comments

Comments
 (0)