From ee534a9dccbe90e1e1de7f456d292aecef6e751c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 26 Oct 2026 08:58:10 +0000 Subject: [PATCH 1/4] fix(source-edit): place MoveSymbol imports at syntax boundaries Restack unique #4531 onto the rewritten #4530 tip. (cherry picked from commit f2cf33e39cbd6c6fb8857a89a0e6503b9ac8c0be) --- .../src/edits/execute_tests.rs | 95 ++++++++++++++++++- .../tracedecay-source-edit/src/edits/mod.rs | 4 +- .../src/edits/preview.rs | 47 +++------ .../src/move_symbol/mod.rs | 7 +- 4 files changed, 111 insertions(+), 42 deletions(-) diff --git a/crates/tracedecay-source-edit/src/edits/execute_tests.rs b/crates/tracedecay-source-edit/src/edits/execute_tests.rs index f1e11c8cc5..be4829064e 100644 --- a/crates/tracedecay-source-edit/src/edits/execute_tests.rs +++ b/crates/tracedecay-source-edit/src/edits/execute_tests.rs @@ -346,12 +346,22 @@ fn assert_import_fixture_compiles(project: &Path) { ); } +const MARKER_DESTINATION: &str = "pub fn marker() {}\n"; + async fn assert_move_import_ownership(import: &str, binding: &str, preserve: bool) { + assert_move_into_destination(import, binding, preserve, MARKER_DESTINATION).await; +} + +async fn assert_move_into_destination( + import: &str, + binding: &str, + preserve: bool, + original_destination: &str, +) { let project = tempdir().unwrap(); fs::create_dir_all(project.path().join("src")).unwrap(); let moved_symbol = format!("pub fn moved(value: {binding}) -> {binding} {{\n value\n}}"); let original_source = format!("{import}\n\n{moved_symbol}\n\npub fn remains() {{}}\n"); - let original_destination = "pub fn marker() {}\n"; // This module never refers to `moved`; its dependency on the original // export path must survive moving that function to a different module. let consumer_type = if preserve { @@ -510,6 +520,89 @@ async fn move_symbol_still_removes_an_unused_private_import() { assert_move_import_ownership("use crate::model::Widget;", "Widget", false).await; } +#[tokio::test] +async fn move_symbol_inserts_imports_after_a_multiline_use_group() { + assert_move_into_destination( + "use crate::model::Widget;", + "Widget", + false, + "use std::{\n fmt::Debug,\n};\n\npub fn marker(_: &dyn Debug) {}\n", + ) + .await; +} + +#[tokio::test] +async fn move_symbol_preserves_an_outer_attribute_after_a_same_line_import() { + assert_move_into_destination( + "use crate::model::Widget;", + "Widget", + false, + "use std::fmt::Debug; #[cfg(any())]\npub fn marker(_: &dyn Debug) {}\n", + ) + .await; +} + +#[tokio::test] +async fn move_symbol_inserts_imports_after_module_inner_attributes() { + assert_move_into_destination( + "use crate::model::Widget;", + "Widget", + false, + "#![allow(dead_code)]\npub fn marker() {}\n", + ) + .await; +} + +#[tokio::test] +async fn move_symbol_refuses_a_destination_that_does_not_parse() { + let project = tempdir().unwrap(); + fs::create_dir_all(project.path().join("src")).unwrap(); + let source = + "use crate::model::Widget;\n\npub fn moved(value: Widget) -> Widget {\n value\n}\n"; + let broken = "pub fn marker( {\n"; + for (path, content) in [ + ("src/api.rs", source), + ("src/model.rs", "pub struct Widget;\n"), + ("src/relocated.rs", broken), + ] { + fs::write(project.path().join(path), content).unwrap(); + } + let (graph, _) = fixture_graph(project.path()).await; + let code_graph = fixture_symbol_code_graph( + "src/api.rs", + source, + "pub fn moved(value: Widget) -> Widget {\n value\n}", + "moved", + "api::moved", + ); + let request = fixture_request_for_edit( + SourceEditRequest::MoveSymbol { + symbol: "moved".to_owned(), + dest_file: "src/relocated.rs".to_owned(), + dry_run: true, + update_references: false, + }, + "source-edit.move-unparseable-destination", + ); + let outcome = resolve_source_edit_preview( + &graph, + &code_graph, + &request.context, + request.observed_at, + request_graph_cancellation(&request.context), + request.edit.clone(), + ) + .await; + assert!( + outcome.is_err() || !outcome.unwrap().outcome.success(), + "an unparseable destination must refuse the move" + ); + assert_eq!( + fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(), + broken + ); +} + #[tokio::test] async fn move_symbol_preserves_a_destination_created_at_publication() { let project = tempdir().unwrap(); diff --git a/crates/tracedecay-source-edit/src/edits/mod.rs b/crates/tracedecay-source-edit/src/edits/mod.rs index aaeea9e24b..9fdb30eb60 100644 --- a/crates/tracedecay-source-edit/src/edits/mod.rs +++ b/crates/tracedecay-source-edit/src/edits/mod.rs @@ -18,8 +18,8 @@ mod test_support; pub(crate) use ast_grep::ast_grep_rewrite; pub(crate) use preview::{ - LeadingKind, MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff, - classify_leading_line, edit_success_message, + MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff, edit_success_message, + is_inner_doc_line, }; pub(crate) use primitives::{ insert_at, insert_at_symbol, multi_str_replace, replace_symbol, splice_lines, str_replace, diff --git a/crates/tracedecay-source-edit/src/edits/preview.rs b/crates/tracedecay-source-edit/src/edits/preview.rs index d2227df442..11ce2e0035 100644 --- a/crates/tracedecay-source-edit/src/edits/preview.rs +++ b/crates/tracedecay-source-edit/src/edits/preview.rs @@ -53,6 +53,12 @@ pub(crate) fn classify_leading_line(line: &str) -> LeadingKind { } } +/// True for a `//!` inner module-doc line, which documents the enclosing +/// module and can never belong to a moved item. +pub(crate) fn is_inner_doc_line(line: &str) -> bool { + classify_leading_line(line) == LeadingKind::InnerDoc +} + /// Unchanged context lines shown on each side of the changed region in a /// dry-run preview diff. pub(crate) const PREVIEW_DIFF_CONTEXT: usize = 3; @@ -142,43 +148,14 @@ pub(crate) fn bounded_region_diff( #[cfg(test)] mod tests { - use super::{LeadingKind, bounded_region_diff, classify_leading_line}; + use super::{bounded_region_diff, is_inner_doc_line}; #[test] - fn classify_leading_line_covers_every_kind() { - assert_eq!(classify_leading_line(""), LeadingKind::Blank); - assert_eq!(classify_leading_line(" \t"), LeadingKind::Blank); - assert_eq!(classify_leading_line("//! inner"), LeadingKind::InnerDoc); - assert_eq!(classify_leading_line("/// outer"), LeadingKind::OuterDoc); - assert_eq!( - classify_leading_line("/** block doc */"), - LeadingKind::OuterDoc - ); - assert_eq!(classify_leading_line("// plain"), LeadingKind::LineComment); - assert_eq!( - classify_leading_line("/* block */"), - LeadingKind::BlockComment - ); - assert_eq!(classify_leading_line("#[inline]"), LeadingKind::Attribute); - assert_eq!( - classify_leading_line("#![allow(dead_code)]"), - LeadingKind::Attribute - ); - assert_eq!( - classify_leading_line("use crate::x;"), - LeadingKind::UseImport - ); - assert_eq!( - classify_leading_line("pub use crate::x;"), - LeadingKind::UseImport - ); - assert_eq!( - classify_leading_line("extern crate foo;"), - LeadingKind::UseImport - ); - assert_eq!(classify_leading_line("fn f() {}"), LeadingKind::Code); - // Leading whitespace never changes the classification. - assert_eq!(classify_leading_line(" //! inner"), LeadingKind::InnerDoc); + fn inner_doc_lines_are_recognized_regardless_of_indent() { + assert!(is_inner_doc_line("//! inner")); + assert!(is_inner_doc_line(" //! inner")); + assert!(!is_inner_doc_line("/// outer")); + assert!(!is_inner_doc_line("// plain")); } #[test] diff --git a/crates/tracedecay-source-edit/src/move_symbol/mod.rs b/crates/tracedecay-source-edit/src/move_symbol/mod.rs index 9d5b50c0f4..c650a12e4c 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/mod.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/mod.rs @@ -26,8 +26,8 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_graph_query::{map_code_graph_read_runtime_error, map_projection_error}; use crate::edits::{ - EditSymbolV1, LeadingKind, classify_leading_line, edit_success_message, - edit_symbol_from_summary, resolve_symbol_for_edit, splice_lines, + EditSymbolV1, edit_success_message, edit_symbol_from_summary, is_inner_doc_line, + resolve_symbol_for_edit, splice_lines, }; use fs_guards::{ @@ -130,8 +130,7 @@ pub(crate) async fn move_symbol( // module, not the following item. Advance past it so the source keeps its // module doc and the destination doesn't receive a stray `//!` mid-file // (a hard E0753). - while start < end_inclusive && classify_leading_line(src_lines[start]) == LeadingKind::InnerDoc - { + while start < end_inclusive && is_inner_doc_line(src_lines[start]) { start += 1; } let moved_text = src_lines[start..=end_inclusive].join("\n"); From 8fbd99a248493c024bee23513cc69867c57f6552 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:50:11 -0700 Subject: [PATCH 2/4] fix(source-edit): honor destination import scope Recognize existing destination imports from complete top-level Rust declarations instead of matching physical lines in comments or nested scopes. Preserve existing declaration visibility and attachments. Refuse automatic insertion when only an attributed matching import is available: its configuration may disable the binding or make an unconditional duplicate invalid. Exercise comment and nested-scope imports through real preview, publication, and fixture compilation. Assert typed refusal and unchanged files for cfg-disabled and cfg-enabled matching imports. Pinned formatting and diff checks pass. Native execution remains pending hosted CI because this workspace has exhausted its build disk budget. (cherry picked from commit 7a9446c5aa2de4a444efe8f24831769cfdac796a) --- .../src/edits/move_destination_tests.rs | 36 +++++++++-- .../src/move_symbol/mod.rs | 2 +- .../src/move_symbol/rendering.rs | 61 ++++++++++++++++--- .../src/move_symbol/use_parsing.rs | 2 +- 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs index e953cb550e..b2e4de7f7f 100644 --- a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs +++ b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs @@ -6,6 +6,7 @@ use tempfile::tempdir; use tracedecay_contracts::{ EffectTermination, SourceEditKind, SourceEditRequest, source_edit_operation, }; +use tracedecay_domain::errors::TraceDecayError; use super::test_support::{ fixture_authorization, fixture_graph, fixture_request_for_edit, fixture_symbol_code_graph, @@ -175,6 +176,22 @@ async fn move_symbol_imports_preserve_destination_syntax_in_preview_and_apply() } } +#[tokio::test] +async fn move_symbol_ignores_destination_imports_in_comments_and_nested_scopes() { + for (before, after) in [ + ( + "/* Import example:\nuse crate::model::Widget;\n*/\n", + "pub fn marker() {}\n", + ), + ( + "", + "mod nested {\n use crate::model::Widget;\n pub fn marker(value: Widget) -> Widget { value }\n}\npub fn marker() {}\n", + ), + ] { + move_with_destination_header(before, after, "\n", true).await; + } +} + #[tokio::test] async fn move_symbol_preserves_destination_bytes_when_adding_imports() { move_with_destination_header( @@ -190,12 +207,20 @@ async fn move_symbol_preserves_destination_bytes_when_adding_imports() { } #[tokio::test] -async fn move_symbol_refuses_unparseable_destination_before_preview_or_apply() { +async fn move_symbol_refuses_unsupported_destination_before_preview_or_apply() { for (destination, dependency) in [ ("use std::{\n", true), ("pub fn marker() {\n", false), ("/* unclosed destination comment", true), ("/// Detached documentation.\n#[inline]\n", false), + ( + "#[cfg(any())]\nuse crate::model::Widget;\npub fn marker() {}\n", + true, + ), + ( + "#[cfg(all())]\nuse crate::model::Widget;\npub fn marker() {}\n", + true, + ), ] { let project = tempdir().unwrap(); fs::create_dir(project.path().join("src")).unwrap(); @@ -232,10 +257,11 @@ async fn move_symbol_refuses_unparseable_destination_before_preview_or_apply() { request.edit.clone(), ) .await; - assert!( - preview.is_err(), - "malformed destination must not get an accepted preview" - ); + assert!(matches!( + preview, + Err(TraceDecayError::ProjectRoute { reason_code, retryable: false, .. }) + if reason_code == "source-edit-destination-syntax-unsupported" + )); let authorization = fixture_authorization(&request); let applied = execute_source_edit( &graph, diff --git a/crates/tracedecay-source-edit/src/move_symbol/mod.rs b/crates/tracedecay-source-edit/src/move_symbol/mod.rs index c650a12e4c..a89a6a3528 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/mod.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/mod.rs @@ -213,7 +213,7 @@ pub(crate) async fn move_symbol( &analysis.auto_imports, &dest_original, dest_module.as_deref(), - ); + )?; let dest_modified = build_dest_content(&dest_rel, &dest_original, &applied_imports, &moved_text)?; diff --git a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs index 099b1c66dd..abb3de3c88 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs @@ -8,9 +8,12 @@ use std::collections::HashSet; use tracedecay_code_extraction::ts_provider::parse_extractor_source; use tracedecay_domain::errors::{Result, TraceDecayError}; +use tree_sitter::Tree; use crate::edits::{MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff}; +use super::use_parsing::has_outer_attributes; + /// Removes `lines[start..=end]` and collapses the blank-line separator the /// removed item left behind so the source stays tidy. pub(super) fn remove_span_with_cleanup(lines: &[&str], start: usize, end: usize) -> Vec { @@ -100,16 +103,21 @@ fn destination_syntax_error(message: impl Into) -> TraceDecayError { TraceDecayError::project_route("source-edit-destination-syntax-unsupported", false, message) } -fn rust_import_offset(source: &str) -> Result { +fn parse_rust_destination(source: &str) -> Result { let tree = parse_extractor_source("rust", "Rust", source).map_err(|error| { destination_syntax_error(format!("cannot parse Rust destination: {error}")) })?; - let root = tree.root_node(); - if root.has_error() { + if tree.root_node().has_error() { return Err(destination_syntax_error( "Rust destination contains incomplete or unsupported syntax", )); } + Ok(tree) +} + +fn rust_import_offset(source: &str) -> Result { + let tree = parse_rust_destination(source)?; + let root = tree.root_node(); let mut offset = None; let mut seen_item = false; let mut pending_outer = false; @@ -146,20 +154,51 @@ fn rust_import_offset(source: &str) -> Result { Ok(offset.unwrap_or(source.len())) } -/// Drops imports already present verbatim in the destination and de-duplicates -/// within the batch, preserving order. +/// Drops matching unconditional module imports and de-duplicates within the +/// batch. Text in comments or nested scopes cannot supply a module binding. pub(super) fn dedup_preserve( imports: &[String], dest_original: &str, dest_module: Option<&str>, -) -> Vec { +) -> Result> { + if imports.is_empty() { + return Ok(Vec::new()); + } + let tree = parse_rust_destination(dest_original)?; + let mut existing = HashSet::new(); + let mut attributed = HashSet::new(); + for node in tree.root_node().named_children(&mut tree.walk()) { + if node.kind() != "use_declaration" { + continue; + } + let path = node + .child_by_field_name("argument") + .and_then(|argument| argument.utf8_text(dest_original.as_bytes()).ok()) + .ok_or_else(|| { + destination_syntax_error("cannot identify a complete destination import") + })?; + let import = format!("use {path};"); + if has_outer_attributes(node) { + attributed.insert(import); + } else { + existing.insert(import); + } + } let mut seen: HashSet = HashSet::new(); let mut out = Vec::new(); for imp in imports { let trimmed = imp.trim(); - if dest_original.lines().any(|l| l.trim() == trimmed) { + if existing.contains(trimmed) { continue; } + if attributed.contains(trimmed) { + // A conditional import may or may not bind the name. Neither + // dropping the dependency nor adding an unconditional duplicate + // is safe without resolving that configuration. + return Err(destination_syntax_error(format!( + "automatic import matches an attributed destination declaration: {trimmed}" + ))); + } // An import of an item the destination module itself declares // (`use crate::SweepType;` inserted into `src/lib.rs`) would be a // self-import: the name is already in scope at the destination. @@ -178,7 +217,7 @@ pub(super) fn dedup_preserve( out.push(imp.clone()); } } - out + Ok(out) } /// Removes each orphaned single-leaf `use` line (only the moved symbol needed @@ -380,7 +419,8 @@ mod tests { ], "use c::D;\n", None, - ); + ) + .unwrap(); assert_eq!(out, vec!["use a::B;".to_string()]); } @@ -402,7 +442,8 @@ mod tests { ], "pub struct SweepType { pub value: i32 }\n", Some("crate"), - ); + ) + .unwrap(); assert_eq!(out, vec!["use crate::nested::Other;".to_string()]); } } diff --git a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs index 9688e6db3c..9e3d88884e 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs @@ -97,7 +97,7 @@ fn source_relative_use_path(node: TsNode<'_>) -> bool { } } -fn has_outer_attributes(node: TsNode<'_>) -> bool { +pub(super) fn has_outer_attributes(node: TsNode<'_>) -> bool { let mut previous = node.prev_named_sibling(); while let Some(sibling) = previous { match sibling.kind() { From 26b8215983c615405f1e912f9282534c36f24fe8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:50:15 -0700 Subject: [PATCH 3/4] fix(source-edit): match Rust dependency bindings through syntax Preserve aliases across import trivia and grouping, and reuse exact destination bindings without inserting duplicate names. Refuse attributed or conflicting names and unresolved wildcard shadowing before planning. Exercise the existing move preview/apply journeys for equivalent imports, alias preservation, manual dependency hints, and unchanged refusal paths. (cherry picked from commit 5a7f8e7ee3c7d1b21a780cb3b14a427e908f1754) --- .../src/edits/move_destination_tests.rs | 187 ++++++++++++++++-- .../src/move_symbol/mod.rs | 4 +- .../src/move_symbol/rendering.rs | 108 ++++++++-- .../src/move_symbol/use_parsing.rs | 175 ++++++++++++++-- 4 files changed, 419 insertions(+), 55 deletions(-) diff --git a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs index b2e4de7f7f..757b45c5c7 100644 --- a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs +++ b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::process::Command; use tempfile::tempdir; +use tracedecay_contracts::source_edit::MoveHint; use tracedecay_contracts::{ EffectTermination, SourceEditKind, SourceEditRequest, source_edit_operation, }; @@ -41,10 +42,31 @@ async fn move_with_destination_header( newline: &str, destination_exists: bool, ) { + move_with_destination_dependency( + before_import, + after_import, + newline, + destination_exists, + "use crate::model::Widget;", + "Widget", + Some("use crate::model::Widget;"), + ) + .await; +} + +async fn move_with_destination_dependency( + before_import: &str, + after_import: &str, + newline: &str, + destination_exists: bool, + source_import: &str, + binding: &str, + expected_import: Option<&str>, +) -> Vec { let project = tempdir().unwrap(); fs::create_dir(project.path().join("src")).unwrap(); - let moved_text = "pub fn moved(value: Widget) -> Widget {\n value\n}"; - let source = format!("use crate::model::Widget;\n\n{moved_text}\n"); + let moved_text = format!("pub fn moved(value: {binding}) -> {binding} {{\n value\n}}"); + let source = format!("{source_import}\n\n{moved_text}\n"); let destination = format!("{before_import}{after_import}"); fs::write(project.path().join("src/source.rs"), &source).unwrap(); fs::write(project.path().join("src/model.rs"), "pub struct Widget;\n").unwrap(); @@ -61,7 +83,7 @@ async fn move_with_destination_header( let code_graph = fixture_symbol_code_graph( "src/source.rs", &source, - moved_text, + &moved_text, "moved", "source::moved", ); @@ -88,7 +110,10 @@ async fn move_with_destination_header( panic!("expected a move preview"); }; assert!(result.success, "{}", result.message); - assert_eq!(result.applied_imports, ["use crate::model::Widget;"]); + assert_eq!( + result.applied_imports, + expected_import.into_iter().collect::>() + ); assert_eq!(preview.planned_files.len(), 2); assert_eq!( fs::read_to_string(project.path().join("src/source.rs")).unwrap(), @@ -124,8 +149,8 @@ async fn move_with_destination_header( } compile_move_destination_fixture(project.path()); let actual = fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(); - let unchanged_destination = - format!("{before_import}use crate::model::Widget;{newline}{after_import}"); + let inserted = expected_import.map_or_else(String::new, |import| format!("{import}{newline}")); + let unchanged_destination = format!("{before_import}{inserted}{after_import}"); assert!(actual.starts_with(&unchanged_destination), "{actual:?}"); assert!(actual.ends_with(&format!("{moved_text}\n"))); let mut parser = tree_sitter::Parser::new(); @@ -133,15 +158,31 @@ async fn move_with_destination_header( parser.set_language(&language).unwrap(); let tree = parser.parse(&actual, None).unwrap(); assert!(!tree.root_node().has_error()); - assert!( - tree.root_node() - .named_children(&mut tree.walk()) - .any(|node| { - node.kind() == "use_declaration" - && node.utf8_text(actual.as_bytes()).unwrap() == "use crate::model::Widget;" - }), - "the dependency must be at the destination module root" + let original_tree = parser.parse(&destination, None).unwrap(); + let original_imports = original_tree + .root_node() + .named_children(&mut original_tree.walk()) + .filter(|node| node.kind() == "use_declaration") + .count(); + let actual_imports: Vec<_> = tree + .root_node() + .named_children(&mut tree.walk()) + .filter(|node| node.kind() == "use_declaration") + .collect(); + assert_eq!( + actual_imports.len(), + original_imports + usize::from(expected_import.is_some()), + "the move must add only the expected dependency import" ); + if let Some(expected_import) = expected_import { + assert!( + actual_imports + .iter() + .any(|node| node.utf8_text(actual.as_bytes()).unwrap() == expected_import), + "the dependency must be at the destination module root" + ); + } + result.impact.clone() } #[tokio::test] @@ -171,6 +212,7 @@ async fn move_symbol_imports_preserve_destination_syntax_in_preview_and_apply() "\u{feff}#!/usr/bin/env rust-script\n//! Destination module.\n", "pub fn marker() {}\n", ), + ("use std::fmt::Debug as _;\n", "pub fn marker() {}\n"), ] { move_with_destination_header(before, after, "\n", true).await; } @@ -192,6 +234,109 @@ async fn move_symbol_ignores_destination_imports_in_comments_and_nested_scopes() } } +#[tokio::test] +async fn move_symbol_preserves_dependency_aliases_across_trivia() { + for import in [ + "use crate::model::Widget as\n Alias;", + "use crate::model::Widget\tas\tAlias;", + "use crate::model::Widget as /* local name */ Alias;", + ] { + move_with_destination_dependency( + "", + "pub fn marker() {}\n", + "\n", + true, + import, + "Alias", + Some(import), + ) + .await; + } +} + +#[tokio::test] +async fn move_symbol_reuses_equivalent_destination_imports() { + for destination_import in [ + "use crate::model::{Widget};\n", + "use crate::{model::{Widget}};\n", + "use crate /* root */ ::\n model :: Widget;\n", + "use crate::model::{Widget, *};\n", + ] { + move_with_destination_dependency( + destination_import, + "pub fn marker() {}\n", + "\n", + true, + "use crate::model::Widget;", + "Widget", + None, + ) + .await; + } + move_with_destination_dependency( + "use crate::{model::{Widget as /* retained alias */ Alias}};\n", + "pub fn marker() {}\n", + "\n", + true, + "use crate::model::Widget as\n Alias;", + "Alias", + None, + ) + .await; +} + +#[tokio::test] +async fn move_symbol_preserves_destination_item_bindings() { + let import = "use crate::relocated::Widget as\n Alias;"; + move_with_destination_dependency( + "", + "pub struct Widget;\n", + "\n", + true, + import, + "Alias", + Some(import), + ) + .await; + move_with_destination_dependency( + "use std::fmt::*;\n", + "pub struct Widget;\n", + "\n", + true, + "use crate::relocated::Widget;", + "Widget", + None, + ) + .await; +} + +#[tokio::test] +async fn move_symbol_reports_aliases_that_require_manual_imports() { + for source_import in [ + "use super::model::Widget as\n Alias;", + "#[cfg(all())]\nuse crate::model::Widget as /* source configuration */ Alias;", + ] { + let hints = move_with_destination_dependency( + "use crate::model::Widget as Alias;\n", + "pub fn marker() {}\n", + "\n", + true, + source_import, + "Alias", + None, + ) + .await; + assert_eq!( + hints + .iter() + .filter(|hint| hint.kind == "import_needed") + .count(), + 1, + "an unportable alias must remain visible in the dependency report" + ); + } +} + #[tokio::test] async fn move_symbol_preserves_destination_bytes_when_adding_imports() { move_with_destination_header( @@ -221,6 +366,20 @@ async fn move_symbol_refuses_unsupported_destination_before_preview_or_apply() { "#[cfg(all())]\nuse crate::model::Widget;\npub fn marker() {}\n", true, ), + ( + "#[cfg(any())]\nuse crate::{model::{Widget}};\npub fn marker() {}\n", + true, + ), + ( + "#[cfg(all())]\nuse crate::model::\n Widget;\npub fn marker() {}\n", + true, + ), + ("use std::fmt::Error as Widget;\npub fn marker() {}\n", true), + ("pub struct Widget;\npub fn marker() {}\n", true), + ( + "mod other { pub struct Widget; }\nuse self::other::*;\npub fn marker(value: Widget) -> Widget { value }\n", + true, + ), ] { let project = tempdir().unwrap(); fs::create_dir(project.path().join("src")).unwrap(); diff --git a/crates/tracedecay-source-edit/src/move_symbol/mod.rs b/crates/tracedecay-source-edit/src/move_symbol/mod.rs index a89a6a3528..62548bfe73 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/mod.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/mod.rs @@ -486,7 +486,7 @@ async fn analyze_dependencies( if matched.is_empty() { continue; } - if stmt.leaves.len() == 1 && !stmt.glob { + if stmt.leaves.len() == 1 && !stmt.has_unnamed_dependencies { if let Some(import) = portable_dependency_import(&stmt) { out.auto_imports.push(import); } else { @@ -534,7 +534,7 @@ async fn analyze_dependencies( file: dest_rel.to_string(), line: None, detail: format!( - "moved body uses {names:?} from a grouped/glob import in {source_rel}" + "moved body uses {names:?} from a grouped or unnamed import in {source_rel}" ), suggestion: Some(format!( "add an import for {names:?} at the destination (from `{}`)", diff --git a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs index abb3de3c88..e07d0e9ae5 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs @@ -12,7 +12,7 @@ use tree_sitter::Tree; use crate::edits::{MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff}; -use super::use_parsing::has_outer_attributes; +use super::use_parsing::{has_outer_attributes, use_bindings}; /// Removes `lines[start..=end]` and collapses the blank-line separator the /// removed item left behind so the source stays tidy. @@ -156,6 +156,8 @@ fn rust_import_offset(source: &str) -> Result { /// Drops matching unconditional module imports and de-duplicates within the /// batch. Text in comments or nested scopes cannot supply a module binding. +/// Without name resolution, declared names and unknown wildcard bindings +/// conservatively prevent insertion that could change existing uses. pub(super) fn dedup_preserve( imports: &[String], dest_original: &str, @@ -166,56 +168,122 @@ pub(super) fn dedup_preserve( } let tree = parse_rust_destination(dest_original)?; let mut existing = HashSet::new(); + let mut bound_names = HashSet::new(); + let mut declared_names = HashSet::new(); let mut attributed = HashSet::new(); + let mut has_wildcard = false; for node in tree.root_node().named_children(&mut tree.walk()) { if node.kind() != "use_declaration" { + if let Some(name) = node + .child_by_field_name("alias") + .or_else(|| node.child_by_field_name("name")) + .and_then(|name| name.utf8_text(dest_original.as_bytes()).ok()) + { + declared_names.insert(name.to_owned()); + } continue; } - let path = node + let bindings = node .child_by_field_name("argument") - .and_then(|argument| argument.utf8_text(dest_original.as_bytes()).ok()) + .and_then(|argument| use_bindings(argument, dest_original.as_bytes())) .ok_or_else(|| { destination_syntax_error("cannot identify a complete destination import") })?; - let import = format!("use {path};"); - if has_outer_attributes(node) { - attributed.insert(import); - } else { - existing.insert(import); + has_wildcard |= bindings.wildcard; + let has_attributes = has_outer_attributes(node); + for leaf in bindings.leaves { + if has_attributes { + attributed.insert(leaf.binding); + } else { + bound_names.insert(leaf.binding.clone()); + existing.insert(leaf); + } } } - let mut seen: HashSet = HashSet::new(); + let import_source = imports.join("\n"); + let import_tree = parse_rust_destination(&import_source)?; + let mut cursor = import_tree.walk(); + let mut declarations = import_tree + .root_node() + .named_children(&mut cursor) + .filter(|node| node.kind() == "use_declaration"); let mut out = Vec::new(); for imp in imports { - let trimmed = imp.trim(); - if existing.contains(trimmed) { + let declaration = declarations.next().ok_or_else(|| { + destination_syntax_error("cannot identify an automatic dependency import") + })?; + let bindings = declaration + .child_by_field_name("argument") + .and_then(|argument| use_bindings(argument, import_source.as_bytes())) + .ok_or_else(|| { + destination_syntax_error("cannot identify an automatic dependency binding") + })?; + let [leaf] = bindings.leaves.as_slice() else { + return Err(destination_syntax_error( + "automatic dependency import must provide exactly one named binding", + )); + }; + if bindings.wildcard + || bindings.anonymous + || declaration.utf8_text(import_source.as_bytes()).ok() != Some(imp.trim()) + { + return Err(destination_syntax_error( + "automatic dependency import must be one complete named declaration", + )); + } + if existing.contains(leaf) { continue; } - if attributed.contains(trimmed) { + if attributed.contains(&leaf.binding) { // A conditional import may or may not bind the name. Neither // dropping the dependency nor adding an unconditional duplicate // is safe without resolving that configuration. return Err(destination_syntax_error(format!( - "automatic import matches an attributed destination declaration: {trimmed}" + "automatic import conflicts with an attributed destination binding: {}", + leaf.binding + ))); + } + if bound_names.contains(&leaf.binding) { + return Err(destination_syntax_error(format!( + "automatic import conflicts with a different destination binding: {}", + leaf.binding ))); } // An import of an item the destination module itself declares // (`use crate::SweepType;` inserted into `src/lib.rs`) would be a // self-import: the name is already in scope at the destination. if let Some(dest_module) = dest_module - && let Some(rest) = trimmed - .strip_prefix("use ") - .and_then(|rest| rest.strip_suffix(';')) - && rest + && let Some(path) = &leaf.canonical_path + && path .strip_prefix(dest_module) .and_then(|tail| tail.strip_prefix("::")) - .is_some_and(|leaf| !leaf.contains("::")) + .is_some_and(|name| !name.contains("::") && name == leaf.binding) { continue; } - if seen.insert(trimmed.to_string()) { - out.push(imp.clone()); + if declared_names.contains(&leaf.binding) { + return Err(destination_syntax_error(format!( + "automatic import overlaps a destination item name: {}", + leaf.binding + ))); + } + if has_wildcard { + // An explicit binding can silently replace a name supplied by a + // glob. Without resolving it, the existing uses cannot be proven + // to retain their meaning. Anonymous aliases do not bind names. + return Err(destination_syntax_error(format!( + "automatic import may shadow a destination wildcard binding: {}", + leaf.binding + ))); } + bound_names.insert(leaf.binding.clone()); + existing.insert(leaf.clone()); + out.push(imp.clone()); + } + if declarations.next().is_some() { + return Err(destination_syntax_error( + "automatic dependency import contains extra declarations", + )); } Ok(out) } diff --git a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs index 9e3d88884e..fb82e20b3a 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs @@ -10,9 +10,20 @@ use std::collections::HashSet; use tree_sitter::{Node as TsNode, Parser}; /// A single binding brought into scope by a `use` statement. +#[derive(Clone, Eq, Hash, PartialEq)] pub(super) struct UseLeaf { /// The name as referenced in code (the alias when `as` is present). pub(super) binding: String, + /// The syntax-derived path, independent of grouping, whitespace and + /// comments. An unverified fallback never supplies a path identity. + pub(super) canonical_path: Option, +} + +#[derive(Default)] +pub(super) struct UseBindings { + pub(super) wildcard: bool, + pub(super) anonymous: bool, + pub(super) leaves: Vec, } /// A parsed `use` statement from a source file. @@ -27,8 +38,9 @@ pub(super) struct UseStatement { pub(super) removable: bool, /// 1-based line of the statement. pub(super) line: u32, - /// Whether the statement is a glob import (`use a::*;`). - pub(super) glob: bool, + /// A glob or anonymous (`as _`) import cannot be attributed to one named + /// dependency, so moving a named leaf must not relocate the whole import. + pub(super) has_unnamed_dependencies: bool, pub(super) leaves: Vec, } @@ -140,7 +152,7 @@ fn collect_use_declarations( if let Ok(text) = node.utf8_text(src) && let Some(argument) = node.child_by_field_name("argument") && let Ok(path) = argument.utf8_text(src) - && let Some((glob, leaves)) = parse_use_bindings(path) + && let Some(bindings) = use_bindings(argument, src) { let mut cursor = node.walk(); let plain_declaration = syntax_complete && !has_outer_attributes(node); @@ -154,8 +166,8 @@ fn collect_use_declarations( .then(|| path.to_string()), removable, line: node.start_position().row as u32 + 1, - glob, - leaves, + has_unnamed_dependencies: bindings.wildcard || bindings.anonymous, + leaves: bindings.leaves, }); } // `use_declaration` nodes do not nest; no need to descend further. @@ -186,13 +198,13 @@ fn parse_use_statements_linescan(source: &str) -> Vec { else { continue; }; - if let Some((glob, leaves)) = parse_use_bindings(path) { + if let Some((has_unnamed_dependencies, leaves)) = fallback_use_bindings(path) { out.push(UseStatement { text: raw.to_string(), portable_path: None, removable: false, line: (idx + 1) as u32, - glob, + has_unnamed_dependencies, leaves, }); } @@ -200,30 +212,132 @@ fn parse_use_statements_linescan(source: &str) -> Vec { out } -/// Parses bindings from the import argument, after the syntax authority has -/// separated visibility and the declaration boundary. -fn parse_use_bindings(body: &str) -> Option<(bool, Vec)> { +/// Reads binding identities from the grammar's argument fields. The original +/// argument remains the rendering payload; only comparison ignores trivia. +pub(super) fn use_bindings(argument: TsNode<'_>, src: &[u8]) -> Option { + let mut bindings = UseBindings::default(); + collect_use_bindings(argument, None, src, &mut bindings)?; + Some(bindings) +} + +fn collect_use_bindings( + node: TsNode<'_>, + prefix: Option<&str>, + src: &[u8], + bindings: &mut UseBindings, +) -> Option<()> { + match node.kind() { + "scoped_use_list" => { + let path = match node.child_by_field_name("path") { + Some(path) => canonical_use_path(path, src)?, + None => "::".to_owned(), + }; + let path = join_use_path(prefix, &path)?; + collect_use_bindings( + node.child_by_field_name("list")?, + Some(&path), + src, + bindings, + )?; + } + "use_list" => { + for child in node.named_children(&mut node.walk()) { + if !matches!(child.kind(), "line_comment" | "block_comment") { + collect_use_bindings(child, prefix, src, bindings)?; + } + } + } + "use_wildcard" => bindings.wildcard = true, + _ => { + let (path, alias) = if node.kind() == "use_as_clause" { + ( + node.child_by_field_name("path")?, + Some(node.child_by_field_name("alias")?.utf8_text(src).ok()?), + ) + } else { + (node, None) + }; + let path = join_use_path(prefix, &canonical_use_path(path, src)?)?; + let binding = alias.or_else(|| path.rsplit("::").next())?; + if matches!(binding, "_" | "self" | "super" | "crate") { + bindings.anonymous = true; + } else { + bindings.leaves.push(UseLeaf { + binding: binding.to_owned(), + canonical_path: Some(path), + }); + } + } + } + Some(()) +} + +fn canonical_use_path(node: TsNode<'_>, src: &[u8]) -> Option { + match node.kind() { + "identifier" | "crate" | "self" | "super" => Some(node.utf8_text(src).ok()?.to_owned()), + "scoped_identifier" => { + let name = canonical_use_path(node.child_by_field_name("name")?, src)?; + let path = match node.child_by_field_name("path") { + Some(path) => canonical_use_path(path, src)?, + None => String::new(), + }; + Some(format!("{path}::{name}")) + } + _ => None, + } +} + +fn join_use_path(prefix: Option<&str>, path: &str) -> Option { + match (prefix, path) { + (None, path) => Some(path.to_owned()), + (Some(prefix), "self") => Some(prefix.to_owned()), + (Some(_), path) if path.starts_with("::") => None, + (Some("::"), path) => Some(format!("::{path}")), + (Some(prefix), path) => Some(format!("{prefix}::{path}")), + } +} + +/// Unverified names are only useful for dependency hints when the grammar is +/// unavailable. They never authorize insertion, deduplication or removal. +fn fallback_use_bindings(body: &str) -> Option<(bool, Vec)> { let body = body.trim(); - let glob = body.contains('*'); + let mut has_unnamed_dependencies = body.contains('*'); let leaves: Vec = if let Some(open) = body.find('{') { let inner = body[open + 1..].trim_end().trim_end_matches('}'); inner .split(',') - .filter_map(|item| leaf_binding(item.trim())) - .map(|binding| UseLeaf { binding }) + .filter_map(|item| fallback_leaf_binding(item.trim())) + .filter(|binding| { + has_unnamed_dependencies |= binding == "_"; + binding != "_" + }) + .map(|binding| UseLeaf { + binding, + canonical_path: None, + }) .collect() } else { - leaf_binding(body).map_or_default(|binding| vec![UseLeaf { binding }]) + fallback_leaf_binding(body) + .filter(|binding| { + has_unnamed_dependencies |= binding == "_"; + binding != "_" + }) + .map_or_default(|binding| { + vec![UseLeaf { + binding, + canonical_path: None, + }] + }) }; - if leaves.is_empty() && !glob { + if leaves.is_empty() && !has_unnamed_dependencies { return None; } - Some((glob, leaves)) + Some((has_unnamed_dependencies, leaves)) } /// The in-code binding for a single `use` path segment (`a::b::C` -> `C`, /// `a::C as D` -> `D`). Returns `None` for globs and empty items. -fn leaf_binding(item: &str) -> Option { +fn fallback_leaf_binding(item: &str) -> Option { let item = item.trim(); if item.is_empty() || item == "*" || item.ends_with("::*") { return None; @@ -269,12 +383,35 @@ mod tests { let stmts = parse_use_statements(src); assert_eq!(stmts.len(), 4); assert_eq!(stmts[0].leaves[0].binding, "HashMap"); - assert!(!stmts[0].glob); + assert!(!stmts[0].has_unnamed_dependencies); assert_eq!(stmts[1].leaves[0].binding, "C"); assert_eq!(stmts[2].leaves.len(), 2); assert_eq!(stmts[2].leaves[0].binding, "Y"); assert_eq!(stmts[2].leaves[1].binding, "Z"); - assert!(stmts[3].glob); + assert!(stmts[3].has_unnamed_dependencies); + } + + #[test] + fn use_bindings_preserve_path_identity_and_unnamed_dependencies() { + let imports = parse_use_statements( + "use ::std::{fmt::{self as\n Formatting, Debug as _}, collections::HashMap, io::*};", + ); + assert_eq!(imports.len(), 1); + assert!(imports[0].has_unnamed_dependencies); + assert_eq!( + imports[0] + .leaves + .iter() + .map(|leaf| (leaf.binding.as_str(), leaf.canonical_path.as_deref())) + .collect::>(), + [ + ("Formatting", Some("::std::fmt")), + ("HashMap", Some("::std::collections::HashMap")), + ] + ); + let fallback = parse_use_statements_linescan("use crate::model::Widget as Alias;"); + assert_eq!(fallback.len(), 1); + assert!(fallback[0].leaves[0].canonical_path.is_none()); } #[test] From 0b6e8b5e7f2ee2c444e84648244f166fd05f8ab3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:06:00 -0700 Subject: [PATCH 4/4] fix(source-edit): reuse imports with inert lint attributes Recognize direct built-in lint attributes when reusing existing destination imports. Keep conditional, unknown, qualified and procedural attributes on the conservative refusal path, and preserve source attribute ownership. Extend the real move preview/apply fixtures for lint-attributed equivalent imports and mixed conditional or unknown attributes without changing existing assertions, warnings, or mutation boundaries. (cherry picked from commit 5acd312fc33039b251dcac452fbe85c63279f49e) --- .../src/edits/move_destination_tests.rs | 39 ++++++++++++ .../src/move_symbol/rendering.rs | 10 +-- .../src/move_symbol/use_parsing.rs | 63 ++++++++++++++----- 3 files changed, 93 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs index 757b45c5c7..bffa5cd565 100644 --- a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs +++ b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs @@ -285,6 +285,33 @@ async fn move_symbol_reuses_equivalent_destination_imports() { .await; } +#[tokio::test] +async fn move_symbol_reuses_lint_attributed_destination_imports() { + for (destination_import, source_import, binding) in [ + ( + "#[allow(unused_imports)]\nuse crate::model::Widget;\n", + "use crate::model::Widget;", + "Widget", + ), + ( + "#[warn(\n unsafe_code,\n)]\n// Keep the lint policy and public alias.\n#[deny(non_snake_case)]\npub use crate::{model::{Widget as Alias}};\n", + "use crate::model::Widget as Alias;", + "Alias", + ), + ] { + move_with_destination_dependency( + destination_import, + "pub fn marker() {}\n", + "\n", + true, + source_import, + binding, + None, + ) + .await; + } +} + #[tokio::test] async fn move_symbol_preserves_destination_item_bindings() { let import = "use crate::relocated::Widget as\n Alias;"; @@ -374,6 +401,18 @@ async fn move_symbol_refuses_unsupported_destination_before_preview_or_apply() { "#[cfg(all())]\nuse crate::model::\n Widget;\npub fn marker() {}\n", true, ), + ( + "#[cfg_attr(all(), cfg(any()))]\n#[allow(unused_imports)]\nuse crate::model::Widget;\npub fn marker() {}\n", + true, + ), + ( + "#[custom::allow(unused_imports)]\n#[allow(unused_imports)]\nuse crate::model::Widget;\npub fn marker() {}\n", + true, + ), + ( + "#[transform]\n#[deny(unsafe_code)]\nuse crate::model::Widget;\npub fn marker() {}\n", + true, + ), ("use std::fmt::Error as Widget;\npub fn marker() {}\n", true), ("pub struct Widget;\npub fn marker() {}\n", true), ( diff --git a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs index e07d0e9ae5..78591e7961 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs @@ -12,7 +12,7 @@ use tree_sitter::Tree; use crate::edits::{MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff}; -use super::use_parsing::{has_outer_attributes, use_bindings}; +use super::use_parsing::{destination_import_is_unconditional, use_bindings}; /// Removes `lines[start..=end]` and collapses the blank-line separator the /// removed item left behind so the source stays tidy. @@ -190,13 +190,13 @@ pub(super) fn dedup_preserve( destination_syntax_error("cannot identify a complete destination import") })?; has_wildcard |= bindings.wildcard; - let has_attributes = has_outer_attributes(node); + let unconditional = destination_import_is_unconditional(node, dest_original.as_bytes()); for leaf in bindings.leaves { - if has_attributes { - attributed.insert(leaf.binding); - } else { + if unconditional { bound_names.insert(leaf.binding.clone()); existing.insert(leaf); + } else { + attributed.insert(leaf.binding); } } } diff --git a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs index fb82e20b3a..4a6c38041b 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/use_parsing.rs @@ -110,20 +110,55 @@ fn source_relative_use_path(node: TsNode<'_>) -> bool { } pub(super) fn has_outer_attributes(node: TsNode<'_>) -> bool { - let mut previous = node.prev_named_sibling(); - while let Some(sibling) = previous { - match sibling.kind() { - "attribute_item" => return true, - "line_comment" | "block_comment" => { - if sibling.child_by_field_name("outer").is_some() { - return true; - } - previous = sibling.prev_named_sibling(); - } - _ => return false, - } - } - false + outer_attributes(node).next().is_some() +} + +/// Built-in lint attributes leave an existing destination binding available. +/// Source relocation still rejects every attached attribute so its scope and +/// ownership cannot silently change. Unknown attributes may transform imports. +pub(super) fn destination_import_is_unconditional(node: TsNode<'_>, src: &[u8]) -> bool { + outer_attributes(node).all(|attribute| is_lint_attribute(attribute, src)) +} + +fn outer_attributes(node: TsNode<'_>) -> impl Iterator> { + std::iter::successors(node.prev_named_sibling(), |node| node.prev_named_sibling()) + .take_while(|node| { + matches!( + node.kind(), + "attribute_item" | "line_comment" | "block_comment" + ) + }) + .filter(|node| { + node.kind() == "attribute_item" || node.child_by_field_name("outer").is_some() + }) +} + +fn is_lint_attribute(node: TsNode<'_>, src: &[u8]) -> bool { + let Some(attribute) = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "attribute") + else { + return false; + }; + let Some(path) = attribute + .named_children(&mut attribute.walk()) + .find(|child| !matches!(child.kind(), "line_comment" | "block_comment")) + else { + return false; + }; + path.kind() == "identifier" + && matches!( + path.utf8_text(src), + Ok("allow" | "expect" | "warn" | "deny" | "forbid") + ) + && attribute + .child_by_field_name("arguments") + .is_some_and(|arguments| { + arguments.kind() == "token_tree" + && arguments + .child(0) + .is_some_and(|delimiter| delimiter.kind() == "(") + }) } /// Tree-sitter path: collect every `use_declaration` node (top-level and inside