@@ -61,10 +61,71 @@ const VARYING_TOKEN_KINDS: &[&str] = &[
6161fn is_metadata_key ( key : & str ) -> bool {
6262 matches ! (
6363 key,
64- "kind" | "range" | "tokenKind" | "text" | "leadingTrivia" | "trailingTrivia"
64+ "kind"
65+ | "$pos"
66+ | "$end"
67+ | "$lineStarts"
68+ | "tokenKind"
69+ | "text"
70+ | "leadingTrivia"
71+ | "trailingTrivia"
6572 )
6673}
6774
75+ /// Converts compact UTF-8 byte offsets into tree-sitter-style points.
76+ struct LocationTable {
77+ line_starts : Vec < usize > ,
78+ }
79+
80+ impl LocationTable {
81+ fn from_root ( root : & Value ) -> Result < Self , String > {
82+ let values = root
83+ . get ( "$lineStarts" )
84+ . and_then ( Value :: as_array)
85+ . ok_or ( "root node is missing an array `$lineStarts`" ) ?;
86+ let mut line_starts = Vec :: with_capacity ( values. len ( ) ) ;
87+ for ( index, value) in values. iter ( ) . enumerate ( ) {
88+ let offset = value
89+ . as_u64 ( )
90+ . and_then ( |offset| usize:: try_from ( offset) . ok ( ) )
91+ . ok_or_else ( || format ! ( "`$lineStarts[{index}]` is not a valid byte offset" ) ) ?;
92+ line_starts. push ( offset) ;
93+ }
94+ if line_starts. first ( ) != Some ( & 0 ) {
95+ return Err ( "`$lineStarts` must start with offset 0" . to_string ( ) ) ;
96+ }
97+ if line_starts. windows ( 2 ) . any ( |pair| pair[ 0 ] >= pair[ 1 ] ) {
98+ return Err ( "`$lineStarts` offsets must be strictly increasing" . to_string ( ) ) ;
99+ }
100+ Ok ( Self { line_starts } )
101+ }
102+
103+ fn point ( & self , offset : usize ) -> Point {
104+ let row = self
105+ . line_starts
106+ . partition_point ( |line_start| * line_start <= offset)
107+ - 1 ;
108+ Point :: new ( row, offset - self . line_starts [ row] )
109+ }
110+
111+ /// Parse a node's half-open UTF-8 byte range into a [`yeast::Range`].
112+ fn range ( & self , node : & Value ) -> Option < Range > {
113+ let offset = |key : & str | {
114+ node. get ( key) ?
115+ . as_u64 ( )
116+ . and_then ( |offset| usize:: try_from ( offset) . ok ( ) )
117+ } ;
118+ let start_byte = offset ( "$pos" ) ?;
119+ let end_byte = offset ( "$end" ) ?;
120+ Some ( Range {
121+ start_byte,
122+ end_byte,
123+ start_point : self . point ( start_byte) ,
124+ end_point : self . point ( end_byte) ,
125+ } )
126+ }
127+ }
128+
68129/// The classification of a JSON node into a yeast kind name and named-ness.
69130struct KindInfo {
70131 /// The name under which the kind is registered in the schema.
@@ -158,16 +219,21 @@ fn children_of(value: &Value) -> Vec<&Value> {
158219/// comment/`unexpectedText` trivia carried by a token is harvested into
159220/// `extras` (as [`ExtraToken`]s) during the same pass rather than embedded in
160221/// the tree.
161- fn build ( node : & Value , ast : & mut Ast , extras : & mut Vec < ExtraToken > ) -> Result < Id , String > {
222+ fn build (
223+ node : & Value ,
224+ locations : & LocationTable ,
225+ ast : & mut Ast ,
226+ extras : & mut Vec < ExtraToken > ,
227+ ) -> Result < Id , String > {
162228 let info = classify ( node) ?;
163- collect_extras ( node, extras) ;
229+ collect_extras ( node, locations , extras) ;
164230
165231 let mut fields: BTreeMap < u16 , Vec < Id > > = BTreeMap :: new ( ) ;
166232 for ( field, value) in field_entries ( node) {
167233 let field_id = ast. register_field ( field) ;
168234 let mut ids = Vec :: new ( ) ;
169235 for child in children_of ( value) {
170- ids. push ( build ( child, ast, extras) ?) ;
236+ ids. push ( build ( child, locations , ast, extras) ?) ;
171237 }
172238 fields. insert ( field_id, ids) ;
173239 }
@@ -183,23 +249,23 @@ fn build(node: &Value, ast: &mut Ast, extras: &mut Vec<ExtraToken>) -> Result<Id
183249 NodeContent :: DynamicString ( info. text ) ,
184250 fields,
185251 info. is_named ,
186- parse_range ( node) ,
252+ locations . range ( node) ,
187253 ) )
188254}
189255
190256/// Harvest a token's `leadingTrivia`/`trailingTrivia` pieces (each already
191257/// filtered to comments/`unexpectedText` upstream) into `out` as
192258/// [`ExtraToken`]s. Non-token nodes have no trivia keys, so this is a no-op for
193259/// them.
194- fn collect_extras ( node : & Value , out : & mut Vec < ExtraToken > ) {
260+ fn collect_extras ( node : & Value , locations : & LocationTable , out : & mut Vec < ExtraToken > ) {
195261 for key in [ "leadingTrivia" , "trailingTrivia" ] {
196262 let Some ( Value :: Array ( pieces) ) = node. get ( key) else {
197263 continue ;
198264 } ;
199265 for piece in pieces {
200266 let ( Some ( kind) , Some ( range) ) = (
201267 piece. get ( "kind" ) . and_then ( Value :: as_str) ,
202- parse_range ( piece) ,
268+ locations . range ( piece) ,
203269 ) else {
204270 continue ;
205271 } ;
@@ -232,35 +298,6 @@ fn trivia_kind_id(kind: &str) -> usize {
232298 }
233299}
234300
235- /// Parse a node's `range` into a [`yeast::Range`].
236- ///
237- /// The JSON carries, for `start` and `end`, a 0-based UTF-8 file byte `offset`,
238- /// a 1-based `line`, and a 1-based UTF-8 byte `column`. yeast (like tree-sitter)
239- /// uses byte offsets with 0-based rows/columns and an exclusive end, so the
240- /// line/column are shifted down by one. swift-syntax's end position is already
241- /// exclusive, so the byte offsets map across directly.
242- fn parse_range ( node : & Value ) -> Option < Range > {
243- let range = node. get ( "range" ) ?;
244- let point = |key : & str | -> Option < ( usize , Point ) > {
245- let p = range. get ( key) ?;
246- let offset = p. get ( "offset" ) ?. as_u64 ( ) ? as usize ;
247- let line = p. get ( "line" ) ?. as_u64 ( ) ? as usize ;
248- let column = p. get ( "column" ) ?. as_u64 ( ) ? as usize ;
249- Some ( (
250- offset,
251- Point :: new ( line. saturating_sub ( 1 ) , column. saturating_sub ( 1 ) ) ,
252- ) )
253- } ;
254- let ( start_byte, start_point) = point ( "start" ) ?;
255- let ( end_byte, end_point) = point ( "end" ) ?;
256- Some ( Range {
257- start_byte,
258- end_byte,
259- start_point,
260- end_point,
261- } )
262- }
263-
264301/// The authoritative swift-syntax input node-types schema, generated from
265302/// swift-syntax by `swift-syntax-rs/schemagen` (run
266303/// `unified/scripts/regenerate-node-types.sh` to refresh it).
@@ -276,10 +313,11 @@ const SWIFT_NODE_TYPES: &str = include_str!("../../../swift_node_types.yml");
276313/// ever consumes swift-syntax input, so the schema is not a parameter.
277314pub fn json_to_ast ( json : & str ) -> Result < AdaptedTree , String > {
278315 let root: Value = serde_json:: from_str ( json) . map_err ( |e| format ! ( "invalid JSON: {e}" ) ) ?;
316+ let locations = LocationTable :: from_root ( & root) ?;
279317
280318 let mut ast = Ast :: with_schema ( yeast:: node_types_yaml:: schema_from_yaml ( SWIFT_NODE_TYPES ) ?) ;
281319 let mut extras = Vec :: new ( ) ;
282- let root_id = build ( & root, & mut ast, & mut extras) ?;
320+ let root_id = build ( & root, & locations , & mut ast, & mut extras) ?;
283321 ast. set_root ( root_id) ;
284322
285323 // Emit extras in source order (the traversal visits nodes bottom-up).
@@ -297,23 +335,28 @@ mod tests {
297335 /// adapter is tested without needing the Swift toolchain.
298336 fn sample_json ( ) -> & ' static str {
299337 r#"{
338+ "$lineStarts": [0],
339+ "$pos": 0,
340+ "$end": 9,
300341 "kind": "sourceFile",
301- "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":9,"line":1,"column":10}},
302342 "statements": [
303343 {
344+ "$pos": 0,
345+ "$end": 9,
304346 "kind": "variableDecl",
305- "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":9,"line":1,"column":10}},
306347 "bindingSpecifier": {
348+ "$pos": 0,
349+ "$end": 3,
307350 "kind": "token",
308351 "tokenKind": "keyword(SwiftSyntax.Keyword.let)",
309- "text": "let",
310- "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":3,"line":1,"column":4}}
352+ "text": "let"
311353 },
312354 "name": {
355+ "$pos": 4,
356+ "$end": 5,
313357 "kind": "token",
314358 "tokenKind": "identifier(\"x\")",
315- "text": "x",
316- "range": {"start":{"offset":4,"line":1,"column":5},"end":{"offset":5,"line":1,"column":6}}
359+ "text": "x"
317360 }
318361 }
319362 ]
@@ -377,30 +420,72 @@ mod tests {
377420 . iter ( )
378421 . find ( |n| n. kind_name ( ) == "identifier" )
379422 . expect ( "identifier node exists" ) ;
380- // `x` is at file offset 4..5, line 1, column 5 (1-based) in the JSON,
381- // which maps to 0-based row 0, column 4 and byte range 4..5.
423+ // `x` is at UTF-8 byte range 4..5 on the first line.
382424 assert_eq ! ( ident. start_byte( ) , 4 ) ;
383425 assert_eq ! ( ident. end_byte( ) , 5 ) ;
384426 assert_eq ! ( ident. start_position( ) , Point :: new( 0 , 4 ) ) ;
385427 assert_eq ! ( ident. end_position( ) , Point :: new( 0 , 5 ) ) ;
386428 }
387429
430+ #[ test]
431+ fn maps_utf8_locations_across_swift_line_endings ( ) {
432+ // The implied source prefix is `// é😀\r\nlet `: the second line begins
433+ // at UTF-8 byte 11 and `x` occupies bytes 15..16.
434+ let json = r#"{
435+ "$lineStarts": [0, 11, 21, 31],
436+ "$pos": 0,
437+ "$end": 31,
438+ "kind": "sourceFile",
439+ "name": {
440+ "$pos": 15,
441+ "$end": 16,
442+ "kind": "token",
443+ "tokenKind": "identifier(\"x\")",
444+ "text": "x"
445+ }
446+ }"# ;
447+ let ast = json_to_ast ( json) . expect ( "adapter should succeed" ) . ast ;
448+ let ident = ast
449+ . nodes ( )
450+ . iter ( )
451+ . find ( |n| n. kind_name ( ) == "identifier" )
452+ . expect ( "identifier node exists" ) ;
453+ assert_eq ! ( ident. start_byte( ) , 15 ) ;
454+ assert_eq ! ( ident. end_byte( ) , 16 ) ;
455+ assert_eq ! ( ident. start_position( ) , Point :: new( 1 , 4 ) ) ;
456+ assert_eq ! ( ident. end_position( ) , Point :: new( 1 , 5 ) ) ;
457+ }
458+
459+ #[ test]
460+ fn rejects_invalid_line_starts ( ) {
461+ let json = r#"{"$lineStarts":[1],"$pos":0,"$end":0,"kind":"sourceFile"}"# ;
462+ let error = match json_to_ast ( json) {
463+ Ok ( _) => panic ! ( "invalid line starts should fail" ) ,
464+ Err ( error) => error,
465+ } ;
466+ assert ! ( error. contains( "must start with offset 0" ) , "{error}" ) ;
467+ }
468+
388469 #[ test]
389470 fn collects_extras_into_side_channel ( ) {
390471 // A token carrying a trailing line comment in its trivia.
391472 let json = r#"{
473+ "$lineStarts": [0],
474+ "$pos": 0,
475+ "$end": 14,
392476 "kind": "sourceFile",
393- "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":14,"line":1,"column":15}},
394477 "value": {
478+ "$pos": 0,
479+ "$end": 1,
395480 "kind": "token",
396481 "tokenKind": "integerLiteral(\"1\")",
397482 "text": "1",
398- "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":1,"line":1,"column":2}},
399483 "trailingTrivia": [
400484 {
485+ "$pos": 2,
486+ "$end": 6,
401487 "kind": "lineComment",
402- "text": "// c",
403- "range": {"start":{"offset":2,"line":1,"column":3},"end":{"offset":6,"line":1,"column":7}}
488+ "text": "// c"
404489 }
405490 ]
406491 }
0 commit comments