057. Source Metadata and Declaration Comments
- Status: Proposed
- Author: @ornew
- Date: 2026-10-14
Summary
Add common source spans and normalized comment ownership to source-bearing
public grammar nodes and serialize that metadata in a nested JSON source
object. Follow the comment-group ownership transitions of the Protocol Buffers
v32 tokenizer
(descriptor contract,
tokenizer tests),
while retaining PEGO's lexical rules. The concrete source grammar must construct
ownership directly; the manual parser must agree. No semantic-analysis or
post-parse attachment pass is involved.
Motivation and Goals
The public grammar AST should let documentation and tooling clients read a declaration's comments without inferring ownership from positions or formatter layout. A shared value keeps spans and normalized documentation together while raw comment markers and layout trivia remain available to the in-memory formatter.
- Add a common
SourceInfovalue to source-bearing grammar nodes and preserve source metadata through Go AST construction, JSON conversion and cloning. - Make normalized leading, trailing and detached comments directly available to API and documentation consumers.
- Keep raw formatter trivia separate from normalized ownership.
- Preserve existing diagnostic locations and grammar behavior.
Proposed Design
Public Source Information
type SourceInfo struct {
Layout *SourceLayout `json:"-"`
Pos Pos
End Pos
LeadingComments string
TrailingComments []string
LeadingDetachedComments []string
DiagnosticPos Pos
}
type SourceLayout struct {
Before []*Comment
PreviousLine int
NextLine int
}
Embed this value in source-bearing declarations, fields, expressions, actions,
attributes, pragmas, Operators Rule entries and explicit source grouping nodes.
End is exclusive; both positions use the existing line/column units. A span
is one continuous source interval: it excludes leading and trailing comments
owned at its boundaries, but includes comments inside the interval. Leading
values are normalized strings and trailing values are lists of them;
each detached entry is one comment paragraph. Normalized comment text preserves
newlines and conventional whitespace after comment markers. Empty values are
zero values and may be omitted in JSON. Clone detached slices rather than
sharing mutable backing storage.
Each source-bearing node exposes Source() *SourceInfo and satisfies
SourceNode. Diagnostic() returns DiagnosticPos when valid and otherwise
falls back to the span's Pos. This keeps operator-token diagnostics stable
when Pos becomes the source-span start. Grammar.Clone() is the grammar-level
deep-copy operation and copies all source metadata, including detached-comment
slices. SourceInfo has no clone method. The clone contract covers the
supported grammar AST and attribute argument value domain; it does not cover
arbitrary application values or cyclic maps and slices embedded in attribute
arguments. Shared AST subtrees stay shared in the clone, and AST pointer cycles
are preserved for Validate to report.
Keep the package name in File.Pkg.Name and use File.Pkg.SourceInfo for the
package clause span and owned comments. Use OperatorsRule.SkipSource for the
whole skip clause, from the skip keyword through its expression. Do not
retain the old PackagePos field.
Comment Collection and Ownership
The collector groups PEGO comment tokens and returns a boundary record with
trailing text for the previous token, detached groups, leading text for the next
token, and HasNext to indicate whether a next owner exists. Keep pending state
local to a parse production or transactionally restore it during backtracking;
do not use mutable global state. Apply the following transitions:
| Event | Collector action |
|---|---|
| File start | Disable previous-token attachment; no prior token exists. |
| Inline comment | Flush each comment on the line of the previous token immediately as a trailing group when a line break, a closing delimiter, ; or EOF follows the last of them (the last may be a line comment), and disable further trailing attachment. When a token follows them on that line, flush only the first as provisional trailing. A following comment-only run starts a new group; it never extends the inline comments. |
Consecutive // comments |
Combine into one group. |
| Block comment | Flush any pending group, then start a distinct group. Switching from block to line comments also flushes. Empty tokens such as /**/ still count as groups. |
| Blank line | Flush pending group, then disable previous-token attachment. |
| Flush with attachment enabled | The first eligible group trails the previous token and disables further trailing attachment; later flushed groups detach. |
| Flush without attachment | Record the group as detached. Without a blank-line flush, the final buffered group leads the next token. |
| Same-line ambiguity | Apply MaybeDetachComment when the next token is on the previous token's line, or a provisional trailing block ends on the next token's line. If exactly one group exists across trailing, detached and pending buffers, move it to detached rather than guessing. With multiple groups, retain the first eligible trailing group, detach intervening groups, and make the final pending group leading. |
| Closing delimiter or EOF | Flush by the same eligibility rule. Return detached groups even when HasNext is false; the owner constructor leaves them unattached and preserves original tokens as raw formatter trivia. |
The ownership mapping is part of the grammar's typed construction. Leading and detached declaration comments belong to the declaration node, not its name or body; trailing comments after its final token belong to that declaration. Comments before a list element belong to the element, not a separator. Nested expression boundaries belong to the expression production that returns them. Explicit Group nodes own delimiter-adjacent comments. Wrappers such as Capture, Atomic and Attributed follow their production's return path and must not duplicate ownership merely because spans share a boundary. The root grammar and manual parser must produce the same owner paths.
The executable fixture spec/language/testdata/comment-boundaries.pego
and focused test spec/language/comment_boundary_test.go
exercise the boundary record and package/rule owner construction directly. The
focused matrix covers 34 boundary cases across three backends and two position
units, plus direct package/rule construction and a generated typed Go consumer.
Detached values use typed []CommentText records; an adapter copies Text
into normalized strings. This fixture covers selected ownership construction,
not end-to-end public metadata or formatter migration. General typed-list
construction is separate and is not required for this design.
Reference Cases and Normalization
A standalone run after baz with no inline comment trails it when a closing
scope flushes the group. Without a blank line, the same run before another
statement leads that statement. An inline package comment is flushed
immediately; a later comment-only run is distinct and may be detached. A
following leading run belongs to the next declaration. At EOF, a final
ineligible group remains raw-only when there is no next owner.
Adjacent block groups can split across tokens: in corge /* trailing */ /* leading */ grault, the first group trails corge and the second leads
grault. A single ambiguous same-line group is detached. A multiline block
ending on the next token's line follows the same ambiguity check. These are
ownership transitions, not a claim that PEGO adopts every protobuf lexical
rule: PEGO's CR-only newlines, Unicode whitespace and nested block comments
remain governed by its own lexer.
For normalized text, remove only the // marker from line comments, retaining
following whitespace and the terminating newline. For block comments, remove
the outer delimiters, retain first-line content, and on later lines strip
indentation and the first * marker. Preserve encountered newlines and nested
comment delimiters as content. Raw comment markers and formatter layout remain
separate from normalized ownership; they are not a byte-for-byte copy of every
source whitespace detail.
JSON and Formatting
Serialize embedded node metadata in an explicit nested source object; package
and skip metadata use pkg.source and skipSource. Do not rely on Go
embedding to flatten fields. Keep AST type tags and position units. Older JSON
without these optional metadata objects decodes to zero values. Example:
{
"@type": "RuleDef",
"name": "B",
"source": {
"pos": {"line": 10, "col": 1},
"end": {"line": 10, "col": 18},
"leadingComments": " leading for B\n",
"trailingComments": [" B trailing\n"],
"leadingDetachedComments": [" detached for the next declaration\n"]
}
}
Raw formatter trivia is not serialized in source. Therefore AST JSON does not
promise enough layout information for lossless formatting after decode; a
future lossless archive needs a separate layout representation. On an in-memory
parsed AST, grammar.Format uses raw comments and layout metadata, does not
reconstruct ownership from positions, and must not move comments between
owners. Explicit TypeGroup and TermGroup source nodes keep comments and
boundary trivia inside parenthesized type and action expressions, distinguishing
the grouped node from its enclosed child. The formatter must preserve those owner paths while printing
parentheses. Parse/format/parse must preserve owner paths for supported nodes;
this guarantee does not apply to JSON-decoded ASTs without serialized layout.
The first interior-boundary formatter increment adds SourceInfo.Layout as a
raw-only adjunct. Before holds the comment tokens at that boundary, while
PreviousLine and NextLine preserve their physical line relationship to the
surrounding syntax. A nil layout uses canonical formatting. Layout is excluded
from JSON, and a SourceInfo containing only layout does not produce an empty
source object. Grammar.Clone() deep-copies the layout and its comment tokens.
The formatter emits each stored token at its boundary without deduplicating by
comment value. Normalized LeadingComments, TrailingComments and detached
paragraphs remain unchanged. When layout is present, changing those normalized
strings alone does not rewrite the emitted comment; update the raw layout tokens
or reparse the source to resynchronize it.
This increment preserves raw layout at selected Action Expression operand
boundaries, including unary operands and confirmed lambda expressions, and
before constructor field names. The 15 fixtures cover selected function
arguments, constructor fields and values, binary and binding right-hand
sides, unary prefixes and operands, lambda expressions, repeated identical
comments, multiline groups and calls, idempotence, cloning and JSON omission.
For example, formatting
rule main = "x" => int(/* argument */ "1") now keeps the comment inside the
call; previously it moved before the rule. Type expressions, rule headers,
member markers, closing delimiters and other source forms remain outside this
increment's coverage. The broader ownership and formatter migration remains
incomplete.
Direct grammar JSON preserves normalized source metadata. The current .pegoc
AST-bearing codec does not encode source positions or comments; loading or
saving that AST preserves the compiled grammar tree, not its source-document
ownership. This distinction does not change the .pegoc bytecode format.
Compatibility and Migration
Embedding SourceInfo preserves selectors but changes Go composite literals
that set Pos directly. Migrate AST builders, parser and generated-source
adapter, strict JSON schemas and fixtures, cloning, formatter tests, API docs and
documentation consumers together. Exact raw trivia remains an in-memory
formatting concern. Keep bytecode and runtime parsing behavior unchanged.
The first increment adds source spans and normalized ownership to the public AST,
nested JSON and manual parser while preserving diagnostics through
DiagnosticPos. The next increment must preserve parenthesized source structure
with source-only TypeGroup and TermGroup nodes. TypeGroup implements
TypeExpr; the TermGroup.Term field has type Term, and TermGroup
implements Term. Their source spans include the opening and closing
delimiters, while enclosed comments and layout stay owned within the group. Raw
layout is stored separately in OpenBreak, BodyBreak, CloseBreak and
OneLine; these fields preserve line placement around delimiters without
changing normalized source ownership. The compiler and generators lower these
wrappers transparently before semantic execution, so grouping adds no matching,
value-frame, type, or bytecode semantics. The canonical pego.pego parser must
construct these nodes directly. The first interior-boundary formatter increment
stores raw comment tokens in SourceLayout for selected Action Expression
operand boundaries and constructor field names. It preserves normalized
ownership and the JSON contract while keeping comments at their parsed physical
boundary; its scope and remaining formatter work are described under
JSON and Formatting.
The canonical pego.pego parser builds ownership for the package clause, type
declarations, rule declarations, struct fields, operators skip/operand/level/
operator lines and constructor field initializers. A statement cannot see the
boundary before it, so each boundary is recorded once after the item it
follows: the item's own trailing group (Source) and the SourceLead (leading
group, detached groups and whether an owner follows) of the next item (Next).
A list's first element takes its lead from the Head recorded after the
opening delimiter, or the start of the file. The comments on the line of the
delimiter belong to that head, since the delimiter is not a node and could not
own a trailing group: they lead the first element as if they stood on lines of
their own, and only a closing delimiter right after them keeps them with the
delimiter. The source adapter
(spec/language/internal/sourceast, Positions.Items) joins these records into
grammar.SourceInfo without moving a group between boundaries. Parity with the
hand-written frontend is tested over crafted comment cases and every grammar in
the repository.
Expression and type operands, member markers, closing delimiters, callee-open
boundaries and the type-expression header have no boundary of their own in the
canonical grammar. A comment at a token belongs to the outermost node that starts
(leading and detached groups) or ends (trailing group) at that token, and
pego.pego cannot say that without a wrapper node per expression. The grammar
instead keeps every comment-bearing gap between two tokens (File.Gaps): the
trailing, leading and detached groups of the gap, whether the trailing group
exists and whether a token follows. A gap node spans exactly the trivia, so the
gap a node starts at and the gap it ends at follow from the node's span. The
source adapter (spec/language/internal/sourceast, Positions.Claim) replays the claims the
hand-written parser makes as it completes nodes, inner before outer: the node
completed last at a token takes its groups and the inner node loses them, a gap
with no next token leads nobody, a node completed again by a later suffix keeps
what it took, and a node keeps its trailing groups before a comma separator unless the
gap after it has one, and after a ; the groups of the gap after it follow
the ones before it. No group moves between gaps. The same rule
covers statements, which complete outermost, and every operand, member marker,
closing delimiter and header of a rule expression, value expression or type.
Parity with the hand-written frontend, including the diagnostic position, is
tested for every node of the public AST over crafted cases, a comment inserted
at every offset of grammars with every kind of node, pairs of comments at random offsets and
every grammar in the repository.
The canonical tree has no public-AST adapter yet: the parity test builds the
nodes of the public AST (flat sequences, choices, joins and meets, the
pragma and attribute completions, hidden literals) from the canonical tree and
passes them to Claim, and that construction moves into the adapter with the
parser selection. Raw formatter state (SourceInfo.Layout) is still not
constructed by the canonical grammar. It needs a formatter gate: byte-identical
output on the existing format corpus and no lost comment. The focused fixtures
are an oracle for covered boundaries, not a substitute for that gate.
Alternatives Considered
- Duplicate comment fields on every node: rejected in favor of one shared metadata contract.
- Keep only formatter trivia: rejected because clients would have to infer ownership from positions and layout.
- Attach every group to the next node: rejected because it loses trailing and detached distinctions.
- Attach by physical order after parsing: rejected because blank lines and ambiguous same-line groups affect ownership; construction must capture the boundary directly.
- Use a node-identity side table: rejected because copying, JSON and source adapters can lose the association.
- Flatten JSON fields: rejected in favor of an explicit nested
sourceobject.
Testing
- Cover collector transitions, package/rule/field/list/expression owners, and
nested
TypeGroup/TermGroupowners; verify transparent lowering and retained source parentheses/comments in the parsed AST, CR/LF/CRLF, blank-line boundaries, adjacent blocks, empty comments, EOF and closing delimiters with and without a next owner. - Assert exact normalized text/newlines, block indentation and marker handling, spans, diagnostic token locations, and no duplicate ownership.
- Round-trip JSON with and without
source; verify strict and permissive decoders, zero-value omission and source metadata on every supported node. - Parse/format/parse in-memory ASTs and compare owner paths while checking raw comment placement. Test relocation around grouping nodes.
- For raw interior layout, verify token ownership at call arguments, constructor fields, binary and binding right-hand sides, and unary boundaries. Include repeated identical comment values, multiline groups and calls, formatting idempotence, clone isolation, and JSON omission of layout tokens and layout-only source objects.
- Test generated grammar construction, manual-parser parity, adapters, cloning, programmatic ASTs and documentation extraction.
Performance and Limitations
Measure parser allocations/bytes and JSON size/marshal cost on comment-free,
comment-heavy and detached-comment inputs. Do not present this contract as an
optimization. The paired harness called ParseGrammar; the permanent
BenchmarkSourceMetadata uses the same inputs with the underlying source
parser. The baseline binary was built from 54551c2 before comparison, and the
candidate binary used the current source. The repository self-grammar
input has SHA-256
5d66963a879cffe2de44165b4d47cef2487361e07f265807eb1f440c958d0b56. Five
alternating paired samples used Go 1.27.1 on darwin/arm64, Apple M3 Max,
GOMAXPROCS=16, with 500 ms per sample. The medians and pretty-formatted JSON
sizes were:
| Input | Source bytes | Parse ns/op before → after | B/op before → after | Allocs/op before → after | JSON bytes before → after |
|---|---|---|---|---|---|
| Small grammar | 26 | 1,290 → 1,401 | 2,488 → 3,080 | 19 → 19 | 423 → 1,408 |
Root pego.pego |
22,964 | 964,262 → 1,204,372 | 2,919,964 → 3,304,963 | 8,338 → 8,409 | 287,710 → 1,050,989 |
| Commented grammar (64 rules) | 5,848 | 79,332 → 103,200 | 138,608 → 175,768 | 1,172 → 2,068 | 8,527 → 44,612 |
These descriptive medians quantify source-parser and serialized-size costs for
the tested inputs; they are not a statistical-significance claim. They do not
measure parsing user input, JSON marshal latency, or runtime speed. Source spans
exclude leading/trailing boundary comments but include comments inside their
continuous interval; DiagnosticPos preserves a diagnostic token when it
differs from the span start. The package clause uses File.Pkg.SourceInfo while
retaining File.Pkg.Name as its string field; the Operators Rule skip
clause uses SkipSource. Raw markers and comments with no normalized owner
remain formatter trivia and are not preserved by JSON.
The common public metadata shape, manual-parser ownership and spans, normalized
JSON fields, and Grammar.Clone() are implemented and validated. The full root
suite, dedicated metadata tests, parser generation/tests, site tests/build and
independent review pass. The boundary fixture validates grammar-level boundary
and selected owner construction only. The transparent Type/Action grouping increment is also implemented and
validated, including owned boundary comments, strict and permissive JSON,
structural validation and equivalent generated code and parse results.
The in-progress raw interior-layout formatter increment is scoped to selected
unary/lambda value boundaries and constructor field names. Canonical metadata
construction in pego.pego covers statements, list elements and, through the
comment-bearing gaps between tokens and the claim in the source adapter, every
node of expressions and types, and matches the hand-written frontend on the
parity corpus. The public-AST adapter that builds those nodes from the canonical
tree and the raw formatter state remain follow-up scope. Keep the overall record
Proposed until those parts of the contract are implemented and validated.
Parenthesized Source Groups: Incremental Cost
A focused comparison against the first source-metadata increment isolates the
additional source-group retention, using Go 1.27.1 on an Apple M3 Max
(darwin/arm64), GOMAXPROCS=16, and five alternating 500 ms samples per
workload. Both binaries read identical inputs. The self-grammar fixture is
22,868 bytes with SHA-256
01aa2f4cab8192701792ddc6eadb8d38a2cff8fe9149bdfc7209f0b4ce5238d9;
it predates the pragma-adjacency change. These are source parsing and pretty
AST JSON measurements, not input parsing with a compiled grammar.
| Workload | Median Before → After (ns/op) | B/op Before → After | Allocations Before → After |
|---|---|---|---|
| Small | 1,573 → 1,517 | 3,080 → 3,080 | 19 → 19 |
| Self grammar | 1,270,874 → 1,336,694 | 3,304,893 → 3,305,511 | 8,409 → 8,417 |
| Comment-heavy | 119,783 → 105,870 | 175,768 → 175,768 | 2,068 → 2,068 |
The self-grammar gains 618 B/op, eight allocations, and 1,604 bytes in pretty JSON (1,050,885 → 1,052,489 bytes). Small and comment-heavy allocation and JSON sizes are unchanged. The unaffected time controls drift by -3.6% and -11.6%, so the self-grammar's +5.2% median timing difference does not establish an attributable speed change. Runtime matching and bytecode semantics are unchanged; grouped and ungrouped grammars have equal generated Go/TypeScript code and parsing results in the focused parity checks. Raw measurements remain outside Git.