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