From 39ffd10d355c0086b35b5b0aed590772574541ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:44:34 +0000 Subject: [PATCH 1/3] Include elided tokens in yeast ranges Co-authored-by: aschackmull <28296824+aschackmull@users.noreply.github.com> --- shared/yeast/src/lib.rs | 31 ++++++++++++++++++++++++++++--- shared/yeast/tests/test.rs | 25 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/shared/yeast/src/lib.rs b/shared/yeast/src/lib.rs index 94f75faa4763..cba96367ce05 100644 --- a/shared/yeast/src/lib.rs +++ b/shared/yeast/src/lib.rs @@ -571,11 +571,17 @@ impl Ast { let source_range = match &content { // Parsed nodes already carry an exact source range in their content. NodeContent::Range(_) => source_range, - // Synthesized nodes derive location from children when possible, - // and fall back to the inherited rule-match range otherwise. + // Synthesized nodes derive location from both their children and + // the inherited rule-match range, so tokens matched by a rule but + // elided from its output still contribute to the replacement range. _ => self .union_source_range_of_children(&fields) - .or(source_range), + .map_or(source_range, |child_range| { + Some(match source_range { + Some(source_range) => union_source_ranges(child_range, source_range), + None => child_range, + }) + }), }; let id = self.nodes.len(); self.nodes.push(Node { @@ -766,6 +772,25 @@ impl Ast { } } +fn union_source_ranges(first: Range, second: Range) -> Range { + let (start_byte, start_point) = if first.start_byte <= second.start_byte { + (first.start_byte, first.start_point) + } else { + (second.start_byte, second.start_point) + }; + let (end_byte, end_point) = if first.end_byte >= second.end_byte { + (first.end_byte, first.end_point) + } else { + (second.end_byte, second.end_point) + }; + Range { + start_byte, + end_byte, + start_point, + end_point, + } +} + /// A node in our AST #[derive(PartialEq, Eq, Debug, Clone, Serialize)] pub struct Node { diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index 60ed8afe84dd..bc2417ce2ecf 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1663,6 +1663,31 @@ fn test_hash_brace_uses_capture_location_for_leaf() { assert_eq!(bar.end_byte(), 7); } +/// Regression test: tokens matched by a rule but elided from the output still +/// contribute to the source location of the synthesized replacement node. +#[test] +fn test_elided_tokens_contribute_to_replacement_location() { + let rule: Rule = rule!( + (call + method: (identifier) @name + receiver: (identifier) @recv + ) + => + (call + method: {name} + ) + ); + + let ast = run_and_ast("foo.bar()", vec![rule]); + let root = ast.get_node(ast.get_root()).unwrap(); + let stmt_field = ast.field_id_for_name("stmt").unwrap(); + let call_id = root.field_children(stmt_field)[0]; + let call = ast.get_node(call_id).unwrap(); + + assert_eq!(call.start_byte(), 0); + assert_eq!(call.end_byte(), 7); +} + // ---- `rules!` macro tests (compile-time type-checking) ---- /// `rules!` should accept well-typed rules using the bare-rule-body From 496cd17fca66e5aea4f8b4bb60d4ff4dddd67d66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:45:55 +0000 Subject: [PATCH 2/3] Fix yeast elided token regression test Co-authored-by: aschackmull <28296824+aschackmull@users.noreply.github.com> --- shared/yeast/tests/test.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index bc2417ce2ecf..35393685b5ca 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1679,13 +1679,21 @@ fn test_elided_tokens_contribute_to_replacement_location() { ); let ast = run_and_ast("foo.bar()", vec![rule]); - let root = ast.get_node(ast.get_root()).unwrap(); - let stmt_field = ast.field_id_for_name("stmt").unwrap(); - let call_id = root.field_children(stmt_field)[0]; + let call_ids: Vec = ast + .reachable_node_ids() + .into_iter() + .filter(|&id| { + ast.get_node(id) + .is_some_and(|node| node.kind_name() == "call") + }) + .collect(); + + assert_eq!(call_ids.len(), 1, "expected exactly one reachable call"); + let call_id = call_ids[0]; let call = ast.get_node(call_id).unwrap(); assert_eq!(call.start_byte(), 0); - assert_eq!(call.end_byte(), 7); + assert_eq!(call.end_byte(), 9); } // ---- `rules!` macro tests (compile-time type-checking) ---- From 94b78bf0355d8c9bca81a5efd7a8c2017050131f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:09:50 +0000 Subject: [PATCH 3/3] Keep import member-chain locations precise Co-authored-by: aschackmull <28296824+aschackmull@users.noreply.github.com> --- .../extractor/src/languages/swift/swift.rs | 10 ++- unified/extractor/tests/corpus_tests.rs | 5 +- .../extractor/tests/swift_syntax_pipeline.rs | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 6396d4addd75..1816c81930c5 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -115,15 +115,21 @@ fn member_chain( ctx: &mut yeast::build::BuildCtx<'_, SwiftContext>, parts: Vec, ) -> yeast::Id { + // `member_chain` builds the imported expression inside the larger import + // declaration rule. The imported expression should span the import path, + // not the whole declaration including the `import` keyword. + let source_range = ctx.source_range.take(); let mut iter = parts.into_iter(); let first = iter .next() .expect("identifier with `part:` must have at least one part"); let init = tree!((name_expr identifier: (identifier #{first}))); - iter.fold( + let result = iter.fold( init, |acc, elem| tree!((member_access_expr base: {acc} member: (identifier #{elem}))), - ) + ); + ctx.source_range = source_range; + result } /// Compound-assignment operator spellings (`+=`, `<<=`, ...). Used to tell a diff --git a/unified/extractor/tests/corpus_tests.rs b/unified/extractor/tests/corpus_tests.rs index f0a3c448f12b..47675fc2287f 100644 --- a/unified/extractor/tests/corpus_tests.rs +++ b/unified/extractor/tests/corpus_tests.rs @@ -98,9 +98,8 @@ fn collect_corpus_stems(dir: &Path, out: &mut Vec) { #[cfg(bazel)] fn corpus_dir() -> std::path::PathBuf { - let base = std::path::PathBuf::from( - std::env::var("RUNFILES_DIR").expect("RUNFILES_DIR not set"), - ); + let base = + std::path::PathBuf::from(std::env::var("RUNFILES_DIR").expect("RUNFILES_DIR not set")); std::fs::read_dir(&base) .expect("failed to read RUNFILES_DIR") .filter_map(Result::ok) diff --git a/unified/extractor/tests/swift_syntax_pipeline.rs b/unified/extractor/tests/swift_syntax_pipeline.rs index fde060f98203..3ae966d7bc38 100644 --- a/unified/extractor/tests/swift_syntax_pipeline.rs +++ b/unified/extractor/tests/swift_syntax_pipeline.rs @@ -16,6 +16,39 @@ mod languages; /// A real `swift-syntax-rs` JSON dump of the Swift source `let x = 1`. const LET_X_JSON: &str = include_str!("fixtures/let_x.swiftsyntax.json"); +const IMPORT_FOUNDATION_JSON: &str = r#"{ + "kind": "sourceFile", + "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":17,"line":1,"column":18}}, + "statements": [ + { + "kind": "codeBlockItem", + "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":17,"line":1,"column":18}}, + "item": { + "kind": "importDecl", + "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":17,"line":1,"column":18}}, + "importKeyword": { + "kind": "token", + "tokenKind": "keyword(SwiftSyntax.Keyword.import)", + "text": "import", + "range": {"start":{"offset":0,"line":1,"column":1},"end":{"offset":6,"line":1,"column":7}} + }, + "path": [ + { + "kind": "importPathComponent", + "range": {"start":{"offset":7,"line":1,"column":8},"end":{"offset":17,"line":1,"column":18}}, + "name": { + "kind": "token", + "tokenKind": "identifier(\"Foundation\")", + "text": "Foundation", + "range": {"start":{"offset":7,"line":1,"column":8},"end":{"offset":17,"line":1,"column":18}} + } + } + ] + } + } + ] +}"#; + #[test] fn swift_syntax_json_runs_through_the_desugarer() { let lang = languages::all_language_specs() @@ -47,3 +80,37 @@ fn swift_syntax_json_runs_through_the_desugarer() { assert!(dump.contains("top_level"), "unexpected dump: {dump}"); assert!(dump.contains("block"), "unexpected dump: {dump}"); } + +#[test] +fn import_name_expr_location_excludes_import_keyword() { + let lang = languages::all_language_specs() + .into_iter() + .find(|l| l.file_globs.iter().any(|g| g.contains("swift"))) + .expect("swift language spec"); + let desugarer = lang.desugarer.as_ref(); + let adapted = languages::swift_adapter::json_to_ast(IMPORT_FOUNDATION_JSON) + .expect("adapter should succeed"); + + let desugared = desugarer + .run_from_ast(adapted.ast) + .expect("desugaring an import should not error"); + + let name_expr_ids: Vec = desugared + .reachable_node_ids() + .into_iter() + .filter(|&id| { + desugared + .get_node(id) + .is_some_and(|node| node.kind_name() == "name_expr") + }) + .collect(); + assert_eq!( + name_expr_ids.len(), + 1, + "expected exactly one reachable name_expr" + ); + + let name_expr = desugared.get_node(name_expr_ids[0]).unwrap(); + assert_eq!(name_expr.start_byte(), 7); + assert_eq!(name_expr.end_byte(), 17); +}