Skip to content

Commit b2ce132

Browse files
committed
feat(radls): structured docs in builtin hover + funcs doc infrastructure
Hover on a built-in function now shows: 1. The signature, as before. 2. A horizontal rule. 3. The structured prose description from docs/funcs/<name>.md. 4. The first rad example block. When no doc file exists for a builtin, hover falls back to the signature-only view that's been there all along. The migration is incremental: this commit lands the infrastructure plus docs for five high-value functions (print, len, range, abs, sort) to prove the path end-to-end. Migrating the remaining ~60 builtins is follow-up work that each just needs a new docs/funcs/<name>.md. Source of truth: docs/funcs/ - README.md explains the model: one file per function, required sections (# title, ## Signature, ## Parameters, ## Examples, ## Category), optional sections (## Notes, ## See also). File stem must match the function name and parse as a Rad identifier; contributor notes (README, scratch files) are filtered out by the codegen. - print.md, len.md, range.md, abs.md, sort.md as the starter set. Embedded copy: rts/embedded_funcs/ - Go's //go:embed needs files inside the Go package, so the authoritative docs/funcs/ tree is mirrored to rts/embedded_funcs/. TestFuncDocsSourceMatchesEmbedded gates drift between the two until the proper codegen pipeline lands - the build-time copy is a temporary stand-in that keeps the architecture honest. Runtime parser: rts/funcdocs.go + rts/funcdocs_loader.go - ParseFuncDoc parses the structured shape. Lenient about blank lines; strict about required sections. - FuncDoc struct: Name, Description, Signature, Parameters, Examples, Category, Notes, SeeAlso. - GetFuncDoc(name) returns the parsed doc lazily; FuncDocNames() enumerates registered docs. LSP integration: radls/analysis/hover.go - formatIdentHover branches on rts.GetFuncDoc for SymBuiltin: if the doc exists, render signature + '---' + description + first example. Otherwise fall through to the old signature-only path. - BuiltinShowsSignatureAndDocs snapshot in hover.snap locks the new shape for 'print'. Tests: - TestFuncDocsValid: every embedded doc parses cleanly. - TestFuncDocsMatchRegisteredBuiltins: docs only exist for real builtins (catches typo'd file names). - TestFuncDocsSignatureMatchesRegistered: the doc's signature line is identical to the registered builtin's signature - drift in either direction fails the test. - TestFuncDocsSourceMatchesEmbedded: docs/funcs/ and rts/embedded_funcs/ stay byte-identical. The reverse completeness test (every registered builtin has a doc) is intentionally NOT in this commit - it would force a 60-builtin migration into one PR. The check lands once the migration finishes.
1 parent 7396532 commit b2ce132

17 files changed

Lines changed: 1065 additions & 2 deletions

File tree

core/testing/funcdocs_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package testing
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/amterp/rad/rts"
10+
)
11+
12+
// TestFuncDocsValid verifies every docs/funcs/*.md file in the
13+
// embedded set parses cleanly through ParseFuncDoc. Catches
14+
// authors landing a malformed doc that the hover layer would
15+
// silently drop.
16+
func TestFuncDocsValid(t *testing.T) {
17+
names := rts.FuncDocNames()
18+
if len(names) == 0 {
19+
t.Skip("no embedded func docs yet")
20+
}
21+
for _, name := range names {
22+
name := name
23+
t.Run(name, func(t *testing.T) {
24+
doc := rts.GetFuncDoc(name)
25+
if doc == nil {
26+
t.Fatalf("FuncDocNames listed %q but GetFuncDoc returned nil", name)
27+
}
28+
if doc.Name != name {
29+
t.Errorf("name mismatch: stem=%q, doc.Name=%q", name, doc.Name)
30+
}
31+
if doc.Signature == "" {
32+
t.Errorf("%s: empty signature", name)
33+
}
34+
if !strings.Contains(doc.Signature, name+"(") {
35+
t.Errorf("%s: signature %q doesn't start with the function name",
36+
name, doc.Signature)
37+
}
38+
if len(doc.Examples) == 0 {
39+
t.Errorf("%s: no example code blocks", name)
40+
}
41+
if doc.Category == "" {
42+
t.Errorf("%s: empty category", name)
43+
}
44+
})
45+
}
46+
}
47+
48+
// TestFuncDocsMatchRegisteredBuiltins verifies that every embedded
49+
// doc names a function the runtime actually registers. Catches the
50+
// reverse-drift case: a doc author renames the file to `say.md`
51+
// while the runtime still exposes `print`.
52+
//
53+
// Note: the opposite assertion - every registered builtin has a
54+
// doc - is intentionally NOT in this test yet. The doc migration
55+
// is incremental; gating CI on 100% coverage would block landing
56+
// any incremental work. The completeness assertion will land once
57+
// the migration finishes.
58+
func TestFuncDocsMatchRegisteredBuiltins(t *testing.T) {
59+
for _, name := range rts.FuncDocNames() {
60+
if _, ok := rts.FnSignaturesByName[name]; !ok {
61+
t.Errorf("doc exists for %q but no such builtin is registered", name)
62+
}
63+
}
64+
}
65+
66+
// TestFuncDocsSignatureMatchesRegistered verifies the signature
67+
// line in each doc matches the registered builtin's signature
68+
// byte-for-byte. Catches the case where a doc author updates the
69+
// signature in source without updating the doc (or vice versa).
70+
func TestFuncDocsSignatureMatchesRegistered(t *testing.T) {
71+
for _, name := range rts.FuncDocNames() {
72+
doc := rts.GetFuncDoc(name)
73+
sig, ok := rts.FnSignaturesByName[name]
74+
if !ok {
75+
continue // covered by TestFuncDocsMatchRegisteredBuiltins
76+
}
77+
if doc.Signature != sig.Signature {
78+
t.Errorf("%s: doc signature %q != registered %q",
79+
name, doc.Signature, sig.Signature)
80+
}
81+
}
82+
}
83+
84+
// TestFuncDocsSourceMatchesEmbedded verifies the source docs at
85+
// docs/funcs/ are in sync with the embedded copy in
86+
// rts/embedded_funcs/. The embedded files are the artefact the
87+
// runtime actually reads; the docs/funcs/ tree is the canonical
88+
// editable source. Manual sync today; codegen later.
89+
func TestFuncDocsSourceMatchesEmbedded(t *testing.T) {
90+
sourceDir := "../../docs/funcs"
91+
embeddedDir := "../../rts/embedded_funcs"
92+
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
93+
t.Skipf("source dir %s missing", sourceDir)
94+
}
95+
if _, err := os.Stat(embeddedDir); os.IsNotExist(err) {
96+
t.Skipf("embedded dir %s missing", embeddedDir)
97+
}
98+
99+
sources, err := collectDocSet(sourceDir)
100+
if err != nil {
101+
t.Fatalf("read source: %v", err)
102+
}
103+
embedded, err := collectDocSet(embeddedDir)
104+
if err != nil {
105+
t.Fatalf("read embedded: %v", err)
106+
}
107+
108+
for name, content := range sources {
109+
if name == "README.md" {
110+
continue
111+
}
112+
emb, ok := embedded[name]
113+
if !ok {
114+
t.Errorf("%s exists in docs/funcs/ but not in rts/embedded_funcs/", name)
115+
continue
116+
}
117+
if string(content) != string(emb) {
118+
t.Errorf("%s differs between docs/funcs/ and rts/embedded_funcs/", name)
119+
}
120+
}
121+
for name := range embedded {
122+
if _, ok := sources[name]; !ok {
123+
t.Errorf("%s exists in rts/embedded_funcs/ but not in docs/funcs/", name)
124+
}
125+
}
126+
}
127+
128+
func collectDocSet(dir string) (map[string][]byte, error) {
129+
out := make(map[string][]byte)
130+
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
131+
if err != nil {
132+
return err
133+
}
134+
base := filepath.Base(path)
135+
if info.IsDir() || !strings.HasSuffix(base, ".md") {
136+
return nil
137+
}
138+
content, err := os.ReadFile(path)
139+
if err != nil {
140+
return err
141+
}
142+
out[base] = content
143+
return nil
144+
})
145+
return out, err
146+
}

docs/funcs/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# `docs/funcs/`: source of truth for built-in function docs
2+
3+
These markdown files are the canonical documentation for Rad's
4+
built-in functions. Each file describes exactly one function. Two
5+
downstream consumers read this directory at build time:
6+
7+
1. `tools/gen-funcs-go/main.go` generates `rts/funcs_gen.go`, the
8+
in-memory map the LSP hover layer reads from to show a function's
9+
description alongside its signature.
10+
2. `tools/gen-funcs-page/main.go` regenerates
11+
`docs-web/docs/reference/functions.md`, the public-facing
12+
functions reference. The aggregate page stays a derived artifact -
13+
editing it directly invites drift.
14+
15+
Treat these `.md` files as the single source. Editing the generated
16+
artifacts directly is a one-way ticket to mismatched docs.
17+
18+
## File naming
19+
20+
One file per function, named `<fn>.md` where `<fn>` is the function's
21+
name in Rad source (`print.md`, `range.md`, `parse_int.md`). The
22+
codegen skips this `README.md` explicitly and any file whose stem
23+
doesn't match the identifier rule `[a-z_][a-z0-9_]*`, so contributor
24+
notes (`scratch.txt`, `2025-plan.md`) won't be picked up.
25+
26+
Internal `_rad_*` builtins do not belong here. Add their docs in the
27+
`docs/funcs/internal/` subdirectory if they need any documentation
28+
at all - the codegen ignores that path so the public surface stays
29+
clean.
30+
31+
## Required sections
32+
33+
Every file must contain these sections in this order:
34+
35+
```markdown
36+
# <fn>
37+
38+
Short one-paragraph description.
39+
40+
## Signature
41+
42+
`<fn>(...) -> <return_type>`
43+
44+
## Parameters
45+
46+
- `param_name` (`type`): description
47+
48+
## Examples
49+
50+
\`\`\`rad
51+
example_code()
52+
\`\`\`
53+
54+
## Category
55+
56+
<one word>
57+
```
58+
59+
`# <fn>` is the H1 title. The function name has to match the file
60+
stem - codegen rejects mismatches.
61+
62+
`## Signature` holds exactly one line of inline-code: the function's
63+
signature in `signatures.go`'s syntax. The codegen parses this
64+
through the same signature parser as the registered fns, so a typo
65+
fails the doc test.
66+
67+
`## Parameters` lists each positional / keyword parameter. Order
68+
matches the signature.
69+
70+
`## Examples` holds one or more rad code blocks. The first block is
71+
what hover renders inline; later blocks appear in the public docs
72+
page.
73+
74+
`## Category` is a single word the public docs use to group
75+
functions ("io", "strings", "lists", "math", "time", "random",
76+
"shell", "system").
77+
78+
## Optional sections
79+
80+
- `## Notes` - call out edge cases or related concepts.
81+
- `## See also` - link to related fns by name.
82+
83+
## Tests
84+
85+
`core/testing/funcs_codegen_test.go` validates the doc set on every
86+
test run:
87+
88+
- Every `.md` parses cleanly into the structured shape above.
89+
- Every signature line parses through `signatures.go`'s parser.
90+
- Re-running both generators into tempdirs and diffing against the
91+
committed outputs fails if either generator's output drifts.
92+
- (Eventual goal) Every registered builtin has a `.md`. While the
93+
migration is in progress this is opt-in.

docs/funcs/abs.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# abs
2+
3+
Returns the absolute value of a number. The result's type matches
4+
the input - `int` in, `int` out; `float` in, `float` out.
5+
6+
## Signature
7+
8+
`abs(_num: int|float) -> int|float`
9+
10+
## Parameters
11+
12+
- `_num` (`int|float`): the number to take the absolute value of.
13+
14+
## Examples
15+
16+
```rad
17+
abs(-5) // -> 5
18+
abs(5) // -> 5
19+
abs(-3.14) // -> 3.14
20+
abs(0) // -> 0
21+
```
22+
23+
## Category
24+
25+
math
26+
27+
## See also
28+
29+
`floor`, `ceil`, `round`

docs/funcs/len.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# len
2+
3+
Returns the number of elements in a string, list, or map. For
4+
strings this is the rune count (not byte count), so unicode characters
5+
contribute one each.
6+
7+
## Signature
8+
9+
`len(_val: str|list|map) -> int`
10+
11+
## Parameters
12+
13+
- `_val` (`str|list|map`): the collection to measure.
14+
15+
## Examples
16+
17+
```rad
18+
len("hello") // -> 5
19+
len([1, 2, 3]) // -> 3
20+
len({"a": 1, "b": 2}) // -> 2
21+
len("héllo") // -> 5 (rune count, not byte count)
22+
```
23+
24+
## Category
25+
26+
lists
27+
28+
## See also
29+
30+
`sort`, `keys`, `values`

docs/funcs/print.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# print
2+
3+
Writes its arguments to stdout, separated by a space, followed by a
4+
newline. The default workhorse for output. For error output, use
5+
`print_err`; for structured pretty-printing, use `pprint`.
6+
7+
## Signature
8+
9+
`print(*_items: any, *, sep: str = " ", end: str = "\n") -> void`
10+
11+
## Parameters
12+
13+
- `_items` (variadic `any`): values to print. Each is converted to
14+
its string form via the default formatter.
15+
- `sep` (`str`, keyword-only, default `" "`): separator inserted
16+
between consecutive items.
17+
- `end` (`str`, keyword-only, default `"\n"`): trailing text after
18+
the last item. Set to `""` to print without a newline.
19+
20+
## Examples
21+
22+
```rad
23+
print("hello", "world") // -> hello world
24+
print(1, 2, 3, sep=", ") // -> 1, 2, 3
25+
print("no newline", end="") // -> no newline
26+
```
27+
28+
## Category
29+
30+
io
31+
32+
## See also
33+
34+
`print_err`, `pprint`, `debug`

docs/funcs/range.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# range
2+
3+
Returns a list of numbers covering the half-open interval
4+
`[start, stop)`. With one argument, `start` defaults to 0. The list
5+
type matches the inputs - all ints produces an `int[]`, any float
6+
produces a `float[]`.
7+
8+
## Signature
9+
10+
`range(_arg1: float|int, _arg2: (float|int)?, _step: float|int = 1) -> float[]|int[]`
11+
12+
## Parameters
13+
14+
- `_arg1` (`float|int`): when `_arg2` is null, this is the upper
15+
bound `stop` and `start` is implicitly 0. When `_arg2` is set,
16+
this is the `start`.
17+
- `_arg2` (optional `float|int`): the upper bound `stop`. The
18+
returned list never includes this value.
19+
- `_step` (`float|int`, default `1`): the spacing between
20+
successive values. Must be non-zero; negative steps count down
21+
when `start > stop`.
22+
23+
## Examples
24+
25+
```rad
26+
range(5) // -> [0, 1, 2, 3, 4]
27+
range(1, 5) // -> [1, 2, 3, 4]
28+
range(0, 1, 0.25) // -> [0.0, 0.25, 0.5, 0.75]
29+
range(10, 0, -2) // -> [10, 8, 6, 4, 2]
30+
```
31+
32+
## Category
33+
34+
math
35+
36+
## See also
37+
38+
`for`, `len`

0 commit comments

Comments
 (0)