Skip to main content

aft/imports/
mod.rs

1//! Import analysis engine: parsing, grouping, deduplication, and insertion.
2//!
3//! Per-language behavior is provided by [`ImportSyntax`] implementations,
4//! resolved through the [`syntax_for`] registry. Each engine extracts imports
5//! from tree-sitter ASTs, classifies them into groups, and generates import
6//! text. A single import's structured shape is carried by [`ImportForm`].
7//!
8//! Currently supports: TypeScript, TSX, JavaScript, Python, Rust, Go.
9
10use std::ops::Range;
11
12use tree_sitter::{Node, Parser, Tree};
13
14use crate::parser::{grammar_for, LangId};
15
16mod c;
17pub(crate) use c::{classify_group_c_import_kind, normalize_include_module};
18mod csharp;
19mod java;
20mod kotlin;
21mod lua;
22mod perl;
23mod php;
24pub(crate) use php::{php_grouped_use_matches_module, php_grouped_use_shares_prefix};
25mod ruby;
26mod scala;
27pub(crate) use scala::scala_block_uses_scala2_dialect;
28mod swift;
29
30// ---------------------------------------------------------------------------
31// Shared types
32// ---------------------------------------------------------------------------
33
34/// What kind of import this is.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ImportKind {
37    /// `import { X } from 'y'` or `import X from 'y'`
38    Value,
39    /// `import type { X } from 'y'`
40    Type,
41    /// `import './side-effect'`
42    SideEffect,
43}
44
45/// Which logical group an import belongs to (language-specific).
46///
47/// Ordering matches conventional import group sorting:
48///   Stdlib (first) < External < Internal (last)
49///
50/// Language mapping:
51///   - TS/JS/TSX: External (no `.` prefix), Internal (`.`/`..` prefix)
52///   - Python:    Stdlib, External (third-party), Internal (relative `.`/`..`)
53///   - Rust:      Stdlib (std/core/alloc), External (crates), Internal (crate/self/super)
54///   - Go:        Stdlib (no dots in path), External (dots in path)
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum ImportGroup {
57    /// Standard library (Python stdlib, Rust std/core/alloc, Go stdlib).
58    /// TS/JS don't use this group.
59    Stdlib,
60    /// External/third-party packages.
61    External,
62    /// Internal/relative imports (TS relative, Python local, Rust crate/self/super).
63    Internal,
64}
65
66impl ImportGroup {
67    /// Human-readable label for the group.
68    pub fn label(&self) -> &'static str {
69        match self {
70            ImportGroup::Stdlib => "stdlib",
71            ImportGroup::External => "external",
72            ImportGroup::Internal => "internal",
73        }
74    }
75}
76
77/// Structured, language-honest representation of a single import's shape.
78///
79/// This is the migration target that replaces the TS-shaped flat fields
80/// (`names`/`default_import`/`namespace_import`) and their per-language
81/// overloads (Rust packs `"pub"` into `default_import`; Go packs the alias
82/// there). It is introduced additively alongside the flat fields (Stream M of
83/// the imports-refactor plan): parsers populate BOTH, readers migrate onto
84/// `form` one at a time behind the golden-parity gate, and the flat fields are
85/// removed once no reader depends on them. New-language variants (Static,
86/// Include, RuntimeRequire, …) are added when their engines land — only the
87/// variants the existing engines produce exist today.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ImportForm {
90    /// ES modules: TypeScript, TSX, JavaScript.
91    /// `named` holds verbatim specifiers (`"useState"`, `"stdin as input"`,
92    /// `"type Foo"`, `"type Foo as Bar"`) — see [`specifier_imported_name`].
93    Es {
94        default_import: Option<String>,
95        namespace_import: Option<String>,
96        named: Vec<String>,
97        /// Statement-level `import type { ... }`.
98        type_only: bool,
99        /// Side-effect-only `import "mod"` (no bindings).
100        side_effect: bool,
101    },
102    /// Python `import module` (`from_import = false`) or
103    /// `from module import a, b` (`from_import = true`).
104    Python {
105        from_import: bool,
106        named: Vec<String>,
107    },
108    /// Rust `use path;` / `pub use path;`. `visibility` replaces the
109    /// `default_import == "pub"` overload (`Some("pub")`, `Some("pub(crate)")`,
110    /// …). The brace/use-tree text remains carried by `module_path` per the
111    /// lossless-round-trip decision; `named` holds extracted use-list names.
112    RustUse {
113        visibility: Option<String>,
114        named: Vec<String>,
115    },
116    /// Go import. `alias` replaces the `default_import` overload, including the
117    /// blank (`_`) and dot (`.`) import bindings.
118    Go { alias: Option<String> },
119    /// Solidity import, in one of four forms:
120    /// - side-effect: `import "x";` (all empty)
121    /// - named: `import { A, B as C } from "x";` (`named`)
122    /// - namespace: `import * as A from "x";` (`namespace`)
123    /// - whole-file alias: `import "x" as A;` (`alias`)
124    ///
125    /// `named` holds verbatim specifiers (`"A"`, `"B as C"`) like the ES form,
126    /// so [`specifier_imported_name`] / [`specifier_local_name`] apply.
127    Solidity {
128        named: Vec<String>,
129        namespace: Option<String>,
130        alias: Option<String>,
131    },
132    /// Generic structured form shared by the Phase-1 engines (Java, C#, PHP,
133    /// Kotlin, Scala, Swift, …). Carries the full schema field set so a new
134    /// engine does not need its own enum variant; `module_path` (on the parent
135    /// `ImportStatement`) holds the path/FQN. `named` uses the verbatim
136    /// specifier convention.
137    Structured {
138        named: Vec<String>,
139        namespace: Option<String>,
140        alias: Option<String>,
141        modifiers: Vec<String>,
142        import_kind: Option<String>,
143    },
144}
145
146/// Structured request to generate a single import line. Superset of the fields
147/// the public `aft_import` schema exposes; each engine reads only the subset it
148/// supports. New languages add fields here rather than growing positional
149/// parameters on every generator signature.
150#[derive(Debug, Clone)]
151pub struct ImportRequest<'a> {
152    pub module_path: &'a str,
153    pub names: &'a [String],
154    pub default_import: Option<&'a str>,
155    /// ES `* as ns` / Solidity `* as A`.
156    pub namespace: Option<&'a str>,
157    /// Whole-module local alias (Solidity `import "x" as A`).
158    pub alias: Option<&'a str>,
159    pub type_only: bool,
160    /// Statement-level modifier tokens (Java/C# `static`, C# `global`/`unsafe`,
161    /// `wildcard`, Swift `@testable`, …). Empty for the legacy engines.
162    pub modifiers: &'a [String],
163    /// Symbol-kind-specific import (PHP `function`/`const`, Swift `struct`/…,
164    /// Scala `given`). Absent for the legacy engines.
165    pub import_kind: Option<&'a str>,
166}
167
168/// Empty default for the `modifiers` slice so legacy callers need not allocate.
169const NO_MODIFIERS: &[String] = &[];
170
171impl<'a> ImportRequest<'a> {
172    /// Construct a request carrying only the legacy positional fields; the
173    /// structured fields (alias/modifiers/import_kind) default to absent. Used
174    /// by the back-compat free-function wrappers.
175    pub fn legacy(
176        module_path: &'a str,
177        names: &'a [String],
178        default_import: Option<&'a str>,
179        namespace: Option<&'a str>,
180        type_only: bool,
181    ) -> Self {
182        ImportRequest {
183            module_path,
184            names,
185            default_import,
186            namespace,
187            alias: None,
188            type_only,
189            modifiers: NO_MODIFIERS,
190            import_kind: None,
191        }
192    }
193}
194
195/// A single parsed import statement.
196#[derive(Debug, Clone)]
197pub struct ImportStatement {
198    /// The module path (e.g., `react`, `./utils`, `../config`).
199    pub module_path: String,
200    /// Named imports (e.g., `["useState", "useEffect"]`).
201    pub names: Vec<String>,
202    /// Default import name (e.g., `React` from `import React from 'react'`).
203    pub default_import: Option<String>,
204    /// Namespace import name (e.g., `path` from `import * as path from 'path'`).
205    pub namespace_import: Option<String>,
206    /// What kind: value, type, or side-effect.
207    pub kind: ImportKind,
208    /// Which group this import belongs to.
209    pub group: ImportGroup,
210    /// Byte range in the original source.
211    pub byte_range: Range<usize>,
212    /// Raw text of the import statement.
213    pub raw_text: String,
214    /// Structured, de-overloaded representation (Stream M migration target).
215    /// Populated by every parser alongside the flat fields above; readers
216    /// migrate onto this incrementally behind the golden-parity gate.
217    pub form: ImportForm,
218}
219
220/// A block of parsed imports from a file.
221#[derive(Debug, Clone)]
222pub struct ImportBlock {
223    /// All parsed import statements, in source order.
224    pub imports: Vec<ImportStatement>,
225    /// Overall byte range covering all import statements (start of first to end of last).
226    /// `None` if no imports found.
227    pub byte_range: Option<Range<usize>>,
228}
229
230impl ImportBlock {
231    pub fn empty() -> Self {
232        ImportBlock {
233            imports: Vec::new(),
234            byte_range: None,
235        }
236    }
237}
238
239pub(crate) fn import_byte_range(imports: &[ImportStatement]) -> Option<Range<usize>> {
240    imports.first().zip(imports.last()).map(|(first, last)| {
241        let start = first.byte_range.start;
242        let end = last.byte_range.end;
243        start..end
244    })
245}
246
247// ---------------------------------------------------------------------------
248// Specifier helpers (TS/JS verbatim-string format)
249// ---------------------------------------------------------------------------
250
251/// Return the local binding name for a TS/JS named-import specifier stored in
252/// `ImportStatement::names`. Specifiers are stored verbatim — e.g.
253/// `"stdin as input"`, `"type Foo"`, `"type Foo as Bar"`, `"useState"` — so
254/// callers that want the name actually introduced into scope must strip the
255/// optional `type ` prefix and prefer the post-`as` identifier when present.
256///
257/// Examples:
258///   `"useState"`            → `"useState"`
259///   `"stdin as input"`      → `"input"`
260///   `"type Foo"`            → `"Foo"`
261///   `"type Foo as Bar"`     → `"Bar"`
262pub fn specifier_local_name(spec: &str) -> &str {
263    let trimmed = spec.trim();
264    let after_type = trimmed
265        .strip_prefix("type ")
266        .unwrap_or(trimmed)
267        .trim_start();
268    if let Some(idx) = after_type.find(" as ") {
269        after_type[idx + 4..].trim()
270    } else {
271        after_type
272    }
273}
274
275/// Return the imported (pre-`as`) name for a TS/JS named-import specifier.
276/// Used by dedup, remove, and any caller that needs the source-side name.
277///
278/// Examples:
279///   `"useState"`            → `"useState"`
280///   `"stdin as input"`      → `"stdin"`
281///   `"type Foo"`            → `"Foo"`
282///   `"type Foo as Bar"`     → `"Foo"`
283pub fn specifier_imported_name(spec: &str) -> &str {
284    let trimmed = spec.trim();
285    let after_type = trimmed
286        .strip_prefix("type ")
287        .unwrap_or(trimmed)
288        .trim_start();
289    after_type
290        .find(" as ")
291        .map(|idx| after_type[..idx].trim())
292        .unwrap_or(after_type)
293}
294
295/// Whether a stored specifier matches a target name. Matches against either
296/// the imported name or the local binding so callers can pass whichever name
297/// they observed in source. Useful for `remove_import` where the agent may
298/// reference an aliased import by either name.
299pub fn specifier_matches(spec: &str, target: &str) -> bool {
300    specifier_imported_name(spec) == target || specifier_local_name(spec) == target
301}
302
303// ---------------------------------------------------------------------------
304// Per-language engine: the ImportSyntax trait + registry
305// ---------------------------------------------------------------------------
306
307/// Per-language import engine. One impl per supported language; [`syntax_for`]
308/// maps a [`LangId`] to its `&'static dyn ImportSyntax`. This is the single
309/// plug-in point that replaces the scattered `match lang` dispatch in
310/// `parse_imports` / `generate_import_line_with_namespace` / `classify_group` /
311/// `is_supported`. Adding a language is a new impl + one registry arm.
312///
313/// The existing engines are thin wrappers over the free functions they already
314/// used, so routing through the trait is behavior-preserving (golden-gated).
315pub trait ImportSyntax: Sync {
316    /// Parse all imports from a file's already-parsed tree.
317    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock;
318
319    /// Generate a single import line from a structured [`ImportRequest`].
320    /// Engines read only the fields they support and ignore the rest.
321    fn generate_line(&self, req: &ImportRequest) -> String;
322
323    /// Classify a module path into stdlib / external / internal.
324    fn classify_group(&self, module_path: &str) -> ImportGroup;
325}
326
327/// ES modules engine: TypeScript, TSX, JavaScript.
328struct EsSyntax;
329impl ImportSyntax for EsSyntax {
330    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
331        parse_ts_imports(source, tree)
332    }
333    fn generate_line(&self, req: &ImportRequest) -> String {
334        generate_ts_import_line(
335            req.module_path,
336            req.names,
337            req.default_import,
338            req.namespace,
339            req.type_only,
340        )
341    }
342    fn classify_group(&self, module_path: &str) -> ImportGroup {
343        classify_group_ts(module_path)
344    }
345}
346
347struct PythonSyntax;
348impl ImportSyntax for PythonSyntax {
349    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
350        parse_py_imports(source, tree)
351    }
352    fn generate_line(&self, req: &ImportRequest) -> String {
353        generate_py_import_line(req.module_path, req.names, req.default_import)
354    }
355    fn classify_group(&self, module_path: &str) -> ImportGroup {
356        classify_group_py(module_path)
357    }
358}
359
360struct RustSyntax;
361impl ImportSyntax for RustSyntax {
362    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
363        parse_rs_imports(source, tree)
364    }
365    fn generate_line(&self, req: &ImportRequest) -> String {
366        generate_rs_import_line(req.module_path, req.names, req.type_only)
367    }
368    fn classify_group(&self, module_path: &str) -> ImportGroup {
369        classify_group_rs(module_path)
370    }
371}
372
373struct GoSyntax;
374impl ImportSyntax for GoSyntax {
375    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
376        parse_go_imports(source, tree)
377    }
378    fn generate_line(&self, req: &ImportRequest) -> String {
379        generate_go_import_line(req.module_path, req.default_import, false)
380    }
381    fn classify_group(&self, module_path: &str) -> ImportGroup {
382        classify_group_go(module_path)
383    }
384}
385
386/// Solidity import engine. Supports named / namespace / whole-file-alias /
387/// side-effect forms.
388struct SoliditySyntax;
389impl ImportSyntax for SoliditySyntax {
390    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
391        parse_solidity_imports(source, tree)
392    }
393    fn generate_line(&self, req: &ImportRequest) -> String {
394        generate_solidity_import_line(req)
395    }
396    fn classify_group(&self, module_path: &str) -> ImportGroup {
397        classify_group_solidity(module_path)
398    }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub(crate) enum VueScriptRangeError {
403    MissingScript,
404    MultipleScripts,
405}
406
407impl VueScriptRangeError {
408    pub(crate) fn code(self) -> &'static str {
409        match self {
410            VueScriptRangeError::MissingScript => "missing_vue_script",
411            VueScriptRangeError::MultipleScripts => "ambiguous_vue_script",
412        }
413    }
414
415    pub(crate) fn message(self, command: &str) -> String {
416        match self {
417            VueScriptRangeError::MissingScript => format!(
418                "{command}: Vue import management requires exactly one <script> block; found none"
419            ),
420            VueScriptRangeError::MultipleScripts => format!(
421                "{command}: Vue import management requires exactly one <script> block; found multiple"
422            ),
423        }
424    }
425}
426
427/// Locate the byte range of the single `<script>` block's inner content in a
428/// Vue Single-File Component. tree-sitter-vue exposes the script body as a
429/// single `raw_text` node; this returns `(start, end)` of that node, or — for an
430/// empty `<script></script>` with no `raw_text` child — a zero-width range right
431/// after the start tag. Multiple scripts are ambiguous for byte-level edits and
432/// no-script SFCs have no safe insertion region, so callers should surface the
433/// returned error instead of silently editing byte 0 or the first script.
434pub(crate) fn vue_single_script_content_range(
435    tree: &Tree,
436) -> Result<(usize, usize), VueScriptRangeError> {
437    let root = tree.root_node();
438    let mut ranges = Vec::new();
439    let mut cursor = root.walk();
440    for child in root.named_children(&mut cursor) {
441        if child.kind() == "script_element" {
442            ranges.push(vue_script_element_content_range(&child));
443        }
444    }
445
446    match ranges.len() {
447        0 => Err(VueScriptRangeError::MissingScript),
448        1 => Ok(ranges[0]),
449        _ => Err(VueScriptRangeError::MultipleScripts),
450    }
451}
452
453/// Back-compat convenience wrapper for callers that only need the safe single
454/// script range and intentionally treat missing/ambiguous scripts as absent.
455pub(crate) fn vue_script_content_range(tree: &Tree) -> Option<(usize, usize)> {
456    vue_single_script_content_range(tree).ok()
457}
458
459fn vue_script_element_content_range(child: &Node) -> (usize, usize) {
460    let mut inner = child.walk();
461    for sub in child.named_children(&mut inner) {
462        if sub.kind() == "raw_text" {
463            return (sub.start_byte(), sub.end_byte());
464        }
465    }
466
467    // Empty `<script></script>`: insert right after the start tag.
468    let mut inner2 = child.walk();
469    for sub in child.named_children(&mut inner2) {
470        if sub.kind() == "start_tag" {
471            return (sub.end_byte(), sub.end_byte());
472        }
473    }
474
475    (child.end_byte(), child.end_byte())
476}
477
478/// Parse imports from a Vue SFC `<script>` block. The script body is re-parsed
479/// with the TypeScript grammar (which covers both `lang="ts"` and plain JS
480/// import syntax), then every byte offset is remapped from script-relative to
481/// whole-file positions so insertion, removal, and organize operate correctly.
482fn parse_vue_imports(source: &str, tree: &Tree) -> ImportBlock {
483    let Ok((start, end)) = vue_single_script_content_range(tree) else {
484        return ImportBlock {
485            imports: Vec::new(),
486            byte_range: None,
487        };
488    };
489    let inner = &source[start..end];
490    let mut parser = Parser::new();
491    if parser
492        .set_language(&grammar_for(LangId::TypeScript))
493        .is_err()
494    {
495        return ImportBlock {
496            imports: Vec::new(),
497            byte_range: None,
498        };
499    }
500    let Some(inner_tree) = parser.parse(inner, None) else {
501        return ImportBlock {
502            imports: Vec::new(),
503            byte_range: None,
504        };
505    };
506    let mut block = parse_ts_imports(inner, &inner_tree);
507    for imp in &mut block.imports {
508        imp.byte_range = (imp.byte_range.start + start)..(imp.byte_range.end + start);
509    }
510    block.byte_range = block.byte_range.map(|r| (r.start + start)..(r.end + start));
511    block
512}
513
514/// Vue Single-File Component import engine. The `<script>` body is exposed by
515/// tree-sitter-vue as a single `raw_text` node, so we re-parse it with the
516/// TypeScript grammar and remap the resulting byte offsets back to whole-file
517/// positions. Generation and grouping reuse the ES (TS/JS) engine, since Vue
518/// script imports are TypeScript/JavaScript.
519struct VueSyntax;
520impl ImportSyntax for VueSyntax {
521    fn parse(&self, source: &str, tree: &Tree) -> ImportBlock {
522        parse_vue_imports(source, tree)
523    }
524    fn generate_line(&self, req: &ImportRequest) -> String {
525        generate_ts_import_line(
526            req.module_path,
527            req.names,
528            req.default_import,
529            req.namespace,
530            req.type_only,
531        )
532    }
533    fn classify_group(&self, module_path: &str) -> ImportGroup {
534        classify_group_ts(module_path)
535    }
536}
537
538static ES_SYNTAX: EsSyntax = EsSyntax;
539static PYTHON_SYNTAX: PythonSyntax = PythonSyntax;
540static RUST_SYNTAX: RustSyntax = RustSyntax;
541static GO_SYNTAX: GoSyntax = GoSyntax;
542static SOLIDITY_SYNTAX: SoliditySyntax = SoliditySyntax;
543static VUE_SYNTAX: VueSyntax = VueSyntax;
544
545/// Map a language to its import engine, or `None` when imports are unsupported.
546pub fn syntax_for(lang: LangId) -> Option<&'static dyn ImportSyntax> {
547    match lang {
548        LangId::TypeScript | LangId::Tsx | LangId::JavaScript => Some(&ES_SYNTAX),
549        LangId::Python => Some(&PYTHON_SYNTAX),
550        LangId::Rust => Some(&RUST_SYNTAX),
551        LangId::Go => Some(&GO_SYNTAX),
552        LangId::Solidity => Some(&SOLIDITY_SYNTAX),
553        LangId::Vue => Some(&VUE_SYNTAX),
554        LangId::C => Some(&c::C_SYNTAX),
555        LangId::Cpp => Some(&c::C_SYNTAX),
556        LangId::Java => Some(&java::JAVA_SYNTAX),
557        LangId::Kotlin => Some(&kotlin::KOTLIN_SYNTAX),
558        LangId::Lua => Some(&lua::LUA_SYNTAX),
559        LangId::CSharp => Some(&csharp::CSHARP_SYNTAX),
560        LangId::Php => Some(&php::PHP_SYNTAX),
561        LangId::Perl => Some(&perl::PERL_SYNTAX),
562        LangId::Ruby => Some(&ruby::RUBY_SYNTAX),
563        LangId::Scala => Some(&scala::SCALA_SYNTAX),
564        LangId::Swift => Some(&swift::SWIFT_SYNTAX),
565        LangId::Zig
566        | LangId::Bash
567        | LangId::Scss
568        | LangId::Json
569        | LangId::Html
570        | LangId::Markdown
571        | LangId::Yaml
572        | LangId::Pascal
573        | LangId::R
574        | LangId::Groovy
575        | LangId::ObjC => None,
576    }
577}
578
579// ---------------------------------------------------------------------------
580// Core API
581// ---------------------------------------------------------------------------
582
583/// Parse imports from source using the provided tree-sitter tree.
584pub fn parse_imports(source: &str, tree: &Tree, lang: LangId) -> ImportBlock {
585    match syntax_for(lang) {
586        Some(engine) => engine.parse(source, tree),
587        None => ImportBlock::empty(),
588    }
589}
590
591/// Check if an import with the given module + name combination already exists.
592///
593/// For dedup: same module path and matching binding shape. Side-effect imports
594/// are only duplicates of side-effect imports; namespace imports are distinct
595/// from side-effect imports and from other namespace aliases.
596pub fn is_duplicate(
597    block: &ImportBlock,
598    module_path: &str,
599    names: &[String],
600    default_import: Option<&str>,
601    type_only: bool,
602) -> bool {
603    is_duplicate_with_namespace(block, module_path, names, default_import, None, type_only)
604}
605
606/// Check if an import with the given module + complete binding shape already exists.
607pub fn is_duplicate_with_namespace(
608    block: &ImportBlock,
609    module_path: &str,
610    names: &[String],
611    default_import: Option<&str>,
612    namespace_import: Option<&str>,
613    type_only: bool,
614) -> bool {
615    let target_kind = if type_only {
616        ImportKind::Type
617    } else {
618        ImportKind::Value
619    };
620
621    for imp in &block.imports {
622        if imp.module_path != module_path {
623            continue;
624        }
625
626        // For side-effect imports (no names/default/namespace): module path
627        // match is sufficient only when the existing import is also a
628        // side-effect import. Namespace imports like `import * as fs from 'fs'`
629        // are distinct local bindings and must not be conflated with
630        // `import 'fs'`.
631        if names.is_empty()
632            && default_import.is_none()
633            && namespace_import.is_none()
634            && imp.names.is_empty()
635            && imp.default_import.is_none()
636            && imp.namespace_import.is_none()
637        {
638            return true;
639        }
640
641        // For side-effect imports specifically (TS/JS): module match is enough
642        if names.is_empty()
643            && default_import.is_none()
644            && namespace_import.is_none()
645            && imp.kind == ImportKind::SideEffect
646        {
647            return true;
648        }
649
650        // Kind must match for dedup (value imports don't dedup against type imports)
651        if imp.kind != target_kind && imp.kind != ImportKind::SideEffect {
652            continue;
653        }
654
655        // Default+namespace imports are one ES binding shape. A plain default
656        // import must not satisfy a request for `default, * as ns`, and a
657        // different namespace alias must not either.
658        if let (Some(def), Some(namespace)) = (default_import, namespace_import) {
659            if imp.default_import.as_deref() == Some(def)
660                && imp.namespace_import.as_deref() == Some(namespace)
661                && names
662                    .iter()
663                    .all(|n| imp.names.iter().any(|stored| specifier_matches(stored, n)))
664            {
665                return true;
666            }
667            continue;
668        }
669
670        // Namespace-only requests are satisfied by any existing same-module
671        // import that already binds that namespace alias, even if it also has a
672        // default binding.
673        if names.is_empty()
674            && default_import.is_none()
675            && namespace_import.is_some()
676            && imp.namespace_import.as_deref() == namespace_import
677        {
678            return true;
679        }
680
681        // Check default import match. This branch only handles requests that do
682        // not also ask for a namespace; that combined shape is checked above.
683        if let Some(def) = default_import {
684            if namespace_import.is_none() && imp.default_import.as_deref() == Some(def) {
685                return true;
686            }
687        }
688
689        // Check named imports — if ALL requested names already exist.
690        // Compare on the imported (pre-`as`) name so adding `Foo` is a
691        // no-op when `Foo as Bar` is already imported, but adding
692        // `Foo as Bar` is NOT a duplicate of bare `Foo` (different
693        // local bindings).
694        if !names.is_empty()
695            && names
696                .iter()
697                .all(|n| imp.names.iter().any(|stored| specifier_matches(stored, n)))
698        {
699            return true;
700        }
701    }
702
703    false
704}
705
706/// Check whether a fully structured add-import request is already present.
707///
708/// Legacy ES/Python/Rust/Go callers intentionally keep the historical
709/// subset/dominance semantics (`import { a, b }` satisfies adding `{ a }`). The
710/// newer engines carry language-specific shape in `ImportForm::Structured` (or
711/// Solidity's dedicated form), where module path alone is not enough: include
712/// delimiters, statement kinds, modifiers, aliases, and runtime import flavors
713/// all affect the generated source. Those languages deduplicate on a canonical
714/// full-form key so `#include <x>` does not block `#include "x"`, `load` does
715/// not block `require`, and side-effect Solidity imports do not block aliases.
716pub(crate) fn is_duplicate_import_request(
717    lang: LangId,
718    block: &ImportBlock,
719    req: &ImportRequest<'_>,
720) -> bool {
721    if lang == LangId::Python && req.names.is_empty() && req.default_import.is_none() {
722        return block.imports.iter().any(|imp| {
723            matches!(
724                &imp.form,
725                ImportForm::Python {
726                    from_import: false,
727                    named,
728                } if named.iter().any(|specifier| {
729                    specifier_imported_name(specifier) == req.module_path
730                })
731            )
732        });
733    }
734
735    if !uses_form_aware_dedup(lang) {
736        return is_duplicate_with_namespace(
737            block,
738            req.module_path,
739            req.names,
740            req.default_import,
741            req.namespace,
742            req.type_only,
743        );
744    }
745
746    let target = request_dedup_key(lang, req);
747    block
748        .imports
749        .iter()
750        .map(|imp| statement_dedup_key(lang, imp))
751        .any(|key| key == target)
752}
753
754fn uses_form_aware_dedup(lang: LangId) -> bool {
755    matches!(
756        lang,
757        LangId::Solidity
758            | LangId::C
759            | LangId::Cpp
760            | LangId::Java
761            | LangId::CSharp
762            | LangId::Php
763            | LangId::Kotlin
764            | LangId::Scala
765            | LangId::Swift
766            | LangId::Ruby
767            | LangId::Lua
768            | LangId::Perl
769    )
770}
771
772#[derive(Debug, Clone, PartialEq, Eq)]
773struct ImportDedupKey {
774    module_path: String,
775    kind: ImportKind,
776    form: ImportForm,
777}
778
779fn statement_dedup_key(lang: LangId, imp: &ImportStatement) -> ImportDedupKey {
780    canonical_dedup_key(
781        lang,
782        ImportDedupKey {
783            module_path: imp.module_path.clone(),
784            kind: imp.kind,
785            form: imp.form.clone(),
786        },
787    )
788}
789
790fn request_dedup_key(lang: LangId, req: &ImportRequest<'_>) -> ImportDedupKey {
791    let key = match lang {
792        LangId::Solidity => {
793            let kind = if req.names.is_empty() && req.namespace.is_none() && req.alias.is_none() {
794                ImportKind::SideEffect
795            } else {
796                ImportKind::Value
797            };
798            ImportDedupKey {
799                module_path: req.module_path.to_string(),
800                kind,
801                form: ImportForm::Solidity {
802                    named: req.names.to_vec(),
803                    namespace: req.namespace.map(str::to_string),
804                    alias: req.alias.map(str::to_string),
805                },
806            }
807        }
808        LangId::C | LangId::Cpp => structured_dedup_key(
809            req.module_path,
810            ImportKind::SideEffect,
811            &[],
812            None,
813            None,
814            &[],
815            Some(req.import_kind.or(req.default_import).unwrap_or("system")),
816        ),
817        LangId::Java => {
818            let (mut module_path, modifiers) = wildcard_suffix_request(
819                req.module_path,
820                req.modifiers,
821                req.default_import == Some("*"),
822            );
823            let mut names = req.names.to_vec();
824            normalize_java_static_member_key(&mut module_path, &modifiers, &mut names);
825            structured_dedup_key(
826                &module_path,
827                ImportKind::Value,
828                &names,
829                None,
830                None,
831                &modifiers,
832                None,
833            )
834        }
835        LangId::CSharp => structured_dedup_key(
836            req.module_path,
837            ImportKind::Value,
838            &[],
839            None,
840            req.alias,
841            req.modifiers,
842            None,
843        ),
844        LangId::Php => structured_dedup_key(
845            req.module_path,
846            ImportKind::Value,
847            &[],
848            None,
849            req.alias,
850            req.modifiers,
851            req.import_kind,
852        ),
853        LangId::Kotlin => {
854            let wildcard = req.default_import == Some("*") || req.module_path.ends_with(".*");
855            let (module_path, modifiers) =
856                wildcard_suffix_request(req.module_path, req.modifiers, wildcard);
857            let alias = req
858                .alias
859                .or(req.default_import.filter(|value| *value != "*"));
860            structured_dedup_key(
861                &module_path,
862                ImportKind::Value,
863                &[],
864                None,
865                alias,
866                &modifiers,
867                None,
868            )
869        }
870        LangId::Scala => scala_request_dedup_key(req),
871        LangId::Swift => structured_dedup_key(
872            req.module_path,
873            ImportKind::Value,
874            &[],
875            None,
876            None,
877            req.modifiers,
878            req.import_kind,
879        ),
880        LangId::Ruby => {
881            let mut modifiers = req.modifiers.to_vec();
882            if !modifiers
883                .iter()
884                .any(|modifier| modifier == "quote:single" || modifier == "quote:double")
885            {
886                modifiers.push("quote:single".to_string());
887            }
888            structured_dedup_key(
889                req.module_path,
890                ImportKind::SideEffect,
891                &[],
892                None,
893                None,
894                &modifiers,
895                Some(req.import_kind.unwrap_or("require")),
896            )
897        }
898        LangId::Lua => {
899            let alias = req.default_import.or(req.alias);
900            let kind = if alias.is_some() {
901                ImportKind::Value
902            } else {
903                ImportKind::SideEffect
904            };
905            structured_dedup_key(req.module_path, kind, &[], None, alias, req.modifiers, None)
906        }
907        LangId::Perl => structured_dedup_key(
908            req.module_path,
909            ImportKind::SideEffect,
910            &[],
911            None,
912            None,
913            req.modifiers,
914            Some(req.import_kind.unwrap_or("use")),
915        ),
916        _ => structured_dedup_key(
917            req.module_path,
918            if req.type_only {
919                ImportKind::Type
920            } else {
921                ImportKind::Value
922            },
923            req.names,
924            req.namespace,
925            req.alias,
926            req.modifiers,
927            req.import_kind,
928        ),
929    };
930
931    canonical_dedup_key(lang, key)
932}
933
934fn structured_dedup_key(
935    module_path: &str,
936    kind: ImportKind,
937    named: &[String],
938    namespace: Option<&str>,
939    alias: Option<&str>,
940    modifiers: &[String],
941    import_kind: Option<&str>,
942) -> ImportDedupKey {
943    ImportDedupKey {
944        module_path: module_path.to_string(),
945        kind,
946        form: ImportForm::Structured {
947            named: named.to_vec(),
948            namespace: namespace.map(str::to_string),
949            alias: alias.map(str::to_string),
950            modifiers: modifiers.to_vec(),
951            import_kind: import_kind.map(str::to_string),
952        },
953    }
954}
955
956fn wildcard_suffix_request(
957    module_path: &str,
958    modifiers: &[String],
959    wildcard: bool,
960) -> (String, Vec<String>) {
961    let stripped = module_path.strip_suffix(".*").unwrap_or(module_path);
962    let mut modifiers = modifiers.to_vec();
963    if (wildcard || stripped.len() != module_path.len())
964        && !modifiers.iter().any(|modifier| modifier == "wildcard")
965    {
966        modifiers.push("wildcard".to_string());
967    }
968    (stripped.to_string(), modifiers)
969}
970
971fn normalize_java_static_member_key(
972    module_path: &mut String,
973    modifiers: &[String],
974    names: &mut Vec<String>,
975) {
976    let is_static = modifiers.iter().any(|modifier| modifier == "static");
977    let is_wildcard = modifiers.iter().any(|modifier| modifier == "wildcard");
978    if !is_static || is_wildcard || !names.is_empty() {
979        return;
980    }
981
982    if let Some((prefix, member)) = module_path.rsplit_once('.') {
983        if !prefix.is_empty() && !member.is_empty() {
984            names.push(member.to_string());
985            *module_path = prefix.to_string();
986        }
987    }
988}
989
990fn scala_request_dedup_key(req: &ImportRequest<'_>) -> ImportDedupKey {
991    let mut module_path = req.module_path.to_string();
992    let mut names: Vec<String> = req
993        .names
994        .iter()
995        .map(|name| normalize_scala_selector_for_dedup(name))
996        .collect();
997    let mut modifiers = req.modifiers.to_vec();
998    let mut import_kind = req.import_kind.map(str::to_string);
999
1000    if req.default_import == Some("given") || module_path.ends_with(".given") {
1001        import_kind.get_or_insert_with(|| "given".to_string());
1002        if let Some(stripped) = module_path.strip_suffix(".given") {
1003            module_path = stripped.to_string();
1004        }
1005    }
1006
1007    if matches!(req.default_import, Some("*") | Some("_"))
1008        || matches!(req.namespace, Some("*") | Some("_"))
1009        || module_path.ends_with(".*")
1010        || module_path.ends_with("._")
1011    {
1012        if !modifiers.iter().any(|modifier| modifier == "wildcard") {
1013            modifiers.push("wildcard".to_string());
1014        }
1015        module_path = module_path
1016            .strip_suffix(".*")
1017            .or_else(|| module_path.strip_suffix("._"))
1018            .unwrap_or(&module_path)
1019            .to_string();
1020    }
1021
1022    if names.is_empty() {
1023        if let Some(alias) = req.alias.filter(|alias| !alias.is_empty()) {
1024            if let Some((prefix, leaf)) = module_path.rsplit_once('.') {
1025                names.push(format!("{leaf} as {alias}"));
1026                module_path = prefix.to_string();
1027            }
1028        }
1029    }
1030
1031    structured_dedup_key(
1032        &module_path,
1033        ImportKind::Value,
1034        &names,
1035        None,
1036        None,
1037        &modifiers,
1038        import_kind.as_deref(),
1039    )
1040}
1041
1042fn normalize_scala_selector_for_dedup(name: &str) -> String {
1043    let trimmed = name.trim();
1044    if let Some((from, to)) = trimmed.split_once("=>") {
1045        format!("{} as {}", from.trim(), to.trim())
1046    } else {
1047        trimmed.to_string()
1048    }
1049}
1050
1051fn canonical_dedup_key(lang: LangId, mut key: ImportDedupKey) -> ImportDedupKey {
1052    match &mut key.form {
1053        ImportForm::Structured { named, .. } | ImportForm::Solidity { named, .. } => {
1054            sort_named_specifiers(named);
1055        }
1056        ImportForm::Es { named, .. } | ImportForm::Python { named, .. } => {
1057            sort_named_specifiers(named);
1058        }
1059        ImportForm::RustUse { named, .. } => {
1060            sort_named_specifiers(named);
1061        }
1062        ImportForm::Go { .. } => {}
1063    }
1064
1065    if matches!(lang, LangId::Java | LangId::Kotlin) {
1066        if let Some(stripped) = key.module_path.strip_suffix(".*") {
1067            key.module_path = stripped.to_string();
1068        }
1069        if matches!(lang, LangId::Java) {
1070            if let ImportForm::Structured {
1071                named, modifiers, ..
1072            } = &mut key.form
1073            {
1074                normalize_java_static_member_key(&mut key.module_path, modifiers, named);
1075            }
1076        }
1077    } else if matches!(lang, LangId::Scala) {
1078        key.module_path = key
1079            .module_path
1080            .strip_suffix(".given")
1081            .or_else(|| key.module_path.strip_suffix(".*"))
1082            .or_else(|| key.module_path.strip_suffix("._"))
1083            .unwrap_or(&key.module_path)
1084            .to_string();
1085    }
1086
1087    key
1088}
1089
1090fn sort_named_specifiers(names: &mut [String]) {
1091    names.sort_by(|a, b| {
1092        specifier_imported_name(a)
1093            .cmp(specifier_imported_name(b))
1094            .then_with(|| a.cmp(b))
1095    });
1096}
1097
1098/// Find the byte offset where a new import should be inserted.
1099///
1100/// Strategy:
1101/// - Find all existing imports in the same group.
1102/// - Within that group, find the alphabetical position by module path.
1103/// - Type imports sort after value imports within the same group and module-sort position.
1104/// - If no imports exist in the target group, insert after the last import of the
1105///   nearest preceding group (or before the first import of the nearest following
1106///   group, or at file start if no groups exist).
1107/// - Returns (byte_offset, needs_newline_before, needs_newline_after)
1108pub fn find_insertion_point(
1109    source: &str,
1110    block: &ImportBlock,
1111    group: ImportGroup,
1112    module_path: &str,
1113    type_only: bool,
1114) -> (usize, bool, bool) {
1115    if block.imports.is_empty() {
1116        // No imports at all — insert at start of file
1117        return (0, false, source.is_empty().then_some(false).unwrap_or(true));
1118    }
1119
1120    let target_kind = if type_only {
1121        ImportKind::Type
1122    } else {
1123        ImportKind::Value
1124    };
1125
1126    // Collect imports in the target group
1127    let group_imports: Vec<&ImportStatement> =
1128        block.imports.iter().filter(|i| i.group == group).collect();
1129
1130    if group_imports.is_empty() {
1131        // No imports in this group yet — find nearest neighbor group
1132        // Try preceding groups (lower ordinal) first
1133        let preceding_last = block.imports.iter().filter(|i| i.group < group).last();
1134
1135        if let Some(last) = preceding_last {
1136            let end = last.byte_range.end;
1137            let insert_at = skip_newline(source, end);
1138            return (insert_at, true, true);
1139        }
1140
1141        // No preceding group — try following groups (higher ordinal)
1142        let following_first = block.imports.iter().find(|i| i.group > group);
1143
1144        if let Some(first) = following_first {
1145            return (first.byte_range.start, false, true);
1146        }
1147
1148        // Shouldn't reach here if block is non-empty, but handle gracefully
1149        let first_byte = import_byte_range(&block.imports)
1150            .map(|range| range.start)
1151            .unwrap_or(0);
1152        return (first_byte, false, true);
1153    }
1154
1155    // Find position within the group (alphabetical by module path, type after value)
1156    for imp in &group_imports {
1157        let cmp = module_path.cmp(&imp.module_path);
1158        match cmp {
1159            std::cmp::Ordering::Less => {
1160                // Insert before this import
1161                return (imp.byte_range.start, false, false);
1162            }
1163            std::cmp::Ordering::Equal => {
1164                // Same module — type imports go after value imports
1165                if target_kind == ImportKind::Type && imp.kind == ImportKind::Value {
1166                    // Insert after this value import
1167                    let end = imp.byte_range.end;
1168                    let insert_at = skip_newline(source, end);
1169                    return (insert_at, false, false);
1170                }
1171                // Insert before (or it's a duplicate, caller should have checked)
1172                return (imp.byte_range.start, false, false);
1173            }
1174            std::cmp::Ordering::Greater => continue,
1175        }
1176    }
1177
1178    // Module path sorts after all existing imports in this group — insert at end
1179    let Some(last) = group_imports.last() else {
1180        return (
1181            import_byte_range(&block.imports)
1182                .map(|range| range.end)
1183                .unwrap_or(0),
1184            false,
1185            false,
1186        );
1187    };
1188    let end = last.byte_range.end;
1189    let insert_at = skip_newline(source, end);
1190    (insert_at, false, false)
1191}
1192
1193/// Generate a single import line from a structured [`ImportRequest`]. The full
1194/// entry point — engines read the fields they support; unsupported languages
1195/// yield an empty string.
1196pub fn generate_import(lang: LangId, req: &ImportRequest) -> String {
1197    match syntax_for(lang) {
1198        Some(engine) => engine.generate_line(req),
1199        None => String::new(),
1200    }
1201}
1202
1203/// Generate an import line for the given language. Back-compat wrapper over
1204/// [`generate_import`] for callers that pass only the legacy positional fields.
1205pub fn generate_import_line(
1206    lang: LangId,
1207    module_path: &str,
1208    names: &[String],
1209    default_import: Option<&str>,
1210    type_only: bool,
1211) -> String {
1212    generate_import(
1213        lang,
1214        &ImportRequest::legacy(module_path, names, default_import, None, type_only),
1215    )
1216}
1217
1218/// Generate an import line including namespace imports
1219/// (`import * as ns from 'mod'`). Back-compat wrapper over [`generate_import`].
1220pub fn generate_import_line_with_namespace(
1221    lang: LangId,
1222    module_path: &str,
1223    names: &[String],
1224    default_import: Option<&str>,
1225    namespace_import: Option<&str>,
1226    type_only: bool,
1227) -> String {
1228    generate_import(
1229        lang,
1230        &ImportRequest::legacy(
1231            module_path,
1232            names,
1233            default_import,
1234            namespace_import,
1235            type_only,
1236        ),
1237    )
1238}
1239
1240/// Check if the given language is supported by the import engine.
1241pub fn is_supported(lang: LangId) -> bool {
1242    syntax_for(lang).is_some()
1243}
1244
1245/// Classify a module path into a group for TS/JS/TSX.
1246pub fn classify_group_ts(module_path: &str) -> ImportGroup {
1247    if module_path.starts_with('.') {
1248        ImportGroup::Internal
1249    } else {
1250        ImportGroup::External
1251    }
1252}
1253
1254/// Classify a module path into a group for the given language.
1255pub fn classify_group(lang: LangId, module_path: &str) -> ImportGroup {
1256    match syntax_for(lang) {
1257        Some(engine) => engine.classify_group(module_path),
1258        // Unsupported languages have no grouping policy; External is the
1259        // historical neutral default.
1260        None => ImportGroup::External,
1261    }
1262}
1263
1264/// Parse a file from disk and return its import block.
1265/// Convenience wrapper that handles parsing.
1266pub fn parse_file_imports(
1267    path: &std::path::Path,
1268    lang: LangId,
1269) -> Result<(String, Tree, ImportBlock), crate::error::AftError> {
1270    let source =
1271        std::fs::read_to_string(path).map_err(|e| crate::error::AftError::FileNotFound {
1272            path: format!("{}: {}", path.display(), e),
1273        })?;
1274
1275    let grammar = grammar_for(lang);
1276    let mut parser = Parser::new();
1277    parser
1278        .set_language(&grammar)
1279        .map_err(|e| crate::error::AftError::ParseError {
1280            message: format!("grammar init failed for {:?}: {}", lang, e),
1281        })?;
1282
1283    let tree = parser
1284        .parse(&source, None)
1285        .ok_or_else(|| crate::error::AftError::ParseError {
1286            message: format!("tree-sitter parse returned None for {}", path.display()),
1287        })?;
1288
1289    let block = parse_imports(&source, &tree, lang);
1290    Ok((source, tree, block))
1291}
1292
1293// ---------------------------------------------------------------------------
1294// TS/JS/TSX implementation
1295// ---------------------------------------------------------------------------
1296
1297/// Parse imports from a TS/JS/TSX file.
1298///
1299/// Walks the AST root's direct children looking for `import_statement` nodes (D041).
1300fn parse_ts_imports(source: &str, tree: &Tree) -> ImportBlock {
1301    let root = tree.root_node();
1302    let mut imports = Vec::new();
1303
1304    let mut cursor = root.walk();
1305    if !cursor.goto_first_child() {
1306        return ImportBlock::empty();
1307    }
1308
1309    loop {
1310        let node = cursor.node();
1311        if node.kind() == "import_statement" {
1312            if let Some(imp) = parse_single_ts_import(source, &node) {
1313                imports.push(imp);
1314            }
1315        }
1316        if !cursor.goto_next_sibling() {
1317            break;
1318        }
1319    }
1320
1321    let byte_range = import_byte_range(&imports);
1322
1323    ImportBlock {
1324        imports,
1325        byte_range,
1326    }
1327}
1328
1329/// Parse a single `import_statement` node into an `ImportStatement`.
1330fn parse_single_ts_import(source: &str, node: &Node) -> Option<ImportStatement> {
1331    let raw_text = source[node.byte_range()].to_string();
1332    let byte_range = node.byte_range();
1333
1334    // Find the source module (string/string_fragment child of the import)
1335    let module_path = extract_module_path(source, node)?;
1336
1337    // Determine if this is a type-only import: `import type ...`
1338    let is_type_only = has_type_keyword(node);
1339
1340    // Extract import clause details
1341    let mut names = Vec::new();
1342    let mut default_import = None;
1343    let mut namespace_import = None;
1344
1345    let mut child_cursor = node.walk();
1346    if child_cursor.goto_first_child() {
1347        loop {
1348            let child = child_cursor.node();
1349            match child.kind() {
1350                "import_clause" => {
1351                    extract_import_clause(
1352                        source,
1353                        &child,
1354                        &mut names,
1355                        &mut default_import,
1356                        &mut namespace_import,
1357                    );
1358                }
1359                // In some grammars, the default import is a direct identifier child
1360                "identifier" => {
1361                    let text = &source[child.byte_range()];
1362                    if text != "import" && text != "from" && text != "type" {
1363                        default_import = Some(text.to_string());
1364                    }
1365                }
1366                _ => {}
1367            }
1368            if !child_cursor.goto_next_sibling() {
1369                break;
1370            }
1371        }
1372    }
1373
1374    // Classify kind
1375    let kind = if names.is_empty() && default_import.is_none() && namespace_import.is_none() {
1376        ImportKind::SideEffect
1377    } else if is_type_only {
1378        ImportKind::Type
1379    } else {
1380        ImportKind::Value
1381    };
1382
1383    let group = classify_group_ts(&module_path);
1384
1385    let form = ImportForm::Es {
1386        default_import: default_import.clone(),
1387        namespace_import: namespace_import.clone(),
1388        named: names.clone(),
1389        type_only: is_type_only,
1390        side_effect: matches!(kind, ImportKind::SideEffect),
1391    };
1392
1393    Some(ImportStatement {
1394        module_path,
1395        names,
1396        default_import,
1397        namespace_import,
1398        kind,
1399        group,
1400        byte_range,
1401        raw_text,
1402        form,
1403    })
1404}
1405
1406/// Extract the module path string from an import_statement node.
1407///
1408/// Looks for a `string` child node and extracts the content without quotes.
1409fn extract_module_path(source: &str, node: &Node) -> Option<String> {
1410    let mut cursor = node.walk();
1411    if !cursor.goto_first_child() {
1412        return None;
1413    }
1414
1415    loop {
1416        let child = cursor.node();
1417        if child.kind() == "string" {
1418            // Get the text and strip quotes
1419            let text = &source[child.byte_range()];
1420            let stripped = text
1421                .trim_start_matches(|c| c == '\'' || c == '"')
1422                .trim_end_matches(|c| c == '\'' || c == '"');
1423            return Some(stripped.to_string());
1424        }
1425        if !cursor.goto_next_sibling() {
1426            break;
1427        }
1428    }
1429    None
1430}
1431
1432/// Check if the import_statement has a `type` keyword (import type ...).
1433///
1434/// In tree-sitter-typescript, `import type { X } from 'y'` produces a `type`
1435/// node as a direct child of `import_statement`, between `import` and `import_clause`.
1436fn has_type_keyword(node: &Node) -> bool {
1437    let mut cursor = node.walk();
1438    if !cursor.goto_first_child() {
1439        return false;
1440    }
1441
1442    loop {
1443        let child = cursor.node();
1444        if child.kind() == "type" {
1445            return true;
1446        }
1447        if !cursor.goto_next_sibling() {
1448            break;
1449        }
1450    }
1451
1452    false
1453}
1454
1455/// Extract named imports, default import, and namespace import from an import_clause.
1456fn extract_import_clause(
1457    source: &str,
1458    node: &Node,
1459    names: &mut Vec<String>,
1460    default_import: &mut Option<String>,
1461    namespace_import: &mut Option<String>,
1462) {
1463    let mut cursor = node.walk();
1464    if !cursor.goto_first_child() {
1465        return;
1466    }
1467
1468    loop {
1469        let child = cursor.node();
1470        match child.kind() {
1471            "identifier" => {
1472                // This is a default import: `import Foo from 'bar'`
1473                let text = &source[child.byte_range()];
1474                if text != "type" {
1475                    *default_import = Some(text.to_string());
1476                }
1477            }
1478            "named_imports" => {
1479                // `{ name1, name2 }`
1480                extract_named_imports(source, &child, names);
1481            }
1482            "namespace_import" => {
1483                // `* as name`
1484                extract_namespace_import(source, &child, namespace_import);
1485            }
1486            _ => {}
1487        }
1488        if !cursor.goto_next_sibling() {
1489            break;
1490        }
1491    }
1492}
1493
1494/// Extract individual names from a named_imports node (`{ a, b, c }`).
1495///
1496/// Each name is stored verbatim including any alias and per-name `type`
1497/// modifier so the regenerator can round-trip them losslessly. Examples of
1498/// captured forms:
1499///
1500/// - `useState`               (plain)
1501/// - `stdin as input`         (renamed)
1502/// - `type Foo`               (per-specifier type-only)
1503/// - `type Foo as Bar`        (per-specifier type-only with rename)
1504///
1505/// **Why verbatim strings instead of a struct field per attribute:** dedup,
1506/// sort, dropping a single import, and the regenerator are all driven by
1507/// `Vec<String>` today. Encoding the alias inside the string preserves the
1508/// shape so the rest of the pipeline (organize, remove_import, move_symbol)
1509/// keeps working without a workspace-wide refactor. The cost is that callers
1510/// who want the canonical name (e.g. dedup) must compare on the leading
1511/// identifier only — see `extract_canonical_name` if you need that.
1512fn extract_named_imports(source: &str, node: &Node, names: &mut Vec<String>) {
1513    let mut cursor = node.walk();
1514    if !cursor.goto_first_child() {
1515        return;
1516    }
1517
1518    loop {
1519        let child = cursor.node();
1520        if child.kind() == "import_specifier" {
1521            // Capture the full text of the specifier so per-name `type` markers
1522            // and `as alias` clauses are preserved across organize/regenerate
1523            // round-trips. Falls back to the imported name if the specifier
1524            // text is empty for any reason.
1525            let raw = source[child.byte_range()].trim().to_string();
1526            if !raw.is_empty() {
1527                names.push(raw);
1528            } else if let Some(name_node) = child.child_by_field_name("name") {
1529                names.push(source[name_node.byte_range()].to_string());
1530            }
1531        }
1532        if !cursor.goto_next_sibling() {
1533            break;
1534        }
1535    }
1536}
1537
1538/// Extract the alias name from a namespace_import node (`* as name`).
1539fn extract_namespace_import(source: &str, node: &Node, namespace_import: &mut Option<String>) {
1540    let mut cursor = node.walk();
1541    if !cursor.goto_first_child() {
1542        return;
1543    }
1544
1545    loop {
1546        let child = cursor.node();
1547        if child.kind() == "identifier" {
1548            *namespace_import = Some(source[child.byte_range()].to_string());
1549            return;
1550        }
1551        if !cursor.goto_next_sibling() {
1552            break;
1553        }
1554    }
1555}
1556
1557/// Generate an import line for TS/JS/TSX.
1558fn generate_ts_import_line(
1559    module_path: &str,
1560    names: &[String],
1561    default_import: Option<&str>,
1562    namespace_import: Option<&str>,
1563    type_only: bool,
1564) -> String {
1565    let type_prefix = if type_only { "type " } else { "" };
1566
1567    // Side-effect import
1568    if names.is_empty() && default_import.is_none() && namespace_import.is_none() {
1569        return format!("import '{module_path}';");
1570    }
1571
1572    // Namespace import only
1573    if names.is_empty() && default_import.is_none() {
1574        if let Some(namespace) = namespace_import {
1575            return format!("import {type_prefix}* as {namespace} from '{module_path}';");
1576        }
1577    }
1578
1579    // Default + namespace import
1580    if names.is_empty() {
1581        if let (Some(def), Some(namespace)) = (default_import, namespace_import) {
1582            return format!("import {type_prefix}{def}, * as {namespace} from '{module_path}';");
1583        }
1584    }
1585
1586    // Default import only
1587    if names.is_empty() && namespace_import.is_none() {
1588        if let Some(def) = default_import {
1589            return format!("import {type_prefix}{def} from '{module_path}';");
1590        }
1591    }
1592
1593    // Named imports only
1594    if default_import.is_none() && namespace_import.is_none() {
1595        let mut sorted_names = names.to_vec();
1596        sort_named_specifiers(&mut sorted_names);
1597        let names_str = sorted_names.join(", ");
1598        return format!("import {type_prefix}{{ {names_str} }} from '{module_path}';");
1599    }
1600
1601    // Namespace + named imports
1602    if default_import.is_none() {
1603        if let Some(namespace) = namespace_import {
1604            let mut sorted_names = names.to_vec();
1605            sort_named_specifiers(&mut sorted_names);
1606            let names_str = sorted_names.join(", ");
1607            return format!(
1608                "import {type_prefix}{{ {names_str} }}, * as {namespace} from '{module_path}';"
1609            );
1610        }
1611    }
1612
1613    // Default + named + namespace imports
1614    if let (Some(def), Some(namespace)) = (default_import, namespace_import) {
1615        let mut sorted_names = names.to_vec();
1616        sort_named_specifiers(&mut sorted_names);
1617        let names_str = sorted_names.join(", ");
1618        return format!(
1619            "import {type_prefix}{def}, {{ {names_str} }}, * as {namespace} from '{module_path}';"
1620        );
1621    }
1622
1623    // Both default and named imports
1624    if let Some(def) = default_import {
1625        let mut sorted_names = names.to_vec();
1626        sort_named_specifiers(&mut sorted_names);
1627        let names_str = sorted_names.join(", ");
1628        return format!("import {type_prefix}{def}, {{ {names_str} }} from '{module_path}';");
1629    }
1630
1631    // Shouldn't reach here, but handle gracefully
1632    format!("import '{module_path}';")
1633}
1634
1635// ---------------------------------------------------------------------------
1636// Python implementation
1637// ---------------------------------------------------------------------------
1638
1639/// Python 3.x standard library module names (top-level modules).
1640/// Used for import group classification. Covers the commonly-used modules;
1641/// unknown modules are assumed third-party.
1642const PYTHON_STDLIB: &[&str] = &[
1643    "__future__",
1644    "_thread",
1645    "abc",
1646    "aifc",
1647    "argparse",
1648    "array",
1649    "ast",
1650    "asynchat",
1651    "asyncio",
1652    "asyncore",
1653    "atexit",
1654    "audioop",
1655    "base64",
1656    "bdb",
1657    "binascii",
1658    "bisect",
1659    "builtins",
1660    "bz2",
1661    "calendar",
1662    "cgi",
1663    "cgitb",
1664    "chunk",
1665    "cmath",
1666    "cmd",
1667    "code",
1668    "codecs",
1669    "codeop",
1670    "collections",
1671    "colorsys",
1672    "compileall",
1673    "concurrent",
1674    "configparser",
1675    "contextlib",
1676    "contextvars",
1677    "copy",
1678    "copyreg",
1679    "cProfile",
1680    "crypt",
1681    "csv",
1682    "ctypes",
1683    "curses",
1684    "dataclasses",
1685    "datetime",
1686    "dbm",
1687    "decimal",
1688    "difflib",
1689    "dis",
1690    "distutils",
1691    "doctest",
1692    "email",
1693    "encodings",
1694    "enum",
1695    "errno",
1696    "faulthandler",
1697    "fcntl",
1698    "filecmp",
1699    "fileinput",
1700    "fnmatch",
1701    "fractions",
1702    "ftplib",
1703    "functools",
1704    "gc",
1705    "getopt",
1706    "getpass",
1707    "gettext",
1708    "glob",
1709    "grp",
1710    "gzip",
1711    "hashlib",
1712    "heapq",
1713    "hmac",
1714    "html",
1715    "http",
1716    "idlelib",
1717    "imaplib",
1718    "imghdr",
1719    "importlib",
1720    "inspect",
1721    "io",
1722    "ipaddress",
1723    "itertools",
1724    "json",
1725    "keyword",
1726    "lib2to3",
1727    "linecache",
1728    "locale",
1729    "logging",
1730    "lzma",
1731    "mailbox",
1732    "mailcap",
1733    "marshal",
1734    "math",
1735    "mimetypes",
1736    "mmap",
1737    "modulefinder",
1738    "multiprocessing",
1739    "netrc",
1740    "numbers",
1741    "operator",
1742    "optparse",
1743    "os",
1744    "pathlib",
1745    "pdb",
1746    "pickle",
1747    "pickletools",
1748    "pipes",
1749    "pkgutil",
1750    "platform",
1751    "plistlib",
1752    "poplib",
1753    "posixpath",
1754    "pprint",
1755    "profile",
1756    "pstats",
1757    "pty",
1758    "pwd",
1759    "py_compile",
1760    "pyclbr",
1761    "pydoc",
1762    "queue",
1763    "quopri",
1764    "random",
1765    "re",
1766    "readline",
1767    "reprlib",
1768    "resource",
1769    "rlcompleter",
1770    "runpy",
1771    "sched",
1772    "secrets",
1773    "select",
1774    "selectors",
1775    "shelve",
1776    "shlex",
1777    "shutil",
1778    "signal",
1779    "site",
1780    "smtplib",
1781    "sndhdr",
1782    "socket",
1783    "socketserver",
1784    "sqlite3",
1785    "ssl",
1786    "stat",
1787    "statistics",
1788    "string",
1789    "stringprep",
1790    "struct",
1791    "subprocess",
1792    "symtable",
1793    "sys",
1794    "sysconfig",
1795    "syslog",
1796    "tabnanny",
1797    "tarfile",
1798    "tempfile",
1799    "termios",
1800    "textwrap",
1801    "threading",
1802    "time",
1803    "timeit",
1804    "tkinter",
1805    "token",
1806    "tokenize",
1807    "tomllib",
1808    "trace",
1809    "traceback",
1810    "tracemalloc",
1811    "tty",
1812    "turtle",
1813    "types",
1814    "typing",
1815    "unicodedata",
1816    "unittest",
1817    "urllib",
1818    "uuid",
1819    "venv",
1820    "warnings",
1821    "wave",
1822    "weakref",
1823    "webbrowser",
1824    "wsgiref",
1825    "xml",
1826    "xmlrpc",
1827    "zipapp",
1828    "zipfile",
1829    "zipimport",
1830    "zlib",
1831];
1832
1833/// Classify a Python import into a group.
1834pub fn classify_group_py(module_path: &str) -> ImportGroup {
1835    // Relative imports start with '.'
1836    if module_path.starts_with('.') {
1837        return ImportGroup::Internal;
1838    }
1839    // Check stdlib: use the top-level module name (before first '.')
1840    let top_module = module_path.split('.').next().unwrap_or(module_path);
1841    if PYTHON_STDLIB.contains(&top_module) {
1842        ImportGroup::Stdlib
1843    } else {
1844        ImportGroup::External
1845    }
1846}
1847
1848/// Parse imports from a Python file.
1849fn parse_py_imports(source: &str, tree: &Tree) -> ImportBlock {
1850    let root = tree.root_node();
1851    let mut imports = Vec::new();
1852
1853    let mut cursor = root.walk();
1854    if !cursor.goto_first_child() {
1855        return ImportBlock::empty();
1856    }
1857
1858    loop {
1859        let node = cursor.node();
1860        match node.kind() {
1861            "import_statement" => {
1862                if let Some(imp) = parse_py_import_statement(source, &node) {
1863                    imports.push(imp);
1864                }
1865            }
1866            "import_from_statement" => {
1867                if let Some(imp) = parse_py_import_from_statement(source, &node) {
1868                    imports.push(imp);
1869                }
1870            }
1871            _ => {}
1872        }
1873        if !cursor.goto_next_sibling() {
1874            break;
1875        }
1876    }
1877
1878    let byte_range = import_byte_range(&imports);
1879
1880    ImportBlock {
1881        imports,
1882        byte_range,
1883    }
1884}
1885
1886/// Parse `import X` or `import X.Y` Python statements.
1887fn parse_py_import_statement(source: &str, node: &Node) -> Option<ImportStatement> {
1888    let raw_text = source[node.byte_range()].to_string();
1889    let byte_range = node.byte_range();
1890
1891    // Keep every module specifier verbatim. A single Python import statement can
1892    // bind several modules, and aliases must remain in the model so removing or
1893    // merging one binding cannot silently delete its siblings.
1894    let mut specifiers = Vec::new();
1895    let mut c = node.walk();
1896    if c.goto_first_child() {
1897        loop {
1898            let child = c.node();
1899            if matches!(child.kind(), "dotted_name" | "aliased_import") {
1900                specifiers.push(source[child.byte_range()].trim().to_string());
1901            }
1902            if !c.goto_next_sibling() {
1903                break;
1904            }
1905        }
1906    }
1907    let module_path = specifiers
1908        .first()
1909        .map(|specifier| specifier_imported_name(specifier).to_string())?;
1910
1911    let group = classify_group_py(&module_path);
1912
1913    Some(ImportStatement {
1914        module_path,
1915        names: Vec::new(),
1916        default_import: None,
1917        namespace_import: None,
1918        kind: ImportKind::Value,
1919        group,
1920        byte_range,
1921        raw_text,
1922        form: ImportForm::Python {
1923            from_import: false,
1924            named: specifiers,
1925        },
1926    })
1927}
1928
1929/// Parse `from X import Y, Z` or `from . import Y` Python statements.
1930fn parse_py_import_from_statement(source: &str, node: &Node) -> Option<ImportStatement> {
1931    let raw_text = source[node.byte_range()].to_string();
1932    let byte_range = node.byte_range();
1933
1934    let mut module_path = String::new();
1935    let mut names = Vec::new();
1936
1937    let mut c = node.walk();
1938    if c.goto_first_child() {
1939        loop {
1940            let child = c.node();
1941            match child.kind() {
1942                "dotted_name" => {
1943                    // Could be the module name or an imported name
1944                    // The module name comes right after `from`, imported names come after `import`
1945                    // Use position: if we haven't set module_path yet and this comes
1946                    // before the `import` keyword, it's the module.
1947                    if module_path.is_empty()
1948                        && !has_seen_import_keyword(source, node, child.start_byte())
1949                    {
1950                        module_path = source[child.byte_range()].to_string();
1951                    } else {
1952                        // It's an imported name
1953                        names.push(source[child.byte_range()].to_string());
1954                    }
1955                }
1956                "relative_import" => {
1957                    // from . import X or from ..module import X
1958                    module_path = source[child.byte_range()].to_string();
1959                }
1960                "aliased_import" => {
1961                    // Store the full `source as local` spelling so later edits
1962                    // can match either name and reproduce the alias unchanged.
1963                    names.push(source[child.byte_range()].trim().to_string());
1964                }
1965                _ => {}
1966            }
1967            if !c.goto_next_sibling() {
1968                break;
1969            }
1970        }
1971    }
1972
1973    // module_path must be non-empty for a valid import
1974    if module_path.is_empty() {
1975        return None;
1976    }
1977
1978    let group = classify_group_py(&module_path);
1979
1980    Some(ImportStatement {
1981        module_path,
1982        names: names.clone(),
1983        default_import: None,
1984        namespace_import: None,
1985        kind: ImportKind::Value,
1986        group,
1987        byte_range,
1988        raw_text,
1989        form: ImportForm::Python {
1990            from_import: true,
1991            named: names,
1992        },
1993    })
1994}
1995
1996/// Check if the `import` keyword appears before the given byte position in a from...import node.
1997fn has_seen_import_keyword(_source: &str, parent: &Node, before_byte: usize) -> bool {
1998    let mut c = parent.walk();
1999    if c.goto_first_child() {
2000        loop {
2001            let child = c.node();
2002            if child.kind() == "import" && child.start_byte() < before_byte {
2003                return true;
2004            }
2005            if child.start_byte() >= before_byte {
2006                return false;
2007            }
2008            if !c.goto_next_sibling() {
2009                break;
2010            }
2011        }
2012    }
2013    false
2014}
2015
2016/// Generate a Python import line.
2017fn generate_py_import_line(
2018    module_path: &str,
2019    names: &[String],
2020    _default_import: Option<&str>,
2021) -> String {
2022    if names.is_empty() {
2023        // `import module`
2024        format!("import {module_path}")
2025    } else {
2026        // `from module import name1, name2`
2027        let mut sorted = names.to_vec();
2028        sorted.sort();
2029        let names_str = sorted.join(", ");
2030        format!("from {module_path} import {names_str}")
2031    }
2032}
2033
2034// ---------------------------------------------------------------------------
2035// Rust implementation
2036// ---------------------------------------------------------------------------
2037
2038/// Classify a Rust use path into a group.
2039pub fn classify_group_rs(module_path: &str) -> ImportGroup {
2040    // Extract the first path segment (before ::)
2041    let first_seg = module_path.split("::").next().unwrap_or(module_path);
2042    match first_seg {
2043        "std" | "core" | "alloc" => ImportGroup::Stdlib,
2044        "crate" | "self" | "super" => ImportGroup::Internal,
2045        _ => ImportGroup::External,
2046    }
2047}
2048
2049/// Parse imports from a Rust file.
2050fn parse_rs_imports(source: &str, tree: &Tree) -> ImportBlock {
2051    let root = tree.root_node();
2052    let mut imports = Vec::new();
2053
2054    let mut cursor = root.walk();
2055    if !cursor.goto_first_child() {
2056        return ImportBlock::empty();
2057    }
2058
2059    loop {
2060        let node = cursor.node();
2061        if node.kind() == "use_declaration" {
2062            if let Some(imp) = parse_rs_use_declaration(source, &node) {
2063                imports.push(imp);
2064            }
2065        }
2066        if !cursor.goto_next_sibling() {
2067            break;
2068        }
2069    }
2070
2071    let byte_range = import_byte_range(&imports);
2072
2073    ImportBlock {
2074        imports,
2075        byte_range,
2076    }
2077}
2078
2079/// Parse a single `use` declaration from Rust.
2080fn parse_rs_use_declaration(source: &str, node: &Node) -> Option<ImportStatement> {
2081    let raw_text = source[node.byte_range()].to_string();
2082    let byte_range = node.byte_range();
2083
2084    // Capture the EXACT visibility modifier text (`pub`, `pub(crate)`,
2085    // `pub(super)`, `pub(in path)`) so organize re-emits it faithfully instead
2086    // of widening every restricted visibility to a bare `pub`.
2087    let mut visibility: Option<String> = None;
2088    let mut use_path = String::new();
2089    let mut names = Vec::new();
2090
2091    let mut c = node.walk();
2092    if c.goto_first_child() {
2093        loop {
2094            let child = c.node();
2095            match child.kind() {
2096                "visibility_modifier" => {
2097                    visibility = Some(source[child.byte_range()].to_string());
2098                }
2099                "scoped_identifier" | "identifier" | "use_as_clause" => {
2100                    // Full path like `std::collections::HashMap` or just `serde`
2101                    use_path = source[child.byte_range()].to_string();
2102                }
2103                "scoped_use_list" => {
2104                    // e.g. `serde::{Deserialize, Serialize}`
2105                    use_path = source[child.byte_range()].to_string();
2106                    // Also extract the individual names from the use_list
2107                    extract_rs_use_list_names(source, &child, &mut names);
2108                }
2109                _ => {}
2110            }
2111            if !c.goto_next_sibling() {
2112                break;
2113            }
2114        }
2115    }
2116
2117    if use_path.is_empty() {
2118        return None;
2119    }
2120
2121    let group = classify_group_rs(&use_path);
2122
2123    Some(ImportStatement {
2124        module_path: use_path,
2125        names: names.clone(),
2126        // `default_import` carries the visibility text for the Rust engine
2127        // (e.g. "pub", "pub(crate)"). organize re-emits it verbatim.
2128        default_import: visibility.clone(),
2129        namespace_import: None,
2130        kind: ImportKind::Value,
2131        group,
2132        byte_range,
2133        raw_text,
2134        form: ImportForm::RustUse {
2135            visibility,
2136            named: names,
2137        },
2138    })
2139}
2140
2141/// Extract individual names from a Rust `scoped_use_list` node.
2142fn extract_rs_use_list_names(source: &str, node: &Node, names: &mut Vec<String>) {
2143    let mut c = node.walk();
2144    if c.goto_first_child() {
2145        loop {
2146            let child = c.node();
2147            if child.kind() == "use_list" {
2148                // Walk into the use_list to find identifiers
2149                let mut lc = child.walk();
2150                if lc.goto_first_child() {
2151                    loop {
2152                        let lchild = lc.node();
2153                        if lchild.kind() == "identifier" || lchild.kind() == "scoped_identifier" {
2154                            names.push(source[lchild.byte_range()].to_string());
2155                        }
2156                        if !lc.goto_next_sibling() {
2157                            break;
2158                        }
2159                    }
2160                }
2161            }
2162            if !c.goto_next_sibling() {
2163                break;
2164            }
2165        }
2166    }
2167}
2168
2169/// Generate a Rust import line.
2170fn generate_rs_import_line(module_path: &str, names: &[String], _type_only: bool) -> String {
2171    if names.is_empty() {
2172        format!("use {module_path};")
2173    } else {
2174        let mut sorted_names = names.to_vec();
2175        sort_named_specifiers(&mut sorted_names);
2176        format!("use {module_path}::{{{}}};", sorted_names.join(", "))
2177    }
2178}
2179
2180// ---------------------------------------------------------------------------
2181// Go implementation
2182// ---------------------------------------------------------------------------
2183
2184/// Classify a Go import path into a group.
2185pub fn classify_group_go(module_path: &str) -> ImportGroup {
2186    // stdlib paths don't contain dots (e.g., "fmt", "os", "net/http")
2187    // external paths contain dots (e.g., "github.com/pkg/errors")
2188    if module_path.contains('.') {
2189        ImportGroup::External
2190    } else {
2191        ImportGroup::Stdlib
2192    }
2193}
2194
2195/// Parse imports from a Go file.
2196fn parse_go_imports(source: &str, tree: &Tree) -> ImportBlock {
2197    let root = tree.root_node();
2198    let mut imports = Vec::new();
2199
2200    let mut cursor = root.walk();
2201    if !cursor.goto_first_child() {
2202        return ImportBlock::empty();
2203    }
2204
2205    loop {
2206        let node = cursor.node();
2207        if node.kind() == "import_declaration" {
2208            parse_go_import_declaration(source, &node, &mut imports);
2209        }
2210        if !cursor.goto_next_sibling() {
2211            break;
2212        }
2213    }
2214
2215    let byte_range = import_byte_range(&imports);
2216
2217    ImportBlock {
2218        imports,
2219        byte_range,
2220    }
2221}
2222
2223/// Parse a single Go import_declaration (may contain one or multiple specs).
2224fn parse_go_import_declaration(source: &str, node: &Node, imports: &mut Vec<ImportStatement>) {
2225    let mut c = node.walk();
2226    if c.goto_first_child() {
2227        loop {
2228            let child = c.node();
2229            match child.kind() {
2230                "import_spec" => {
2231                    if let Some(imp) = parse_go_import_spec(source, &child) {
2232                        imports.push(imp);
2233                    }
2234                }
2235                "import_spec_list" => {
2236                    // Grouped imports: walk into the list
2237                    let mut lc = child.walk();
2238                    if lc.goto_first_child() {
2239                        loop {
2240                            if lc.node().kind() == "import_spec" {
2241                                if let Some(imp) = parse_go_import_spec(source, &lc.node()) {
2242                                    imports.push(imp);
2243                                }
2244                            }
2245                            if !lc.goto_next_sibling() {
2246                                break;
2247                            }
2248                        }
2249                    }
2250                }
2251                _ => {}
2252            }
2253            if !c.goto_next_sibling() {
2254                break;
2255            }
2256        }
2257    }
2258}
2259
2260/// Parse a single Go import_spec node.
2261fn parse_go_import_spec(source: &str, node: &Node) -> Option<ImportStatement> {
2262    let raw_text = source[node.byte_range()].to_string();
2263    let byte_range = node.byte_range();
2264
2265    let mut import_path = String::new();
2266    let mut alias = None;
2267
2268    let mut c = node.walk();
2269    if c.goto_first_child() {
2270        loop {
2271            let child = c.node();
2272            match child.kind() {
2273                "interpreted_string_literal" => {
2274                    // Extract the path without quotes
2275                    let text = source[child.byte_range()].to_string();
2276                    import_path = text.trim_matches('"').to_string();
2277                }
2278                "identifier" | "blank_identifier" | "dot" => {
2279                    // This is an alias (e.g., `alias "path"` or `. "path"` or `_ "path"`)
2280                    alias = Some(source[child.byte_range()].to_string());
2281                }
2282                _ => {}
2283            }
2284            if !c.goto_next_sibling() {
2285                break;
2286            }
2287        }
2288    }
2289
2290    if import_path.is_empty() {
2291        return None;
2292    }
2293
2294    let group = classify_group_go(&import_path);
2295
2296    Some(ImportStatement {
2297        module_path: import_path,
2298        names: Vec::new(),
2299        default_import: alias.clone(),
2300        namespace_import: None,
2301        kind: ImportKind::Value,
2302        group,
2303        byte_range,
2304        raw_text,
2305        form: ImportForm::Go { alias },
2306    })
2307}
2308
2309/// Public API for Go import line generation (used by add_import handler).
2310pub fn generate_go_import_line_pub(
2311    module_path: &str,
2312    alias: Option<&str>,
2313    in_group: bool,
2314) -> String {
2315    generate_go_import_line(module_path, alias, in_group)
2316}
2317
2318/// Generate a Go import line (public API for command handler).
2319///
2320/// `in_group` controls whether to generate a spec for insertion into an
2321/// existing grouped import (`\t"path"`) or a standalone import (`import "path"`).
2322fn generate_go_import_line(module_path: &str, alias: Option<&str>, in_group: bool) -> String {
2323    if in_group {
2324        // Spec for grouped import block
2325        match alias {
2326            Some(a) => format!("\t{a} \"{module_path}\""),
2327            None => format!("\t\"{module_path}\""),
2328        }
2329    } else {
2330        // Standalone import
2331        match alias {
2332            Some(a) => format!("import {a} \"{module_path}\""),
2333            None => format!("import \"{module_path}\""),
2334        }
2335    }
2336}
2337
2338/// Check if a Go import block has a grouped import declaration.
2339/// Returns the byte range of the full import_declaration if found.
2340pub fn go_has_grouped_import(_source: &str, tree: &Tree) -> Option<Range<usize>> {
2341    let root = tree.root_node();
2342    let mut cursor = root.walk();
2343    if !cursor.goto_first_child() {
2344        return None;
2345    }
2346
2347    loop {
2348        let node = cursor.node();
2349        if node.kind() == "import_declaration" && go_import_declaration_is_grouped(&node) {
2350            return Some(node.byte_range());
2351        }
2352        if !cursor.goto_next_sibling() {
2353            break;
2354        }
2355    }
2356    None
2357}
2358
2359pub fn go_import_declarations_range(_source: &str, tree: &Tree) -> Option<Range<usize>> {
2360    let root = tree.root_node();
2361    let mut cursor = root.walk();
2362    let mut range: Option<Range<usize>> = None;
2363    if !cursor.goto_first_child() {
2364        return None;
2365    }
2366
2367    loop {
2368        let node = cursor.node();
2369        if node.kind() == "import_declaration" {
2370            let node_range = node.byte_range();
2371            range = Some(match range {
2372                Some(existing) => {
2373                    existing.start.min(node_range.start)..existing.end.max(node_range.end)
2374                }
2375                None => node_range,
2376            });
2377        }
2378        if !cursor.goto_next_sibling() {
2379            break;
2380        }
2381    }
2382
2383    range
2384}
2385
2386pub fn go_offset_is_in_grouped_import(_source: &str, tree: &Tree, offset: usize) -> bool {
2387    let root = tree.root_node();
2388    let mut cursor = root.walk();
2389    if !cursor.goto_first_child() {
2390        return false;
2391    }
2392
2393    loop {
2394        let node = cursor.node();
2395        if node.kind() == "import_declaration"
2396            && node.start_byte() < offset
2397            && offset < node.end_byte()
2398            && go_import_declaration_is_grouped(&node)
2399        {
2400            return true;
2401        }
2402        if !cursor.goto_next_sibling() {
2403            break;
2404        }
2405    }
2406
2407    false
2408}
2409
2410fn go_import_declaration_is_grouped(node: &Node) -> bool {
2411    let mut c = node.walk();
2412    if c.goto_first_child() {
2413        loop {
2414            if c.node().kind() == "import_spec_list" {
2415                return true;
2416            }
2417            if !c.goto_next_sibling() {
2418                break;
2419            }
2420        }
2421    }
2422    false
2423}
2424
2425// ---------------------------------------------------------------------------
2426// Solidity implementation
2427// ---------------------------------------------------------------------------
2428
2429/// Classify a Solidity import path: relative (`./`, `../`) is internal,
2430/// everything else (remappings, `@scope/...`, bare) is external. No stdlib.
2431pub fn classify_group_solidity(module_path: &str) -> ImportGroup {
2432    if module_path.starts_with('.') {
2433        ImportGroup::Internal
2434    } else {
2435        ImportGroup::External
2436    }
2437}
2438
2439fn parse_solidity_imports(source: &str, tree: &Tree) -> ImportBlock {
2440    let root = tree.root_node();
2441    let mut imports = Vec::new();
2442    let mut cursor = root.walk();
2443    if cursor.goto_first_child() {
2444        loop {
2445            let node = cursor.node();
2446            if node.kind() == "import_directive" {
2447                if let Some(imp) = parse_solidity_import_directive(source, &node) {
2448                    imports.push(imp);
2449                }
2450            }
2451            if !cursor.goto_next_sibling() {
2452                break;
2453            }
2454        }
2455    }
2456    let byte_range = import_byte_range(&imports);
2457    ImportBlock {
2458        imports,
2459        byte_range,
2460    }
2461}
2462
2463/// Parse one `import_directive`. The Solidity grammar emits a flat token
2464/// sequence (verified by grammar fixture test), so the four forms are
2465/// distinguished by the presence of `{` (named), `*` (namespace), a trailing
2466/// `as` (whole-file alias), or none (side-effect).
2467fn parse_solidity_import_directive(source: &str, node: &Node) -> Option<ImportStatement> {
2468    let raw_text = source[node.byte_range()].to_string();
2469    let byte_range = node.byte_range();
2470
2471    let mut children: Vec<(String, String)> = Vec::new();
2472    let mut c = node.walk();
2473    if c.goto_first_child() {
2474        loop {
2475            let ch = c.node();
2476            children.push((ch.kind().to_string(), source[ch.byte_range()].to_string()));
2477            if !c.goto_next_sibling() {
2478                break;
2479            }
2480        }
2481    }
2482
2483    // Every form carries exactly one string literal: the imported file path.
2484    let module_path = children
2485        .iter()
2486        .find(|(k, _)| k == "string")
2487        .map(|(_, t)| t.trim_matches('"').to_string())?;
2488    if module_path.is_empty() {
2489        return None;
2490    }
2491
2492    let has_brace = children.iter().any(|(k, _)| k == "{");
2493    let has_star = children.iter().any(|(k, _)| k == "*");
2494
2495    let mut named: Vec<String> = Vec::new();
2496    let mut namespace: Option<String> = None;
2497    let mut alias: Option<String> = None;
2498
2499    if has_brace {
2500        named = parse_solidity_named_specifiers(&children);
2501    } else if has_star {
2502        namespace = solidity_identifier_after_as(&children);
2503    } else {
2504        // No `{`, no `*`: a trailing `as IDENT` is a whole-file alias;
2505        // otherwise a bare side-effect import.
2506        alias = solidity_identifier_after_as(&children);
2507    }
2508
2509    let kind = if named.is_empty() && namespace.is_none() && alias.is_none() {
2510        ImportKind::SideEffect
2511    } else {
2512        ImportKind::Value
2513    };
2514    let group = classify_group_solidity(&module_path);
2515
2516    Some(ImportStatement {
2517        module_path,
2518        names: named.clone(),
2519        default_import: None,
2520        // Namespace maps to the flat slot so existing readers (dedup) see it;
2521        // the whole-file alias has no flat slot and lives only in `form`.
2522        namespace_import: namespace.clone(),
2523        kind,
2524        group,
2525        byte_range,
2526        raw_text,
2527        form: ImportForm::Solidity {
2528            named,
2529            namespace,
2530            alias,
2531        },
2532    })
2533}
2534
2535/// Return the `identifier` token immediately following the first `as`.
2536fn solidity_identifier_after_as(children: &[(String, String)]) -> Option<String> {
2537    let as_pos = children.iter().position(|(k, _)| k == "as")?;
2538    children[as_pos + 1..]
2539        .iter()
2540        .find(|(k, _)| k == "identifier")
2541        .map(|(_, t)| t.clone())
2542}
2543
2544/// Collect named specifiers between `{` and `}` into verbatim strings,
2545/// combining `A as B` into `"A as B"` to match the ES specifier convention.
2546fn parse_solidity_named_specifiers(children: &[(String, String)]) -> Vec<String> {
2547    let mut names = Vec::new();
2548    let mut in_braces = false;
2549    let mut current: Option<String> = None;
2550    let mut expect_alias = false;
2551    for (k, t) in children {
2552        match k.as_str() {
2553            "{" => in_braces = true,
2554            "}" => {
2555                if let Some(n) = current.take() {
2556                    names.push(n);
2557                }
2558                in_braces = false;
2559            }
2560            _ if !in_braces => {}
2561            "identifier" => {
2562                if expect_alias {
2563                    if let Some(n) = current.take() {
2564                        names.push(format!("{n} as {t}"));
2565                    }
2566                    expect_alias = false;
2567                } else {
2568                    if let Some(n) = current.take() {
2569                        names.push(n);
2570                    }
2571                    current = Some(t.clone());
2572                }
2573            }
2574            "as" => expect_alias = true,
2575            "," => {
2576                if let Some(n) = current.take() {
2577                    names.push(n);
2578                }
2579                expect_alias = false;
2580            }
2581            _ => {}
2582        }
2583    }
2584    names
2585}
2586
2587/// Generate a Solidity import line in the appropriate form.
2588fn generate_solidity_import_line(req: &ImportRequest) -> String {
2589    if !req.names.is_empty() {
2590        format!(
2591            "import {{ {} }} from \"{}\";",
2592            req.names.join(", "),
2593            req.module_path
2594        )
2595    } else if let Some(ns) = req.namespace {
2596        format!("import * as {} from \"{}\";", ns, req.module_path)
2597    } else if let Some(al) = req.alias {
2598        format!("import \"{}\" as {};", req.module_path, al)
2599    } else {
2600        format!("import \"{}\";", req.module_path)
2601    }
2602}
2603
2604/// Skip past a newline character at the given position.
2605fn skip_newline(source: &str, pos: usize) -> usize {
2606    if pos < source.len() {
2607        let bytes = source.as_bytes();
2608        if bytes[pos] == b'\n' {
2609            return pos + 1;
2610        }
2611        if bytes[pos] == b'\r' {
2612            if pos + 1 < source.len() && bytes[pos + 1] == b'\n' {
2613                return pos + 2;
2614            }
2615            return pos + 1;
2616        }
2617    }
2618    pos
2619}
2620
2621// ---------------------------------------------------------------------------
2622// Unit tests
2623// ---------------------------------------------------------------------------
2624
2625#[cfg(test)]
2626mod tests {
2627    use super::*;
2628
2629    // --- ImportForm field-mapping contract (Stream M) ---
2630    //
2631    // These assert the additive `form` field faithfully mirrors the flat
2632    // fields each parser populates. They are the executable field-mapping
2633    // contract from the migration plan: when a reader is moved off a flat
2634    // field onto `form`, these guarantee no information was lost in the
2635    // de-overloading (Rust `pub`, Go alias) or restructuring.
2636
2637    #[test]
2638    fn form_es_mirrors_flat_fields() {
2639        let (_, block) = parse_ts(
2640            "import Default, { a, b as c } from \"ext\";\nimport type { T } from \"./t\";\nimport \"./side\";\nimport * as ns from \"nspkg\";\n",
2641        );
2642        // import Default, { a, b as c } from "ext"
2643        match &block.imports[0].form {
2644            ImportForm::Es {
2645                default_import,
2646                namespace_import,
2647                named,
2648                type_only,
2649                side_effect,
2650            } => {
2651                assert_eq!(default_import.as_deref(), Some("Default"));
2652                assert_eq!(namespace_import, &None);
2653                assert_eq!(named, &block.imports[0].names);
2654                assert!(!type_only);
2655                assert!(!side_effect);
2656            }
2657            other => panic!("expected Es, got {other:?}"),
2658        }
2659        // import type { T } from "./t"
2660        match &block.imports[1].form {
2661            ImportForm::Es {
2662                type_only, named, ..
2663            } => {
2664                assert!(type_only);
2665                assert_eq!(named, &block.imports[1].names);
2666            }
2667            other => panic!("expected Es type-only, got {other:?}"),
2668        }
2669        // import "./side"
2670        match &block.imports[2].form {
2671            ImportForm::Es { side_effect, .. } => assert!(side_effect),
2672            other => panic!("expected Es side-effect, got {other:?}"),
2673        }
2674        // import * as ns from "nspkg"
2675        match &block.imports[3].form {
2676            ImportForm::Es {
2677                namespace_import, ..
2678            } => assert_eq!(namespace_import.as_deref(), Some("ns")),
2679            other => panic!("expected Es namespace, got {other:?}"),
2680        }
2681    }
2682
2683    #[test]
2684    fn form_python_mirrors_flat_fields() {
2685        let (_, block) = parse_py("import os\nfrom sys import argv, path\n");
2686        match &block.imports[0].form {
2687            ImportForm::Python { from_import, named } => {
2688                assert!(!from_import, "`import os` is not a from-import");
2689                assert_eq!(named, &["os"]);
2690            }
2691            other => panic!("expected Python import, got {other:?}"),
2692        }
2693        match &block.imports[1].form {
2694            ImportForm::Python { from_import, named } => {
2695                assert!(from_import, "`from sys import ...` is a from-import");
2696                assert_eq!(named, &block.imports[1].names);
2697            }
2698            other => panic!("expected Python from-import, got {other:?}"),
2699        }
2700    }
2701
2702    #[test]
2703    fn form_rust_de_overloads_pub_from_default_import() {
2704        let (_, block) = parse_rust("pub use crate::a::Exported;\nuse std::fmt::Debug;\n");
2705        // pub use -> visibility=Some("pub"); flat field still carries the "pub" hack.
2706        match &block.imports[0].form {
2707            ImportForm::RustUse { visibility, named } => {
2708                assert_eq!(visibility.as_deref(), Some("pub"));
2709                assert_eq!(named, &block.imports[0].names);
2710            }
2711            other => panic!("expected RustUse, got {other:?}"),
2712        }
2713        assert_eq!(
2714            block.imports[0].default_import.as_deref(),
2715            Some("pub"),
2716            "flat field unchanged during additive migration"
2717        );
2718        // plain use -> visibility=None
2719        match &block.imports[1].form {
2720            ImportForm::RustUse { visibility, .. } => assert_eq!(visibility, &None),
2721            other => panic!("expected RustUse, got {other:?}"),
2722        }
2723        assert_eq!(block.imports[1].default_import, None);
2724    }
2725
2726    #[test]
2727    fn form_go_de_overloads_alias_from_default_import() {
2728        // The current Go parser only captures blank (`_`) / dot bindings as the
2729        // alias (regular package aliases like `al "path"` are a pre-existing
2730        // parser gap — not extracted into `default_import` today). The contract
2731        // locked here is that `form.alias` mirrors `default_import` exactly,
2732        // whatever the parser captures, so the de-overload is information-faithful.
2733        let (_, block) =
2734            parse_go("package main\n\nimport (\n\t_ \"github.com/x/y\"\n\t\"fmt\"\n)\n");
2735        let blank = block
2736            .imports
2737            .iter()
2738            .find(|i| i.module_path == "github.com/x/y")
2739            .expect("blank import parsed");
2740        match &blank.form {
2741            ImportForm::Go { alias } => assert_eq!(alias.as_deref(), Some("_")),
2742            other => panic!("expected Go blank-aliased, got {other:?}"),
2743        }
2744        assert_eq!(
2745            blank.default_import.as_deref(),
2746            Some("_"),
2747            "form.alias mirrors the flat default_import field exactly"
2748        );
2749        let plain = block
2750            .imports
2751            .iter()
2752            .find(|i| i.module_path == "fmt")
2753            .expect("plain import parsed");
2754        match &plain.form {
2755            ImportForm::Go { alias } => assert_eq!(alias, &None),
2756            other => panic!("expected Go plain, got {other:?}"),
2757        }
2758        assert_eq!(plain.default_import, None);
2759    }
2760
2761    fn parse_ts(source: &str) -> (Tree, ImportBlock) {
2762        let grammar = grammar_for(LangId::TypeScript);
2763        let mut parser = Parser::new();
2764        parser.set_language(&grammar).unwrap();
2765        let tree = parser.parse(source, None).unwrap();
2766        let block = parse_imports(source, &tree, LangId::TypeScript);
2767        (tree, block)
2768    }
2769
2770    fn parse_js(source: &str) -> (Tree, ImportBlock) {
2771        let grammar = grammar_for(LangId::JavaScript);
2772        let mut parser = Parser::new();
2773        parser.set_language(&grammar).unwrap();
2774        let tree = parser.parse(source, None).unwrap();
2775        let block = parse_imports(source, &tree, LangId::JavaScript);
2776        (tree, block)
2777    }
2778
2779    fn parse_vue(source: &str) -> (Tree, ImportBlock) {
2780        let grammar = grammar_for(LangId::Vue);
2781        let mut parser = Parser::new();
2782        parser.set_language(&grammar).unwrap();
2783        let tree = parser.parse(source, None).unwrap();
2784        let block = parse_imports(source, &tree, LangId::Vue);
2785        (tree, block)
2786    }
2787
2788    /// Locks the tree-sitter-vue node kinds the Vue engine depends on: the
2789    /// `<script>` body is exposed as a single `raw_text` node inside a
2790    /// `script_element`. If a grammar bump changes this, the engine breaks
2791    /// silently, so assert it here.
2792    #[test]
2793    fn vue_grammar_node_kinds_are_stable() {
2794        let src = "<template>\n  <div />\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\n</script>\n";
2795        let grammar = grammar_for(LangId::Vue);
2796        let mut parser = Parser::new();
2797        parser.set_language(&grammar).unwrap();
2798        let tree = parser.parse(src, None).unwrap();
2799        let root = tree.root_node();
2800        let mut cursor = root.walk();
2801        let script = root
2802            .named_children(&mut cursor)
2803            .find(|n| n.kind() == "script_element")
2804            .expect("expected a script_element node");
2805        let mut inner = script.walk();
2806        assert!(
2807            script
2808                .named_children(&mut inner)
2809                .any(|n| n.kind() == "raw_text"),
2810            "expected script body exposed as raw_text"
2811        );
2812    }
2813
2814    #[test]
2815    fn vue_parses_script_imports_with_whole_file_offsets() {
2816        let src = "<template>\n  <div />\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport Foo from './Foo.vue'\nconst x = ref(0)\n</script>\n";
2817        let (_tree, block) = parse_vue(src);
2818        assert_eq!(block.imports.len(), 2, "should find both script imports");
2819        // Byte ranges must be whole-file (inside the <script> block), not
2820        // script-relative — verify the raw slice round-trips.
2821        for imp in &block.imports {
2822            assert_eq!(&src[imp.byte_range.clone()], imp.raw_text);
2823            assert!(
2824                imp.byte_range.start > src.find("<script").unwrap(),
2825                "import offset must fall inside the script block"
2826            );
2827        }
2828        assert_eq!(block.imports[0].module_path, "vue");
2829        assert_eq!(block.imports[1].module_path, "./Foo.vue");
2830    }
2831
2832    #[test]
2833    fn vue_without_script_block_has_no_imports() {
2834        let src = "<template>\n  <div />\n</template>\n\n<style>.x{}</style>\n";
2835        let (_tree, block) = parse_vue(src);
2836        assert!(block.imports.is_empty());
2837        assert!(block.byte_range.is_none());
2838    }
2839
2840    // --- Basic parsing ---
2841
2842    #[test]
2843    fn parse_ts_named_imports() {
2844        let source = "import { useState, useEffect } from 'react';\n";
2845        let (_, block) = parse_ts(source);
2846        assert_eq!(block.imports.len(), 1);
2847        let imp = &block.imports[0];
2848        assert_eq!(imp.module_path, "react");
2849        assert!(imp.names.contains(&"useState".to_string()));
2850        assert!(imp.names.contains(&"useEffect".to_string()));
2851        assert_eq!(imp.kind, ImportKind::Value);
2852        assert_eq!(imp.group, ImportGroup::External);
2853    }
2854
2855    #[test]
2856    fn parse_ts_default_import() {
2857        let source = "import React from 'react';\n";
2858        let (_, block) = parse_ts(source);
2859        assert_eq!(block.imports.len(), 1);
2860        let imp = &block.imports[0];
2861        assert_eq!(imp.default_import.as_deref(), Some("React"));
2862        assert_eq!(imp.kind, ImportKind::Value);
2863    }
2864
2865    #[test]
2866    fn parse_ts_side_effect_import() {
2867        let source = "import './styles.css';\n";
2868        let (_, block) = parse_ts(source);
2869        assert_eq!(block.imports.len(), 1);
2870        assert_eq!(block.imports[0].kind, ImportKind::SideEffect);
2871        assert_eq!(block.imports[0].module_path, "./styles.css");
2872    }
2873
2874    #[test]
2875    fn parse_ts_relative_import() {
2876        let source = "import { helper } from './utils';\n";
2877        let (_, block) = parse_ts(source);
2878        assert_eq!(block.imports.len(), 1);
2879        assert_eq!(block.imports[0].group, ImportGroup::Internal);
2880    }
2881
2882    #[test]
2883    fn parse_ts_multiple_groups() {
2884        let source = "\
2885import React from 'react';
2886import { useState } from 'react';
2887import { helper } from './utils';
2888import { Config } from '../config';
2889";
2890        let (_, block) = parse_ts(source);
2891        assert_eq!(block.imports.len(), 4);
2892
2893        let external: Vec<_> = block
2894            .imports
2895            .iter()
2896            .filter(|i| i.group == ImportGroup::External)
2897            .collect();
2898        let relative: Vec<_> = block
2899            .imports
2900            .iter()
2901            .filter(|i| i.group == ImportGroup::Internal)
2902            .collect();
2903        assert_eq!(external.len(), 2);
2904        assert_eq!(relative.len(), 2);
2905    }
2906
2907    #[test]
2908    fn parse_ts_namespace_import() {
2909        let source = "import * as path from 'path';\n";
2910        let (_, block) = parse_ts(source);
2911        assert_eq!(block.imports.len(), 1);
2912        let imp = &block.imports[0];
2913        assert_eq!(imp.namespace_import.as_deref(), Some("path"));
2914        assert_eq!(imp.kind, ImportKind::Value);
2915    }
2916
2917    #[test]
2918    fn parse_js_imports() {
2919        let source = "import { readFile } from 'fs';\nimport { helper } from './helper';\n";
2920        let (_, block) = parse_js(source);
2921        assert_eq!(block.imports.len(), 2);
2922        assert_eq!(block.imports[0].group, ImportGroup::External);
2923        assert_eq!(block.imports[1].group, ImportGroup::Internal);
2924    }
2925
2926    // --- Group classification ---
2927
2928    #[test]
2929    fn classify_external() {
2930        assert_eq!(classify_group_ts("react"), ImportGroup::External);
2931        assert_eq!(classify_group_ts("@scope/pkg"), ImportGroup::External);
2932        assert_eq!(classify_group_ts("lodash/map"), ImportGroup::External);
2933    }
2934
2935    #[test]
2936    fn classify_relative() {
2937        assert_eq!(classify_group_ts("./utils"), ImportGroup::Internal);
2938        assert_eq!(classify_group_ts("../config"), ImportGroup::Internal);
2939        assert_eq!(classify_group_ts("./"), ImportGroup::Internal);
2940    }
2941
2942    // --- Dedup ---
2943
2944    #[test]
2945    fn dedup_detects_same_named_import() {
2946        let source = "import { useState } from 'react';\n";
2947        let (_, block) = parse_ts(source);
2948        assert!(is_duplicate(
2949            &block,
2950            "react",
2951            &["useState".to_string()],
2952            None,
2953            false
2954        ));
2955    }
2956
2957    #[test]
2958    fn dedup_misses_different_name() {
2959        let source = "import { useState } from 'react';\n";
2960        let (_, block) = parse_ts(source);
2961        assert!(!is_duplicate(
2962            &block,
2963            "react",
2964            &["useEffect".to_string()],
2965            None,
2966            false
2967        ));
2968    }
2969
2970    #[test]
2971    fn dedup_detects_default_import() {
2972        let source = "import React from 'react';\n";
2973        let (_, block) = parse_ts(source);
2974        assert!(is_duplicate(&block, "react", &[], Some("React"), false));
2975    }
2976
2977    #[test]
2978    fn dedup_side_effect() {
2979        let source = "import './styles.css';\n";
2980        let (_, block) = parse_ts(source);
2981        assert!(is_duplicate(&block, "./styles.css", &[], None, false));
2982    }
2983
2984    #[test]
2985    fn dedup_namespace_import_distinct_from_side_effect_import() {
2986        let side_effect_source = "import 'fs';\n";
2987        let (_, side_effect_block) = parse_ts(side_effect_source);
2988        assert!(!is_duplicate_with_namespace(
2989            &side_effect_block,
2990            "fs",
2991            &[],
2992            None,
2993            Some("fs"),
2994            false
2995        ));
2996
2997        let namespace_source = "import * as fs from 'fs';\n";
2998        let (_, namespace_block) = parse_ts(namespace_source);
2999        assert!(!is_duplicate(&namespace_block, "fs", &[], None, false));
3000        assert!(is_duplicate_with_namespace(
3001            &namespace_block,
3002            "fs",
3003            &[],
3004            None,
3005            Some("fs"),
3006            false
3007        ));
3008        assert!(!is_duplicate_with_namespace(
3009            &namespace_block,
3010            "fs",
3011            &[],
3012            None,
3013            Some("other"),
3014            false
3015        ));
3016    }
3017
3018    #[test]
3019    fn dedup_type_vs_value() {
3020        let source = "import { FC } from 'react';\n";
3021        let (_, block) = parse_ts(source);
3022        // Type import should NOT match a value import of the same name
3023        assert!(!is_duplicate(
3024            &block,
3025            "react",
3026            &["FC".to_string()],
3027            None,
3028            true
3029        ));
3030    }
3031
3032    // --- Generation ---
3033
3034    #[test]
3035    fn generate_named_import() {
3036        let line = generate_import_line(
3037            LangId::TypeScript,
3038            "react",
3039            &["useState".to_string(), "useEffect".to_string()],
3040            None,
3041            false,
3042        );
3043        assert_eq!(line, "import { useEffect, useState } from 'react';");
3044    }
3045
3046    #[test]
3047    fn generate_named_import_sorts_by_imported_name() {
3048        let line = generate_import_line(
3049            LangId::TypeScript,
3050            "x",
3051            &[
3052                "useState".to_string(),
3053                "type Foo".to_string(),
3054                "stdin as input".to_string(),
3055                "type Bar".to_string(),
3056            ],
3057            None,
3058            false,
3059        );
3060        assert_eq!(
3061            line,
3062            "import { type Bar, type Foo, stdin as input, useState } from 'x';"
3063        );
3064    }
3065
3066    #[test]
3067    fn generate_default_import() {
3068        let line = generate_import_line(LangId::TypeScript, "react", &[], Some("React"), false);
3069        assert_eq!(line, "import React from 'react';");
3070    }
3071
3072    #[test]
3073    fn generate_type_import() {
3074        let line =
3075            generate_import_line(LangId::TypeScript, "react", &["FC".to_string()], None, true);
3076        assert_eq!(line, "import type { FC } from 'react';");
3077    }
3078
3079    #[test]
3080    fn generate_side_effect_import() {
3081        let line = generate_import_line(LangId::TypeScript, "./styles.css", &[], None, false);
3082        assert_eq!(line, "import './styles.css';");
3083    }
3084
3085    #[test]
3086    fn generate_default_and_named() {
3087        let line = generate_import_line(
3088            LangId::TypeScript,
3089            "react",
3090            &["useState".to_string()],
3091            Some("React"),
3092            false,
3093        );
3094        assert_eq!(line, "import React, { useState } from 'react';");
3095    }
3096
3097    #[test]
3098    fn parse_ts_type_import() {
3099        let source = "import type { FC } from 'react';\n";
3100        let (_, block) = parse_ts(source);
3101        assert_eq!(block.imports.len(), 1);
3102        let imp = &block.imports[0];
3103        assert_eq!(imp.kind, ImportKind::Type);
3104        assert!(imp.names.contains(&"FC".to_string()));
3105        assert_eq!(imp.group, ImportGroup::External);
3106    }
3107
3108    // --- Insertion point ---
3109
3110    #[test]
3111    fn insertion_empty_file() {
3112        let source = "";
3113        let (_, block) = parse_ts(source);
3114        let (offset, _, _) =
3115            find_insertion_point(source, &block, ImportGroup::External, "react", false);
3116        assert_eq!(offset, 0);
3117    }
3118
3119    #[test]
3120    fn insertion_alphabetical_within_group() {
3121        let source = "\
3122import { a } from 'alpha';
3123import { c } from 'charlie';
3124";
3125        let (_, block) = parse_ts(source);
3126        let (offset, _, _) =
3127            find_insertion_point(source, &block, ImportGroup::External, "bravo", false);
3128        // Should insert before 'charlie' (which starts at line 2)
3129        let before_charlie = source.find("import { c }").unwrap();
3130        assert_eq!(offset, before_charlie);
3131    }
3132
3133    // --- Python parsing ---
3134
3135    fn parse_py(source: &str) -> (Tree, ImportBlock) {
3136        let grammar = grammar_for(LangId::Python);
3137        let mut parser = Parser::new();
3138        parser.set_language(&grammar).unwrap();
3139        let tree = parser.parse(source, None).unwrap();
3140        let block = parse_imports(source, &tree, LangId::Python);
3141        (tree, block)
3142    }
3143
3144    #[test]
3145    fn parse_py_import_statement() {
3146        let source = "import os\nimport sys\n";
3147        let (_, block) = parse_py(source);
3148        assert_eq!(block.imports.len(), 2);
3149        assert_eq!(block.imports[0].module_path, "os");
3150        assert_eq!(block.imports[1].module_path, "sys");
3151        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3152    }
3153
3154    #[test]
3155    fn parse_py_import_statement_preserves_aliases_and_siblings() {
3156        let source = "import alpha, beta as local_beta\n";
3157        let (_, block) = parse_py(source);
3158        let imp = &block.imports[0];
3159        assert_eq!(imp.module_path, "alpha");
3160        assert!(imp.names.is_empty());
3161        match &imp.form {
3162            ImportForm::Python { from_import, named } => {
3163                assert!(!from_import);
3164                assert_eq!(named, &["alpha", "beta as local_beta"]);
3165                assert!(specifier_matches(&named[1], "beta"));
3166                assert!(specifier_matches(&named[1], "local_beta"));
3167            }
3168            other => panic!("expected Python import, got {other:?}"),
3169        }
3170    }
3171
3172    #[test]
3173    fn parse_py_from_import() {
3174        let source = "from collections import OrderedDict\nfrom typing import List, Optional\n";
3175        let (_, block) = parse_py(source);
3176        assert_eq!(block.imports.len(), 2);
3177        assert_eq!(block.imports[0].module_path, "collections");
3178        assert!(block.imports[0].names.contains(&"OrderedDict".to_string()));
3179        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3180        assert_eq!(block.imports[1].module_path, "typing");
3181        assert!(block.imports[1].names.contains(&"List".to_string()));
3182        assert!(block.imports[1].names.contains(&"Optional".to_string()));
3183    }
3184
3185    #[test]
3186    fn parse_py_from_import_preserves_aliases() {
3187        let source = "from module import alpha, beta as local_beta\n";
3188        let (_, block) = parse_py(source);
3189        let imp = &block.imports[0];
3190        assert_eq!(imp.names, ["alpha", "beta as local_beta"]);
3191        assert!(specifier_matches(&imp.names[1], "beta"));
3192        assert!(specifier_matches(&imp.names[1], "local_beta"));
3193        assert_eq!(
3194            imp.form,
3195            ImportForm::Python {
3196                from_import: true,
3197                named: vec!["alpha".to_string(), "beta as local_beta".to_string()],
3198            }
3199        );
3200    }
3201
3202    #[test]
3203    fn parse_py_relative_import() {
3204        let source = "from . import utils\nfrom ..config import Settings\n";
3205        let (_, block) = parse_py(source);
3206        assert_eq!(block.imports.len(), 2);
3207        assert_eq!(block.imports[0].module_path, ".");
3208        assert!(block.imports[0].names.contains(&"utils".to_string()));
3209        assert_eq!(block.imports[0].group, ImportGroup::Internal);
3210        assert_eq!(block.imports[1].module_path, "..config");
3211        assert_eq!(block.imports[1].group, ImportGroup::Internal);
3212    }
3213
3214    #[test]
3215    fn classify_py_groups() {
3216        assert_eq!(classify_group_py("os"), ImportGroup::Stdlib);
3217        assert_eq!(classify_group_py("sys"), ImportGroup::Stdlib);
3218        assert_eq!(classify_group_py("json"), ImportGroup::Stdlib);
3219        assert_eq!(classify_group_py("collections"), ImportGroup::Stdlib);
3220        assert_eq!(classify_group_py("os.path"), ImportGroup::Stdlib);
3221        assert_eq!(classify_group_py("requests"), ImportGroup::External);
3222        assert_eq!(classify_group_py("flask"), ImportGroup::External);
3223        assert_eq!(classify_group_py("."), ImportGroup::Internal);
3224        assert_eq!(classify_group_py("..config"), ImportGroup::Internal);
3225        assert_eq!(classify_group_py(".utils"), ImportGroup::Internal);
3226    }
3227
3228    #[test]
3229    fn parse_py_three_groups() {
3230        let source = "import os\nimport sys\n\nimport requests\n\nfrom . import utils\n";
3231        let (_, block) = parse_py(source);
3232        let stdlib: Vec<_> = block
3233            .imports
3234            .iter()
3235            .filter(|i| i.group == ImportGroup::Stdlib)
3236            .collect();
3237        let external: Vec<_> = block
3238            .imports
3239            .iter()
3240            .filter(|i| i.group == ImportGroup::External)
3241            .collect();
3242        let internal: Vec<_> = block
3243            .imports
3244            .iter()
3245            .filter(|i| i.group == ImportGroup::Internal)
3246            .collect();
3247        assert_eq!(stdlib.len(), 2);
3248        assert_eq!(external.len(), 1);
3249        assert_eq!(internal.len(), 1);
3250    }
3251
3252    #[test]
3253    fn generate_py_import() {
3254        let line = generate_import_line(LangId::Python, "os", &[], None, false);
3255        assert_eq!(line, "import os");
3256    }
3257
3258    #[test]
3259    fn generate_py_from_import() {
3260        let line = generate_import_line(
3261            LangId::Python,
3262            "collections",
3263            &["OrderedDict".to_string()],
3264            None,
3265            false,
3266        );
3267        assert_eq!(line, "from collections import OrderedDict");
3268    }
3269
3270    #[test]
3271    fn generate_py_from_import_multiple() {
3272        let line = generate_import_line(
3273            LangId::Python,
3274            "typing",
3275            &["Optional".to_string(), "List".to_string()],
3276            None,
3277            false,
3278        );
3279        assert_eq!(line, "from typing import List, Optional");
3280    }
3281
3282    // --- Rust parsing ---
3283
3284    fn parse_rust(source: &str) -> (Tree, ImportBlock) {
3285        let grammar = grammar_for(LangId::Rust);
3286        let mut parser = Parser::new();
3287        parser.set_language(&grammar).unwrap();
3288        let tree = parser.parse(source, None).unwrap();
3289        let block = parse_imports(source, &tree, LangId::Rust);
3290        (tree, block)
3291    }
3292
3293    #[test]
3294    fn parse_rs_use_std() {
3295        let source = "use std::collections::HashMap;\nuse std::io::Read;\n";
3296        let (_, block) = parse_rust(source);
3297        assert_eq!(block.imports.len(), 2);
3298        assert_eq!(block.imports[0].module_path, "std::collections::HashMap");
3299        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3300        assert_eq!(block.imports[1].group, ImportGroup::Stdlib);
3301    }
3302
3303    #[test]
3304    fn parse_rs_use_external() {
3305        let source = "use serde::{Deserialize, Serialize};\n";
3306        let (_, block) = parse_rust(source);
3307        assert_eq!(block.imports.len(), 1);
3308        assert_eq!(block.imports[0].group, ImportGroup::External);
3309        assert!(block.imports[0].names.contains(&"Deserialize".to_string()));
3310        assert!(block.imports[0].names.contains(&"Serialize".to_string()));
3311    }
3312
3313    #[test]
3314    fn parse_rs_use_crate() {
3315        let source = "use crate::config::Settings;\nuse super::parent::Thing;\n";
3316        let (_, block) = parse_rust(source);
3317        assert_eq!(block.imports.len(), 2);
3318        assert_eq!(block.imports[0].group, ImportGroup::Internal);
3319        assert_eq!(block.imports[1].group, ImportGroup::Internal);
3320    }
3321
3322    #[test]
3323    fn parse_rs_pub_use() {
3324        let source = "pub use super::parent::Thing;\n";
3325        let (_, block) = parse_rust(source);
3326        assert_eq!(block.imports.len(), 1);
3327        // `pub` is stored in default_import as a marker
3328        assert_eq!(block.imports[0].default_import.as_deref(), Some("pub"));
3329    }
3330
3331    #[test]
3332    fn classify_rs_groups() {
3333        assert_eq!(
3334            classify_group_rs("std::collections::HashMap"),
3335            ImportGroup::Stdlib
3336        );
3337        assert_eq!(classify_group_rs("core::mem"), ImportGroup::Stdlib);
3338        assert_eq!(classify_group_rs("alloc::vec"), ImportGroup::Stdlib);
3339        assert_eq!(
3340            classify_group_rs("serde::Deserialize"),
3341            ImportGroup::External
3342        );
3343        assert_eq!(classify_group_rs("tokio::runtime"), ImportGroup::External);
3344        assert_eq!(classify_group_rs("crate::config"), ImportGroup::Internal);
3345        assert_eq!(classify_group_rs("self::utils"), ImportGroup::Internal);
3346        assert_eq!(classify_group_rs("super::parent"), ImportGroup::Internal);
3347    }
3348
3349    #[test]
3350    fn generate_rs_use() {
3351        let line = generate_import_line(LangId::Rust, "std::fmt::Display", &[], None, false);
3352        assert_eq!(line, "use std::fmt::Display;");
3353    }
3354
3355    // --- Go parsing ---
3356
3357    fn parse_go(source: &str) -> (Tree, ImportBlock) {
3358        let grammar = grammar_for(LangId::Go);
3359        let mut parser = Parser::new();
3360        parser.set_language(&grammar).unwrap();
3361        let tree = parser.parse(source, None).unwrap();
3362        let block = parse_imports(source, &tree, LangId::Go);
3363        (tree, block)
3364    }
3365
3366    #[test]
3367    fn parse_go_single_import() {
3368        let source = "package main\n\nimport \"fmt\"\n";
3369        let (_, block) = parse_go(source);
3370        assert_eq!(block.imports.len(), 1);
3371        assert_eq!(block.imports[0].module_path, "fmt");
3372        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3373    }
3374
3375    #[test]
3376    fn parse_go_grouped_import() {
3377        let source =
3378            "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/pkg/errors\"\n)\n";
3379        let (_, block) = parse_go(source);
3380        assert_eq!(block.imports.len(), 3);
3381        assert_eq!(block.imports[0].module_path, "fmt");
3382        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3383        assert_eq!(block.imports[1].module_path, "os");
3384        assert_eq!(block.imports[1].group, ImportGroup::Stdlib);
3385        assert_eq!(block.imports[2].module_path, "github.com/pkg/errors");
3386        assert_eq!(block.imports[2].group, ImportGroup::External);
3387    }
3388
3389    #[test]
3390    fn parse_go_mixed_imports() {
3391        // Single + grouped
3392        let source = "package main\n\nimport \"fmt\"\n\nimport (\n\t\"os\"\n\t\"github.com/pkg/errors\"\n)\n";
3393        let (_, block) = parse_go(source);
3394        assert_eq!(block.imports.len(), 3);
3395    }
3396
3397    #[test]
3398    fn classify_go_groups() {
3399        assert_eq!(classify_group_go("fmt"), ImportGroup::Stdlib);
3400        assert_eq!(classify_group_go("os"), ImportGroup::Stdlib);
3401        assert_eq!(classify_group_go("net/http"), ImportGroup::Stdlib);
3402        assert_eq!(classify_group_go("encoding/json"), ImportGroup::Stdlib);
3403        assert_eq!(
3404            classify_group_go("github.com/pkg/errors"),
3405            ImportGroup::External
3406        );
3407        assert_eq!(
3408            classify_group_go("golang.org/x/tools"),
3409            ImportGroup::External
3410        );
3411    }
3412
3413    #[test]
3414    fn generate_go_standalone() {
3415        let line = generate_go_import_line("fmt", None, false);
3416        assert_eq!(line, "import \"fmt\"");
3417    }
3418
3419    #[test]
3420    fn generate_go_grouped_spec() {
3421        let line = generate_go_import_line("fmt", None, true);
3422        assert_eq!(line, "\t\"fmt\"");
3423    }
3424
3425    #[test]
3426    fn generate_go_with_alias() {
3427        let line = generate_go_import_line("github.com/pkg/errors", Some("errs"), false);
3428        assert_eq!(line, "import errs \"github.com/pkg/errors\"");
3429    }
3430
3431    // --- Solidity ---
3432
3433    fn parse_solidity(source: &str) -> (Tree, ImportBlock) {
3434        let grammar = grammar_for(LangId::Solidity);
3435        let mut parser = Parser::new();
3436        parser.set_language(&grammar).unwrap();
3437        let tree = parser.parse(source, None).unwrap();
3438        let block = parse_imports(source, &tree, LangId::Solidity);
3439        (tree, block)
3440    }
3441
3442    /// Grammar fixture (council #6): lock the tree-sitter-solidity node kinds the
3443    /// parser depends on. If the grammar updates and renames these, this test
3444    /// fails loudly before the parser silently mis-parses.
3445    #[test]
3446    fn solidity_grammar_node_kinds_are_stable() {
3447        let grammar = grammar_for(LangId::Solidity);
3448        let mut parser = Parser::new();
3449        parser.set_language(&grammar).unwrap();
3450        let src = "import { Foo, Bar as Baz } from \"./A.sol\";\nimport * as N from \"./B.sol\";\nimport \"./C.sol\" as C;\nimport \"./D.sol\";\n";
3451        let tree = parser.parse(src, None).unwrap();
3452        let mut kinds: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3453        fn walk(node: tree_sitter::Node, kinds: &mut std::collections::BTreeSet<String>) {
3454            kinds.insert(node.kind().to_string());
3455            let mut c = node.walk();
3456            if c.goto_first_child() {
3457                loop {
3458                    walk(c.node(), kinds);
3459                    if !c.goto_next_sibling() {
3460                        break;
3461                    }
3462                }
3463            }
3464        }
3465        walk(tree.root_node(), &mut kinds);
3466        for required in [
3467            "import_directive",
3468            "string",
3469            "identifier",
3470            "as",
3471            "from",
3472            "*",
3473            "{",
3474            "}",
3475        ] {
3476            assert!(
3477                kinds.contains(required),
3478                "solidity grammar missing node kind {required:?}; present: {kinds:?}"
3479            );
3480        }
3481    }
3482
3483    #[test]
3484    fn parse_solidity_all_four_forms() {
3485        let (_, block) = parse_solidity(
3486            "import \"./A.sol\";\nimport \"./B.sol\" as B;\nimport * as C from \"./C.sol\";\nimport { Foo, Bar as Baz } from \"./D.sol\";\n",
3487        );
3488        assert_eq!(block.imports.len(), 4);
3489
3490        // side-effect
3491        assert_eq!(block.imports[0].module_path, "./A.sol");
3492        assert_eq!(block.imports[0].kind, ImportKind::SideEffect);
3493        assert_eq!(
3494            block.imports[0].form,
3495            ImportForm::Solidity {
3496                named: vec![],
3497                namespace: None,
3498                alias: None
3499            }
3500        );
3501
3502        // whole-file alias
3503        assert_eq!(
3504            block.imports[1].form,
3505            ImportForm::Solidity {
3506                named: vec![],
3507                namespace: None,
3508                alias: Some("B".to_string())
3509            }
3510        );
3511
3512        // namespace
3513        match &block.imports[2].form {
3514            ImportForm::Solidity { namespace, .. } => assert_eq!(namespace.as_deref(), Some("C")),
3515            other => panic!("expected Solidity namespace, got {other:?}"),
3516        }
3517        assert_eq!(block.imports[2].namespace_import.as_deref(), Some("C"));
3518
3519        // named with alias (verbatim specifier convention)
3520        match &block.imports[3].form {
3521            ImportForm::Solidity { named, .. } => {
3522                assert_eq!(named, &vec!["Foo".to_string(), "Bar as Baz".to_string()]);
3523            }
3524            other => panic!("expected Solidity named, got {other:?}"),
3525        }
3526        assert_eq!(
3527            block.imports[3].names,
3528            vec!["Foo".to_string(), "Bar as Baz".to_string()]
3529        );
3530    }
3531
3532    #[test]
3533    fn generate_solidity_all_forms() {
3534        // side-effect
3535        assert_eq!(
3536            generate_import(
3537                LangId::Solidity,
3538                &ImportRequest::legacy("./A.sol", &[], None, None, false)
3539            ),
3540            "import \"./A.sol\";"
3541        );
3542        // named
3543        let names = vec!["Foo".to_string(), "Bar as Baz".to_string()];
3544        assert_eq!(
3545            generate_import(
3546                LangId::Solidity,
3547                &ImportRequest::legacy("./D.sol", &names, None, None, false)
3548            ),
3549            "import { Foo, Bar as Baz } from \"./D.sol\";"
3550        );
3551        // namespace
3552        assert_eq!(
3553            generate_import(
3554                LangId::Solidity,
3555                &ImportRequest::legacy("./C.sol", &[], None, Some("C"), false)
3556            ),
3557            "import * as C from \"./C.sol\";"
3558        );
3559        // whole-file alias
3560        assert_eq!(
3561            generate_import(
3562                LangId::Solidity,
3563                &ImportRequest {
3564                    module_path: "./B.sol",
3565                    names: &[],
3566                    default_import: None,
3567                    namespace: None,
3568                    alias: Some("B"),
3569                    type_only: false,
3570                    modifiers: &[],
3571                    import_kind: None,
3572                }
3573            ),
3574            "import \"./B.sol\" as B;"
3575        );
3576    }
3577
3578    #[test]
3579    fn solidity_round_trips_through_parse_generate() {
3580        // Every generated form must parse back to the same structured shape.
3581        for src in [
3582            "import \"./A.sol\";",
3583            "import \"./B.sol\" as B;",
3584            "import * as C from \"./C.sol\";",
3585            "import { Foo, Bar as Baz } from \"./D.sol\";",
3586        ] {
3587            let (_, block) = parse_solidity(src);
3588            assert_eq!(block.imports.len(), 1, "parse {src:?}");
3589            let imp = &block.imports[0];
3590            let (namespace, alias) = match &imp.form {
3591                ImportForm::Solidity {
3592                    namespace, alias, ..
3593                } => (namespace.as_deref(), alias.as_deref()),
3594                other => panic!("expected Solidity, got {other:?}"),
3595            };
3596            let regenerated = generate_import(
3597                LangId::Solidity,
3598                &ImportRequest {
3599                    module_path: &imp.module_path,
3600                    names: &imp.names,
3601                    default_import: None,
3602                    namespace,
3603                    alias,
3604                    type_only: false,
3605                    modifiers: &[],
3606                    import_kind: None,
3607                },
3608            );
3609            assert_eq!(regenerated, src, "round-trip mismatch for {src:?}");
3610        }
3611    }
3612
3613    #[test]
3614    fn classify_group_solidity_relative_vs_external() {
3615        assert_eq!(classify_group_solidity("./A.sol"), ImportGroup::Internal);
3616        assert_eq!(
3617            classify_group_solidity("../lib/B.sol"),
3618            ImportGroup::Internal
3619        );
3620        assert_eq!(
3621            classify_group_solidity("@openzeppelin/contracts/token/ERC20/ERC20.sol"),
3622            ImportGroup::External
3623        );
3624    }
3625}