@@ -25,6 +25,29 @@ use serde_json::Value;
2525use yeast:: schema:: Schema ;
2626use 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}
0 commit comments