Skip to content

Commit ddab6cc

Browse files
committed
unified: Emit locations for comments
Also gathers these into a separate side-channel during the initial AST construction. That way, we don't encounter these as weird extra nodes while running yeast.
1 parent 8909fae commit ddab6cc

3 files changed

Lines changed: 179 additions & 28 deletions

File tree

unified/swift-syntax-rs/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,20 @@ rewrite rules operate on — via [`yeast_adapter::json_to_ast`](src/yeast_adapte
177177

178178
```rust
179179
let json = swift_syntax_rs::parse_to_json("let x = 1")?;
180-
let ast = swift_syntax_rs::yeast_adapter::json_to_ast(&json)?;
180+
let adapted = swift_syntax_rs::yeast_adapter::json_to_ast(&json)?;
181+
let ast = adapted.ast; // the yeast::Ast
182+
let comments = adapted.trivia; // side-channel comment/unexpectedText tokens
181183
```
182184

183185
The adapter mirrors tree-sitter's node model, which is what yeast expects:
184186
layout nodes and varying tokens (identifiers, literals, operators) become
185187
**named** nodes; fixed tokens (keywords, punctuation) become **anonymous**
186-
nodes keyed by their text. It preserves swift-syntax's own kind/field names —
187-
aligning them with the tree-sitter-swift schema so the existing rewrite rules
188-
fire is a separate, later step.
188+
nodes keyed by their text. Comments (and `unexpectedText`) are harvested into a
189+
side channel (`adapted.trivia`) during the same traversal rather than embedded
190+
in the tree, matching how the extractor treats tree-sitter `extra` nodes. It
191+
preserves swift-syntax's own kind/field names — aligning them with the
192+
tree-sitter-swift schema so the existing rewrite rules fire is a separate,
193+
later step.
189194

190195
## Layout
191196

unified/swift-syntax-rs/src/yeast_adapter.rs

Lines changed: 134 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,29 @@ use serde_json::Value;
2525
use yeast::schema::Schema;
2626
use yeast::{Ast, Id, NodeContent, Point, Range};
2727

28+
/// A comment (or `unexpectedText`) recovered from the syntax tree's trivia.
29+
///
30+
/// These are collected into a side channel rather than embedded in the
31+
/// [`yeast::Ast`], mirroring how the extractor treats tree-sitter `extra`
32+
/// nodes: they carry a location and text but are not attached to a parent.
33+
#[derive(Debug, Clone, PartialEq, Eq)]
34+
pub struct TriviaToken {
35+
/// The trivia kind (e.g. `lineComment`, `blockComment`, `docLineComment`,
36+
/// `docBlockComment`, `unexpectedText`).
37+
pub kind: String,
38+
/// The verbatim source text of the piece (e.g. `// comment`).
39+
pub text: String,
40+
/// The source range the piece occupies.
41+
pub range: Range,
42+
}
43+
44+
/// The result of adapting a swift-syntax JSON tree: the [`yeast::Ast`] plus the
45+
/// comment/`unexpectedText` trivia harvested from it (in source order).
46+
pub struct AdaptedTree {
47+
pub ast: Ast,
48+
pub trivia: Vec<TriviaToken>,
49+
}
50+
2851
/// swift-syntax `TokenKind` cases whose text is *not* determined by the kind
2952
/// (i.e. `TokenKind.defaultText == nil`). These carry varying information and
3053
/// are modelled as named leaf nodes; every other token is a fixed
@@ -142,16 +165,19 @@ fn children_of(value: &Value) -> Vec<&Value> {
142165
///
143166
/// This is a single traversal: each node's kind and field names are registered
144167
/// in the schema on the fly, immediately before the node is created. Children
145-
/// are built first so a parent's field lists reference existing ids.
146-
fn build(node: &Value, ast: &mut Ast) -> Result<Id, String> {
168+
/// are built first so a parent's field lists reference existing ids. Any
169+
/// comment/`unexpectedText` trivia carried by a token is harvested into
170+
/// `trivia` during the same pass rather than embedded in the tree.
171+
fn build(node: &Value, ast: &mut Ast, trivia: &mut Vec<TriviaToken>) -> Result<Id, String> {
147172
let info = classify(node)?;
173+
collect_trivia(node, trivia);
148174

149175
let mut fields: BTreeMap<u16, Vec<Id>> = BTreeMap::new();
150176
for (field, value) in field_entries(node) {
151177
let field_id = ast.register_field(field);
152178
let mut ids = Vec::new();
153179
for child in children_of(value) {
154-
ids.push(build(child, ast)?);
180+
ids.push(build(child, ast, trivia)?);
155181
}
156182
fields.insert(field_id, ids);
157183
}
@@ -171,6 +197,35 @@ fn build(node: &Value, ast: &mut Ast) -> Result<Id, String> {
171197
))
172198
}
173199

200+
/// Harvest a token's `leadingTrivia`/`trailingTrivia` pieces (each already
201+
/// filtered to comments/`unexpectedText` upstream) into `out`. Non-token nodes
202+
/// have no trivia keys, so this is a no-op for them.
203+
fn collect_trivia(node: &Value, out: &mut Vec<TriviaToken>) {
204+
for key in ["leadingTrivia", "trailingTrivia"] {
205+
let Some(Value::Array(pieces)) = node.get(key) else {
206+
continue;
207+
};
208+
for piece in pieces {
209+
let (Some(kind), Some(range)) = (
210+
piece.get("kind").and_then(Value::as_str),
211+
parse_range(piece),
212+
) else {
213+
continue;
214+
};
215+
let text = piece
216+
.get("text")
217+
.and_then(Value::as_str)
218+
.unwrap_or("")
219+
.to_string();
220+
out.push(TriviaToken {
221+
kind: kind.to_string(),
222+
text,
223+
range,
224+
});
225+
}
226+
}
227+
}
228+
174229
/// Parse a node's `range` into a [`yeast::Range`].
175230
///
176231
/// The JSON carries, for `start` and `end`, a 0-based UTF-8 file byte `offset`,
@@ -201,14 +256,20 @@ fn parse_range(node: &Value) -> Option<Range> {
201256
}
202257

203258
/// Convert a swift-syntax JSON tree (as produced by [`crate::parse_to_json`])
204-
/// into a [`yeast::Ast`].
205-
pub fn json_to_ast(json: &str) -> Result<Ast, String> {
259+
/// into a [`yeast::Ast`] plus the comment/`unexpectedText` trivia harvested
260+
/// from it. Both are produced in a single traversal.
261+
pub fn json_to_ast(json: &str) -> Result<AdaptedTree, String> {
206262
let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
207263

208264
let mut ast = Ast::with_schema(Schema::new());
209-
let root_id = build(&root, &mut ast)?;
265+
let mut trivia = Vec::new();
266+
let root_id = build(&root, &mut ast, &mut trivia)?;
210267
ast.set_root(root_id);
211-
Ok(ast)
268+
269+
// Emit trivia in source order (the traversal visits nodes bottom-up).
270+
trivia.sort_by_key(|t| t.range.start_byte);
271+
272+
Ok(AdaptedTree { ast, trivia })
212273
}
213274

214275
#[cfg(test)]
@@ -245,7 +306,9 @@ mod tests {
245306

246307
#[test]
247308
fn builds_ast_from_json() {
248-
let ast = json_to_ast(sample_json()).expect("adapter should succeed");
309+
let ast = json_to_ast(sample_json())
310+
.expect("adapter should succeed")
311+
.ast;
249312
let root = ast.get_root();
250313
let root_node = ast.get_node(root).expect("root exists");
251314
assert_eq!(root_node.kind_name(), "sourceFile");
@@ -254,7 +317,9 @@ mod tests {
254317

255318
#[test]
256319
fn classifies_named_and_anonymous_tokens() {
257-
let ast = json_to_ast(sample_json()).expect("adapter should succeed");
320+
let ast = json_to_ast(sample_json())
321+
.expect("adapter should succeed")
322+
.ast;
258323
// Walk all nodes and collect (kind_name, is_named) for the two tokens.
259324
let mut let_named = None;
260325
let mut ident_named = None;
@@ -273,7 +338,9 @@ mod tests {
273338

274339
#[test]
275340
fn preserves_leaf_text() {
276-
let ast = json_to_ast(sample_json()).expect("adapter should succeed");
341+
let ast = json_to_ast(sample_json())
342+
.expect("adapter should succeed")
343+
.ast;
277344
let ident = ast
278345
.nodes()
279346
.iter()
@@ -286,7 +353,9 @@ mod tests {
286353

287354
#[test]
288355
fn maps_source_locations() {
289-
let ast = json_to_ast(sample_json()).expect("adapter should succeed");
356+
let ast = json_to_ast(sample_json())
357+
.expect("adapter should succeed")
358+
.ast;
290359
let ident = ast
291360
.nodes()
292361
.iter()
@@ -300,13 +369,55 @@ mod tests {
300369
assert_eq!(ident.end_position(), Point::new(0, 5));
301370
}
302371

372+
#[test]
373+
fn collects_trivia_into_side_channel() {
374+
// A token carrying a trailing line comment in its trivia.
375+
let json = r#"{
376+
"kind": "sourceFile",
377+
"range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":14,"line":1,"column":15}},
378+
"value": {
379+
"kind": "token",
380+
"tokenKind": "integerLiteral(\"1\")",
381+
"text": "1",
382+
"range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":1,"line":1,"column":2}},
383+
"trailingTrivia": [
384+
{
385+
"kind": "lineComment",
386+
"text": "// c",
387+
"range": {"start":{"offset":2,"line":1,"column":3},"end":{"offset":6,"line":1,"column":7}}
388+
}
389+
]
390+
}
391+
}"#;
392+
let adapted = json_to_ast(json).expect("adapter should succeed");
393+
394+
// The comment is in the side channel, with its text and location.
395+
assert_eq!(adapted.trivia.len(), 1);
396+
let comment = &adapted.trivia[0];
397+
assert_eq!(comment.kind, "lineComment");
398+
assert_eq!(comment.text, "// c");
399+
assert_eq!(comment.range.start_byte, 2);
400+
assert_eq!(comment.range.end_byte, 6);
401+
402+
// It is not embedded in the AST as a node.
403+
assert!(
404+
adapted
405+
.ast
406+
.nodes()
407+
.iter()
408+
.all(|n| n.kind_name() != "lineComment"),
409+
"comment should not appear as an AST node"
410+
);
411+
}
412+
303413
/// End-to-end: real Swift source parsed by the shim, then adapted into a
304414
/// `yeast::Ast`. Requires the Swift toolchain (like the crate's FFI tests).
305415
#[test]
306416
fn end_to_end_from_swift_source() {
307-
let json = crate::parse_to_json("func f(n: Int) -> Int { return n }")
417+
let json = crate::parse_to_json("func f(n: Int) -> Int { return n } // trailing")
308418
.expect("parsing should succeed");
309-
let ast = json_to_ast(&json).expect("adapter should succeed");
419+
let adapted = json_to_ast(&json).expect("adapter should succeed");
420+
let ast = &adapted.ast;
310421

311422
let root = ast.get_node(ast.get_root()).expect("root exists");
312423
assert_eq!(root.kind_name(), "sourceFile");
@@ -323,5 +434,15 @@ mod tests {
323434
kinds.contains(&"func"),
324435
"expected an anonymous `func` token, got kinds: {kinds:?}"
325436
);
437+
438+
// The trailing comment is recovered into the side channel.
439+
assert!(
440+
adapted
441+
.trivia
442+
.iter()
443+
.any(|t| t.kind == "lineComment" && t.text == "// trailing"),
444+
"expected the trailing comment in the trivia side channel, got: {:?}",
445+
adapted.trivia
446+
);
326447
}
327448
}

unified/swift-syntax-rs/swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,39 @@ private let keptTriviaKinds: Set<String> = [
3939
"unexpectedText",
4040
]
4141

42-
/// Serialize a trivia collection into an array of `{ kind, text }` pieces,
43-
/// keeping only the kinds in `keptTriviaKinds`.
44-
private func serializeTrivia(_ trivia: Trivia) -> [Any] {
45-
trivia.pieces.compactMap { piece -> [String: Any]? in
42+
/// Serialize a trivia collection into an array of `{ kind, text, range }`
43+
/// pieces, keeping only the kinds in `keptTriviaKinds`.
44+
///
45+
/// `start` is the absolute position of the first piece (a token's leading
46+
/// trivia starts at `token.position`; its trailing trivia at
47+
/// `token.endPositionBeforeTrailingTrivia`). Each piece's range is derived by
48+
/// accumulating piece lengths, so kept pieces carry an exact source location.
49+
private func serializeTrivia(
50+
_ trivia: Trivia,
51+
startingAt start: AbsolutePosition,
52+
_ converter: SourceLocationConverter
53+
) -> [Any] {
54+
var result: [Any] = []
55+
var offset = start.utf8Offset
56+
for piece in trivia.pieces {
57+
let length = piece.sourceLength.utf8Length
4658
// The label of an enum case mirror is the case name (e.g. "spaces",
4759
// "lineComment"), which gives us a stable kind without an exhaustive
4860
// switch over every `TriviaPiece` case.
4961
let kind = Mirror(reflecting: piece).children.first?.label ?? "\(piece)"
50-
guard keptTriviaKinds.contains(kind) else { return nil }
51-
return [
52-
"kind": kind,
53-
"text": Trivia(pieces: [piece]).description,
54-
]
62+
if keptTriviaKinds.contains(kind) {
63+
result.append([
64+
"kind": kind,
65+
"text": Trivia(pieces: [piece]).description,
66+
"range": [
67+
"start": location(AbsolutePosition(utf8Offset: offset), converter),
68+
"end": location(AbsolutePosition(utf8Offset: offset + length), converter),
69+
],
70+
])
71+
}
72+
offset += length
5573
}
74+
return result
5675
}
5776

5877
/// Recursively convert a SwiftSyntax node into a JSON-serializable value.
@@ -95,11 +114,17 @@ private func serialize(
95114
"range": range,
96115
]
97116
// Only emit trivia when present; after filtering, most tokens have none.
98-
let leading = serializeTrivia(token.leadingTrivia)
117+
// Leading trivia starts at the token's own position; trailing trivia
118+
// starts just after the token's content.
119+
let leading = serializeTrivia(
120+
token.leadingTrivia, startingAt: token.position, converter)
99121
if !leading.isEmpty {
100122
result["leadingTrivia"] = leading
101123
}
102-
let trailing = serializeTrivia(token.trailingTrivia)
124+
let trailing = serializeTrivia(
125+
token.trailingTrivia,
126+
startingAt: token.endPositionBeforeTrailingTrivia,
127+
converter)
103128
if !trailing.isEmpty {
104129
result["trailingTrivia"] = trailing
105130
}

0 commit comments

Comments
 (0)