Skip to content

Commit 64ad410

Browse files
authored
Merge pull request #22543 from github/tausbn/unified-swift-use-offsets-for-locations
Unified: Use offsets for `swift-syntax` locations
2 parents ff7edbf + 4aabdf3 commit 64ad410

5 files changed

Lines changed: 270 additions & 265 deletions

File tree

‎unified/extractor/src/languages/swift/adapter.rs‎

Lines changed: 134 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,71 @@ const VARYING_TOKEN_KINDS: &[&str] = &[
6161
fn 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.
69130
struct 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.
277314
pub 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

Comments
 (0)