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 identity_modifiers: Vec<String> = req
1024        .modifiers
1025        .iter()
1026        .filter(|modifier| modifier.as_str() != "scala2")
1027        .cloned()
1028        .collect();
1029    let mut import_kind = req.import_kind.map(str::to_string);
1030
1031    if req.default_import == Some("given") || module_path.ends_with(".given") {
1032        import_kind.get_or_insert_with(|| "given".to_string());
1033        if let Some(stripped) = module_path.strip_suffix(".given") {
1034            module_path = stripped.to_string();
1035        }
1036    }
1037
1038    if matches!(req.default_import, Some("*") | Some("_"))
1039        || matches!(req.namespace, Some("*") | Some("_"))
1040        || module_path.ends_with(".*")
1041        || module_path.ends_with("._")
1042    {
1043        if !identity_modifiers
1044            .iter()
1045            .any(|modifier| modifier == "wildcard")
1046        {
1047            identity_modifiers.push("wildcard".to_string());
1048        }
1049        module_path = module_path
1050            .strip_suffix(".*")
1051            .or_else(|| module_path.strip_suffix("._"))
1052            .unwrap_or(&module_path)
1053            .to_string();
1054    }
1055
1056    if names.is_empty() {
1057        if let Some(alias) = req.alias.filter(|alias| !alias.is_empty()) {
1058            if let Some((prefix, leaf)) = module_path.rsplit_once('.') {
1059                names.push(format!("{leaf} as {alias}"));
1060                module_path = prefix.to_string();
1061            }
1062        }
1063    }
1064
1065    structured_dedup_key(
1066        &module_path,
1067        ImportKind::Value,
1068        &names,
1069        None,
1070        None,
1071        &identity_modifiers,
1072        import_kind.as_deref(),
1073    )
1074}
1075
1076fn normalize_scala_selector_for_dedup(name: &str) -> String {
1077    let trimmed = name.trim();
1078    if let Some((from, to)) = trimmed.split_once("=>") {
1079        format!("{} as {}", from.trim(), to.trim())
1080    } else {
1081        trimmed.to_string()
1082    }
1083}
1084
1085fn canonical_dedup_key(lang: LangId, mut key: ImportDedupKey) -> ImportDedupKey {
1086    match &mut key.form {
1087        ImportForm::Structured { named, .. } | ImportForm::Solidity { named, .. } => {
1088            sort_named_specifiers(named);
1089        }
1090        ImportForm::Es { named, .. } | ImportForm::Python { named, .. } => {
1091            sort_named_specifiers(named);
1092        }
1093        ImportForm::RustUse { named, .. } => {
1094            sort_named_specifiers(named);
1095        }
1096        ImportForm::Go { .. } | ImportForm::Php { .. } => {}
1097    }
1098
1099    if matches!(lang, LangId::Java | LangId::Kotlin) {
1100        if let Some(stripped) = key.module_path.strip_suffix(".*") {
1101            key.module_path = stripped.to_string();
1102        }
1103        if matches!(lang, LangId::Java) {
1104            if let ImportForm::Structured {
1105                named, modifiers, ..
1106            } = &mut key.form
1107            {
1108                normalize_java_static_member_key(&mut key.module_path, modifiers, named);
1109            }
1110        }
1111    } else if matches!(lang, LangId::Scala) {
1112        key.module_path = key
1113            .module_path
1114            .strip_suffix(".given")
1115            .or_else(|| key.module_path.strip_suffix(".*"))
1116            .or_else(|| key.module_path.strip_suffix("._"))
1117            .unwrap_or(&key.module_path)
1118            .to_string();
1119    }
1120
1121    key
1122}
1123
1124fn sort_named_specifiers(names: &mut [String]) {
1125    names.sort_by(|a, b| {
1126        specifier_imported_name(a)
1127            .cmp(specifier_imported_name(b))
1128            .then_with(|| a.cmp(b))
1129    });
1130}
1131
1132/// Find the byte offset where a new import should be inserted.
1133///
1134/// Strategy:
1135/// - Find all existing imports in the same group.
1136/// - Within that group, find the alphabetical position by module path.
1137/// - Type imports sort after value imports within the same group and module-sort position.
1138/// - If no imports exist in the target group, insert after the last import of the
1139///   nearest preceding group (or before the first import of the nearest following
1140///   group, or at file start if no groups exist).
1141/// - Returns (byte_offset, needs_newline_before, needs_newline_after)
1142pub fn find_insertion_point(
1143    source: &str,
1144    block: &ImportBlock,
1145    group: ImportGroup,
1146    module_path: &str,
1147    type_only: bool,
1148) -> (usize, bool, bool) {
1149    if block.imports.is_empty() {
1150        // No imports at all — insert at start of file
1151        return (0, false, source.is_empty().then_some(false).unwrap_or(true));
1152    }
1153
1154    let target_kind = if type_only {
1155        ImportKind::Type
1156    } else {
1157        ImportKind::Value
1158    };
1159
1160    // Collect imports in the target group
1161    let group_imports: Vec<&ImportStatement> =
1162        block.imports.iter().filter(|i| i.group == group).collect();
1163
1164    if group_imports.is_empty() {
1165        // No imports in this group yet — find nearest neighbor group
1166        // Try preceding groups (lower ordinal) first
1167        let preceding_last = block.imports.iter().filter(|i| i.group < group).last();
1168
1169        if let Some(last) = preceding_last {
1170            let end = last.byte_range.end;
1171            let insert_at = skip_newline(source, end);
1172            return (insert_at, true, true);
1173        }
1174
1175        // No preceding group — try following groups (higher ordinal)
1176        let following_first = block.imports.iter().find(|i| i.group > group);
1177
1178        if let Some(first) = following_first {
1179            return (first.byte_range.start, false, true);
1180        }
1181
1182        // Shouldn't reach here if block is non-empty, but handle gracefully
1183        let first_byte = import_byte_range(&block.imports)
1184            .map(|range| range.start)
1185            .unwrap_or(0);
1186        return (first_byte, false, true);
1187    }
1188
1189    // Find position within the group (alphabetical by module path, type after value)
1190    for imp in &group_imports {
1191        let cmp = module_path.cmp(&imp.module_path);
1192        match cmp {
1193            std::cmp::Ordering::Less => {
1194                // Insert before this import
1195                return (imp.byte_range.start, false, false);
1196            }
1197            std::cmp::Ordering::Equal => {
1198                // Same module — type imports go after value imports
1199                if target_kind == ImportKind::Type && imp.kind == ImportKind::Value {
1200                    // Insert after this value import
1201                    let end = imp.byte_range.end;
1202                    let insert_at = skip_newline(source, end);
1203                    return (insert_at, false, false);
1204                }
1205                // Insert before (or it's a duplicate, caller should have checked)
1206                return (imp.byte_range.start, false, false);
1207            }
1208            std::cmp::Ordering::Greater => continue,
1209        }
1210    }
1211
1212    // Module path sorts after all existing imports in this group — insert at end
1213    let Some(last) = group_imports.last() else {
1214        return (
1215            import_byte_range(&block.imports)
1216                .map(|range| range.end)
1217                .unwrap_or(0),
1218            false,
1219            false,
1220        );
1221    };
1222    let end = last.byte_range.end;
1223    let insert_at = skip_newline(source, end);
1224    (insert_at, false, false)
1225}
1226
1227/// Generate a single import line from a structured [`ImportRequest`]. The full
1228/// entry point — engines read the fields they support; unsupported languages
1229/// yield an empty string.
1230pub fn generate_import(lang: LangId, req: &ImportRequest) -> String {
1231    match syntax_for(lang) {
1232        Some(engine) => engine.generate_line(req),
1233        None => String::new(),
1234    }
1235}
1236
1237/// Generate an import line for the given language. Back-compat wrapper over
1238/// [`generate_import`] for callers that pass only the legacy positional fields.
1239pub fn generate_import_line(
1240    lang: LangId,
1241    module_path: &str,
1242    names: &[String],
1243    default_import: Option<&str>,
1244    type_only: bool,
1245) -> String {
1246    generate_import(
1247        lang,
1248        &ImportRequest::legacy(module_path, names, default_import, None, type_only),
1249    )
1250}
1251
1252/// Generate an import line including namespace imports
1253/// (`import * as ns from 'mod'`). Back-compat wrapper over [`generate_import`].
1254pub fn generate_import_line_with_namespace(
1255    lang: LangId,
1256    module_path: &str,
1257    names: &[String],
1258    default_import: Option<&str>,
1259    namespace_import: Option<&str>,
1260    type_only: bool,
1261) -> String {
1262    generate_import(
1263        lang,
1264        &ImportRequest::legacy(
1265            module_path,
1266            names,
1267            default_import,
1268            namespace_import,
1269            type_only,
1270        ),
1271    )
1272}
1273
1274/// Check if the given language is supported by the import engine.
1275pub fn is_supported(lang: LangId) -> bool {
1276    syntax_for(lang).is_some()
1277}
1278
1279/// Classify a module path into a group for TS/JS/TSX.
1280pub fn classify_group_ts(module_path: &str) -> ImportGroup {
1281    if module_path.starts_with('.') {
1282        ImportGroup::Internal
1283    } else {
1284        ImportGroup::External
1285    }
1286}
1287
1288/// Classify a module path into a group for the given language.
1289pub fn classify_group(lang: LangId, module_path: &str) -> ImportGroup {
1290    match syntax_for(lang) {
1291        Some(engine) => engine.classify_group(module_path),
1292        // Unsupported languages have no grouping policy; External is the
1293        // historical neutral default.
1294        None => ImportGroup::External,
1295    }
1296}
1297
1298/// Parse a file from disk and return its import block.
1299/// Convenience wrapper that handles parsing.
1300pub fn parse_file_imports(
1301    path: &std::path::Path,
1302    lang: LangId,
1303) -> Result<(String, Tree, ImportBlock), crate::error::AftError> {
1304    let source =
1305        std::fs::read_to_string(path).map_err(|e| crate::error::AftError::FileNotFound {
1306            path: format!("{}: {}", path.display(), e),
1307        })?;
1308
1309    let grammar = grammar_for(lang);
1310    let mut parser = Parser::new();
1311    parser
1312        .set_language(&grammar)
1313        .map_err(|e| crate::error::AftError::ParseError {
1314            message: format!("grammar init failed for {:?}: {}", lang, e),
1315        })?;
1316
1317    let tree = parser
1318        .parse(&source, None)
1319        .ok_or_else(|| crate::error::AftError::ParseError {
1320            message: format!("tree-sitter parse returned None for {}", path.display()),
1321        })?;
1322
1323    let block = parse_imports(&source, &tree, lang);
1324    Ok((source, tree, block))
1325}
1326
1327// ---------------------------------------------------------------------------
1328// TS/JS/TSX implementation
1329// ---------------------------------------------------------------------------
1330
1331/// Parse imports from a TS/JS/TSX file.
1332///
1333/// Walks the AST root's direct children looking for `import_statement` nodes (D041).
1334fn parse_ts_imports(source: &str, tree: &Tree) -> ImportBlock {
1335    let root = tree.root_node();
1336    let mut imports = Vec::new();
1337
1338    let mut cursor = root.walk();
1339    if !cursor.goto_first_child() {
1340        return ImportBlock::empty();
1341    }
1342
1343    loop {
1344        let node = cursor.node();
1345        if node.kind() == "import_statement" {
1346            if let Some(imp) = parse_single_ts_import(source, &node) {
1347                imports.push(imp);
1348            }
1349        }
1350        if !cursor.goto_next_sibling() {
1351            break;
1352        }
1353    }
1354
1355    let byte_range = import_byte_range(&imports);
1356
1357    ImportBlock {
1358        imports,
1359        byte_range,
1360    }
1361}
1362
1363/// Parse a single `import_statement` node into an `ImportStatement`.
1364fn parse_single_ts_import(source: &str, node: &Node) -> Option<ImportStatement> {
1365    let raw_text = source[node.byte_range()].to_string();
1366    let byte_range = node.byte_range();
1367
1368    // Find the source module (string/string_fragment child of the import)
1369    let module_path = extract_module_path(source, node)?;
1370
1371    // Determine if this is a type-only import: `import type ...`
1372    let is_type_only = has_type_keyword(node);
1373
1374    // Extract import clause details
1375    let mut names = Vec::new();
1376    let mut default_import = None;
1377    let mut namespace_import = None;
1378
1379    let mut child_cursor = node.walk();
1380    if child_cursor.goto_first_child() {
1381        loop {
1382            let child = child_cursor.node();
1383            match child.kind() {
1384                "import_clause" => {
1385                    extract_import_clause(
1386                        source,
1387                        &child,
1388                        &mut names,
1389                        &mut default_import,
1390                        &mut namespace_import,
1391                    );
1392                }
1393                // In some grammars, the default import is a direct identifier child
1394                "identifier" => {
1395                    let text = &source[child.byte_range()];
1396                    if text != "import" && text != "from" && text != "type" {
1397                        default_import = Some(text.to_string());
1398                    }
1399                }
1400                _ => {}
1401            }
1402            if !child_cursor.goto_next_sibling() {
1403                break;
1404            }
1405        }
1406    }
1407
1408    // Classify kind
1409    let kind = if names.is_empty() && default_import.is_none() && namespace_import.is_none() {
1410        ImportKind::SideEffect
1411    } else if is_type_only {
1412        ImportKind::Type
1413    } else {
1414        ImportKind::Value
1415    };
1416
1417    let group = classify_group_ts(&module_path);
1418
1419    let form = ImportForm::Es {
1420        default_import: default_import.clone(),
1421        namespace_import: namespace_import.clone(),
1422        named: names.clone(),
1423        type_only: is_type_only,
1424        side_effect: matches!(kind, ImportKind::SideEffect),
1425    };
1426
1427    Some(ImportStatement {
1428        module_path,
1429        names,
1430        default_import,
1431        namespace_import,
1432        kind,
1433        group,
1434        byte_range,
1435        raw_text,
1436        form,
1437    })
1438}
1439
1440/// Extract the module path string from an import_statement node.
1441///
1442/// Looks for a `string` child node and extracts the content without quotes.
1443fn extract_module_path(source: &str, node: &Node) -> Option<String> {
1444    let mut cursor = node.walk();
1445    if !cursor.goto_first_child() {
1446        return None;
1447    }
1448
1449    loop {
1450        let child = cursor.node();
1451        if child.kind() == "string" {
1452            // Get the text and strip quotes
1453            let text = &source[child.byte_range()];
1454            let stripped = text
1455                .trim_start_matches(|c| c == '\'' || c == '"')
1456                .trim_end_matches(|c| c == '\'' || c == '"');
1457            return Some(stripped.to_string());
1458        }
1459        if !cursor.goto_next_sibling() {
1460            break;
1461        }
1462    }
1463    None
1464}
1465
1466/// Check if the import_statement has a `type` keyword (import type ...).
1467///
1468/// In tree-sitter-typescript, `import type { X } from 'y'` produces a `type`
1469/// node as a direct child of `import_statement`, between `import` and `import_clause`.
1470fn has_type_keyword(node: &Node) -> bool {
1471    let mut cursor = node.walk();
1472    if !cursor.goto_first_child() {
1473        return false;
1474    }
1475
1476    loop {
1477        let child = cursor.node();
1478        if child.kind() == "type" {
1479            return true;
1480        }
1481        if !cursor.goto_next_sibling() {
1482            break;
1483        }
1484    }
1485
1486    false
1487}
1488
1489/// Extract named imports, default import, and namespace import from an import_clause.
1490fn extract_import_clause(
1491    source: &str,
1492    node: &Node,
1493    names: &mut Vec<String>,
1494    default_import: &mut Option<String>,
1495    namespace_import: &mut Option<String>,
1496) {
1497    let mut cursor = node.walk();
1498    if !cursor.goto_first_child() {
1499        return;
1500    }
1501
1502    loop {
1503        let child = cursor.node();
1504        match child.kind() {
1505            "identifier" => {
1506                // This is a default import: `import Foo from 'bar'`
1507                let text = &source[child.byte_range()];
1508                if text != "type" {
1509                    *default_import = Some(text.to_string());
1510                }
1511            }
1512            "named_imports" => {
1513                // `{ name1, name2 }`
1514                extract_named_imports(source, &child, names);
1515            }
1516            "namespace_import" => {
1517                // `* as name`
1518                extract_namespace_import(source, &child, namespace_import);
1519            }
1520            _ => {}
1521        }
1522        if !cursor.goto_next_sibling() {
1523            break;
1524        }
1525    }
1526}
1527
1528/// Extract individual names from a named_imports node (`{ a, b, c }`).
1529///
1530/// Each name is stored verbatim including any alias and per-name `type`
1531/// modifier so the regenerator can round-trip them losslessly. Examples of
1532/// captured forms:
1533///
1534/// - `useState`               (plain)
1535/// - `stdin as input`         (renamed)
1536/// - `type Foo`               (per-specifier type-only)
1537/// - `type Foo as Bar`        (per-specifier type-only with rename)
1538///
1539/// **Why verbatim strings instead of a struct field per attribute:** dedup,
1540/// sort, dropping a single import, and the regenerator are all driven by
1541/// `Vec<String>` today. Encoding the alias inside the string preserves the
1542/// shape so the rest of the pipeline (organize, remove_import, move_symbol)
1543/// keeps working without a workspace-wide refactor. The cost is that callers
1544/// who want the canonical name (e.g. dedup) must compare on the leading
1545/// identifier only — see `extract_canonical_name` if you need that.
1546fn extract_named_imports(source: &str, node: &Node, names: &mut Vec<String>) {
1547    let mut cursor = node.walk();
1548    if !cursor.goto_first_child() {
1549        return;
1550    }
1551
1552    loop {
1553        let child = cursor.node();
1554        if child.kind() == "import_specifier" {
1555            // Capture the full text of the specifier so per-name `type` markers
1556            // and `as alias` clauses are preserved across organize/regenerate
1557            // round-trips. Falls back to the imported name if the specifier
1558            // text is empty for any reason.
1559            let raw = source[child.byte_range()].trim().to_string();
1560            if !raw.is_empty() {
1561                names.push(raw);
1562            } else if let Some(name_node) = child.child_by_field_name("name") {
1563                names.push(source[name_node.byte_range()].to_string());
1564            }
1565        }
1566        if !cursor.goto_next_sibling() {
1567            break;
1568        }
1569    }
1570}
1571
1572/// Extract the alias name from a namespace_import node (`* as name`).
1573fn extract_namespace_import(source: &str, node: &Node, namespace_import: &mut Option<String>) {
1574    let mut cursor = node.walk();
1575    if !cursor.goto_first_child() {
1576        return;
1577    }
1578
1579    loop {
1580        let child = cursor.node();
1581        if child.kind() == "identifier" {
1582            *namespace_import = Some(source[child.byte_range()].to_string());
1583            return;
1584        }
1585        if !cursor.goto_next_sibling() {
1586            break;
1587        }
1588    }
1589}
1590
1591/// Generate an import line for TS/JS/TSX.
1592fn generate_ts_import_line(
1593    module_path: &str,
1594    names: &[String],
1595    default_import: Option<&str>,
1596    namespace_import: Option<&str>,
1597    type_only: bool,
1598) -> String {
1599    let type_prefix = if type_only { "type " } else { "" };
1600
1601    // Side-effect import
1602    if names.is_empty() && default_import.is_none() && namespace_import.is_none() {
1603        return format!("import '{module_path}';");
1604    }
1605
1606    // Namespace import only
1607    if names.is_empty() && default_import.is_none() {
1608        if let Some(namespace) = namespace_import {
1609            return format!("import {type_prefix}* as {namespace} from '{module_path}';");
1610        }
1611    }
1612
1613    // Default + namespace import
1614    if names.is_empty() {
1615        if let (Some(def), Some(namespace)) = (default_import, namespace_import) {
1616            return format!("import {type_prefix}{def}, * as {namespace} from '{module_path}';");
1617        }
1618    }
1619
1620    // Default import only
1621    if names.is_empty() && namespace_import.is_none() {
1622        if let Some(def) = default_import {
1623            return format!("import {type_prefix}{def} from '{module_path}';");
1624        }
1625    }
1626
1627    // Named imports only
1628    if default_import.is_none() && namespace_import.is_none() {
1629        let mut sorted_names = names.to_vec();
1630        sort_named_specifiers(&mut sorted_names);
1631        let names_str = sorted_names.join(", ");
1632        return format!("import {type_prefix}{{ {names_str} }} from '{module_path}';");
1633    }
1634
1635    // Namespace + named imports
1636    if default_import.is_none() {
1637        if let Some(namespace) = namespace_import {
1638            let mut sorted_names = names.to_vec();
1639            sort_named_specifiers(&mut sorted_names);
1640            let names_str = sorted_names.join(", ");
1641            return format!(
1642                "import {type_prefix}{{ {names_str} }}, * as {namespace} from '{module_path}';"
1643            );
1644        }
1645    }
1646
1647    // Default + named + namespace imports
1648    if let (Some(def), Some(namespace)) = (default_import, namespace_import) {
1649        let mut sorted_names = names.to_vec();
1650        sort_named_specifiers(&mut sorted_names);
1651        let names_str = sorted_names.join(", ");
1652        return format!(
1653            "import {type_prefix}{def}, {{ {names_str} }}, * as {namespace} from '{module_path}';"
1654        );
1655    }
1656
1657    // Both default and named imports
1658    if let Some(def) = default_import {
1659        let mut sorted_names = names.to_vec();
1660        sort_named_specifiers(&mut sorted_names);
1661        let names_str = sorted_names.join(", ");
1662        return format!("import {type_prefix}{def}, {{ {names_str} }} from '{module_path}';");
1663    }
1664
1665    // Shouldn't reach here, but handle gracefully
1666    format!("import '{module_path}';")
1667}
1668
1669// ---------------------------------------------------------------------------
1670// Python implementation
1671// ---------------------------------------------------------------------------
1672
1673/// Python 3.x standard library module names (top-level modules).
1674/// Used for import group classification. Covers the commonly-used modules;
1675/// unknown modules are assumed third-party.
1676const PYTHON_STDLIB: &[&str] = &[
1677    "__future__",
1678    "_thread",
1679    "abc",
1680    "aifc",
1681    "argparse",
1682    "array",
1683    "ast",
1684    "asynchat",
1685    "asyncio",
1686    "asyncore",
1687    "atexit",
1688    "audioop",
1689    "base64",
1690    "bdb",
1691    "binascii",
1692    "bisect",
1693    "builtins",
1694    "bz2",
1695    "calendar",
1696    "cgi",
1697    "cgitb",
1698    "chunk",
1699    "cmath",
1700    "cmd",
1701    "code",
1702    "codecs",
1703    "codeop",
1704    "collections",
1705    "colorsys",
1706    "compileall",
1707    "concurrent",
1708    "configparser",
1709    "contextlib",
1710    "contextvars",
1711    "copy",
1712    "copyreg",
1713    "cProfile",
1714    "crypt",
1715    "csv",
1716    "ctypes",
1717    "curses",
1718    "dataclasses",
1719    "datetime",
1720    "dbm",
1721    "decimal",
1722    "difflib",
1723    "dis",
1724    "distutils",
1725    "doctest",
1726    "email",
1727    "encodings",
1728    "enum",
1729    "errno",
1730    "faulthandler",
1731    "fcntl",
1732    "filecmp",
1733    "fileinput",
1734    "fnmatch",
1735    "fractions",
1736    "ftplib",
1737    "functools",
1738    "gc",
1739    "getopt",
1740    "getpass",
1741    "gettext",
1742    "glob",
1743    "grp",
1744    "gzip",
1745    "hashlib",
1746    "heapq",
1747    "hmac",
1748    "html",
1749    "http",
1750    "idlelib",
1751    "imaplib",
1752    "imghdr",
1753    "importlib",
1754    "inspect",
1755    "io",
1756    "ipaddress",
1757    "itertools",
1758    "json",
1759    "keyword",
1760    "lib2to3",
1761    "linecache",
1762    "locale",
1763    "logging",
1764    "lzma",
1765    "mailbox",
1766    "mailcap",
1767    "marshal",
1768    "math",
1769    "mimetypes",
1770    "mmap",
1771    "modulefinder",
1772    "multiprocessing",
1773    "netrc",
1774    "numbers",
1775    "operator",
1776    "optparse",
1777    "os",
1778    "pathlib",
1779    "pdb",
1780    "pickle",
1781    "pickletools",
1782    "pipes",
1783    "pkgutil",
1784    "platform",
1785    "plistlib",
1786    "poplib",
1787    "posixpath",
1788    "pprint",
1789    "profile",
1790    "pstats",
1791    "pty",
1792    "pwd",
1793    "py_compile",
1794    "pyclbr",
1795    "pydoc",
1796    "queue",
1797    "quopri",
1798    "random",
1799    "re",
1800    "readline",
1801    "reprlib",
1802    "resource",
1803    "rlcompleter",
1804    "runpy",
1805    "sched",
1806    "secrets",
1807    "select",
1808    "selectors",
1809    "shelve",
1810    "shlex",
1811    "shutil",
1812    "signal",
1813    "site",
1814    "smtplib",
1815    "sndhdr",
1816    "socket",
1817    "socketserver",
1818    "sqlite3",
1819    "ssl",
1820    "stat",
1821    "statistics",
1822    "string",
1823    "stringprep",
1824    "struct",
1825    "subprocess",
1826    "symtable",
1827    "sys",
1828    "sysconfig",
1829    "syslog",
1830    "tabnanny",
1831    "tarfile",
1832    "tempfile",
1833    "termios",
1834    "textwrap",
1835    "threading",
1836    "time",
1837    "timeit",
1838    "tkinter",
1839    "token",
1840    "tokenize",
1841    "tomllib",
1842    "trace",
1843    "traceback",
1844    "tracemalloc",
1845    "tty",
1846    "turtle",
1847    "types",
1848    "typing",
1849    "unicodedata",
1850    "unittest",
1851    "urllib",
1852    "uuid",
1853    "venv",
1854    "warnings",
1855    "wave",
1856    "weakref",
1857    "webbrowser",
1858    "wsgiref",
1859    "xml",
1860    "xmlrpc",
1861    "zipapp",
1862    "zipfile",
1863    "zipimport",
1864    "zlib",
1865];
1866
1867/// Classify a Python import into a group.
1868pub fn classify_group_py(module_path: &str) -> ImportGroup {
1869    // Relative imports start with '.'
1870    if module_path.starts_with('.') {
1871        return ImportGroup::Internal;
1872    }
1873    // Check stdlib: use the top-level module name (before first '.')
1874    let top_module = module_path.split('.').next().unwrap_or(module_path);
1875    if PYTHON_STDLIB.contains(&top_module) {
1876        ImportGroup::Stdlib
1877    } else {
1878        ImportGroup::External
1879    }
1880}
1881
1882/// Parse imports from a Python file.
1883fn parse_py_imports(source: &str, tree: &Tree) -> ImportBlock {
1884    let root = tree.root_node();
1885    let mut imports = Vec::new();
1886
1887    let mut cursor = root.walk();
1888    if !cursor.goto_first_child() {
1889        return ImportBlock::empty();
1890    }
1891
1892    loop {
1893        let node = cursor.node();
1894        match node.kind() {
1895            "import_statement" => {
1896                if let Some(imp) = parse_py_import_statement(source, &node) {
1897                    imports.push(imp);
1898                }
1899            }
1900            "import_from_statement" => {
1901                if let Some(imp) = parse_py_import_from_statement(source, &node) {
1902                    imports.push(imp);
1903                }
1904            }
1905            _ => {}
1906        }
1907        if !cursor.goto_next_sibling() {
1908            break;
1909        }
1910    }
1911
1912    let byte_range = import_byte_range(&imports);
1913
1914    ImportBlock {
1915        imports,
1916        byte_range,
1917    }
1918}
1919
1920/// Parse `import X` or `import X.Y` Python statements.
1921fn parse_py_import_statement(source: &str, node: &Node) -> Option<ImportStatement> {
1922    let raw_text = source[node.byte_range()].to_string();
1923    let byte_range = node.byte_range();
1924
1925    // Keep every module specifier verbatim. A single Python import statement can
1926    // bind several modules, and aliases must remain in the model so removing or
1927    // merging one binding cannot silently delete its siblings.
1928    let mut specifiers = Vec::new();
1929    let mut c = node.walk();
1930    if c.goto_first_child() {
1931        loop {
1932            let child = c.node();
1933            if matches!(child.kind(), "dotted_name" | "aliased_import") {
1934                specifiers.push(source[child.byte_range()].trim().to_string());
1935            }
1936            if !c.goto_next_sibling() {
1937                break;
1938            }
1939        }
1940    }
1941    let module_path = specifiers
1942        .first()
1943        .map(|specifier| specifier_imported_name(specifier).to_string())?;
1944
1945    let group = classify_group_py(&module_path);
1946
1947    Some(ImportStatement {
1948        module_path,
1949        names: Vec::new(),
1950        default_import: None,
1951        namespace_import: None,
1952        kind: ImportKind::Value,
1953        group,
1954        byte_range,
1955        raw_text,
1956        form: ImportForm::Python {
1957            from_import: false,
1958            named: specifiers,
1959        },
1960    })
1961}
1962
1963/// Parse `from X import Y, Z` or `from . import Y` Python statements.
1964fn parse_py_import_from_statement(source: &str, node: &Node) -> Option<ImportStatement> {
1965    let raw_text = source[node.byte_range()].to_string();
1966    let byte_range = node.byte_range();
1967
1968    let mut module_path = String::new();
1969    let mut names = Vec::new();
1970
1971    let mut c = node.walk();
1972    if c.goto_first_child() {
1973        loop {
1974            let child = c.node();
1975            match child.kind() {
1976                "dotted_name" => {
1977                    // Could be the module name or an imported name
1978                    // The module name comes right after `from`, imported names come after `import`
1979                    // Use position: if we haven't set module_path yet and this comes
1980                    // before the `import` keyword, it's the module.
1981                    if module_path.is_empty()
1982                        && !has_seen_import_keyword(source, node, child.start_byte())
1983                    {
1984                        module_path = source[child.byte_range()].to_string();
1985                    } else {
1986                        // It's an imported name
1987                        names.push(source[child.byte_range()].to_string());
1988                    }
1989                }
1990                "relative_import" => {
1991                    // from . import X or from ..module import X
1992                    module_path = source[child.byte_range()].to_string();
1993                }
1994                "aliased_import" => {
1995                    // Store the full `source as local` spelling so later edits
1996                    // can match either name and reproduce the alias unchanged.
1997                    names.push(source[child.byte_range()].trim().to_string());
1998                }
1999                _ => {}
2000            }
2001            if !c.goto_next_sibling() {
2002                break;
2003            }
2004        }
2005    }
2006
2007    // module_path must be non-empty for a valid import
2008    if module_path.is_empty() {
2009        return None;
2010    }
2011
2012    let group = classify_group_py(&module_path);
2013
2014    Some(ImportStatement {
2015        module_path,
2016        names: names.clone(),
2017        default_import: None,
2018        namespace_import: None,
2019        kind: ImportKind::Value,
2020        group,
2021        byte_range,
2022        raw_text,
2023        form: ImportForm::Python {
2024            from_import: true,
2025            named: names,
2026        },
2027    })
2028}
2029
2030/// Check if the `import` keyword appears before the given byte position in a from...import node.
2031fn has_seen_import_keyword(_source: &str, parent: &Node, before_byte: usize) -> bool {
2032    let mut c = parent.walk();
2033    if c.goto_first_child() {
2034        loop {
2035            let child = c.node();
2036            if child.kind() == "import" && child.start_byte() < before_byte {
2037                return true;
2038            }
2039            if child.start_byte() >= before_byte {
2040                return false;
2041            }
2042            if !c.goto_next_sibling() {
2043                break;
2044            }
2045        }
2046    }
2047    false
2048}
2049
2050/// Generate a Python import line.
2051fn generate_py_import_line(
2052    module_path: &str,
2053    names: &[String],
2054    _default_import: Option<&str>,
2055) -> String {
2056    if names.is_empty() {
2057        // `import module`
2058        format!("import {module_path}")
2059    } else {
2060        // `from module import name1, name2`
2061        let mut sorted = names.to_vec();
2062        sorted.sort();
2063        let names_str = sorted.join(", ");
2064        format!("from {module_path} import {names_str}")
2065    }
2066}
2067
2068// ---------------------------------------------------------------------------
2069// Rust implementation
2070// ---------------------------------------------------------------------------
2071
2072/// Classify a Rust use path into a group.
2073pub fn classify_group_rs(module_path: &str) -> ImportGroup {
2074    // Extract the first path segment (before ::)
2075    let first_seg = module_path.split("::").next().unwrap_or(module_path);
2076    match first_seg {
2077        "std" | "core" | "alloc" => ImportGroup::Stdlib,
2078        "crate" | "self" | "super" => ImportGroup::Internal,
2079        _ => ImportGroup::External,
2080    }
2081}
2082
2083/// Parse imports from a Rust file.
2084fn parse_rs_imports(source: &str, tree: &Tree) -> ImportBlock {
2085    let root = tree.root_node();
2086    let mut imports = Vec::new();
2087
2088    let mut cursor = root.walk();
2089    if !cursor.goto_first_child() {
2090        return ImportBlock::empty();
2091    }
2092
2093    loop {
2094        let node = cursor.node();
2095        if node.kind() == "use_declaration" {
2096            if let Some(imp) = parse_rs_use_declaration(source, &node) {
2097                imports.push(imp);
2098            }
2099        }
2100        if !cursor.goto_next_sibling() {
2101            break;
2102        }
2103    }
2104
2105    let byte_range = import_byte_range(&imports);
2106
2107    ImportBlock {
2108        imports,
2109        byte_range,
2110    }
2111}
2112
2113/// Parse a single `use` declaration from Rust.
2114fn parse_rs_use_declaration(source: &str, node: &Node) -> Option<ImportStatement> {
2115    let raw_text = source[node.byte_range()].to_string();
2116    let byte_range = node.byte_range();
2117
2118    // Capture the EXACT visibility modifier text (`pub`, `pub(crate)`,
2119    // `pub(super)`, `pub(in path)`) so organize re-emits it faithfully instead
2120    // of widening every restricted visibility to a bare `pub`.
2121    let mut visibility: Option<String> = None;
2122    let mut use_path = String::new();
2123    let mut names = Vec::new();
2124
2125    let mut c = node.walk();
2126    if c.goto_first_child() {
2127        loop {
2128            let child = c.node();
2129            match child.kind() {
2130                "visibility_modifier" => {
2131                    visibility = Some(source[child.byte_range()].to_string());
2132                }
2133                "scoped_identifier" | "identifier" | "use_as_clause" => {
2134                    // Full path like `std::collections::HashMap` or just `serde`
2135                    use_path = source[child.byte_range()].to_string();
2136                }
2137                "scoped_use_list" => {
2138                    // e.g. `serde::{Deserialize, Serialize}`
2139                    use_path = source[child.byte_range()].to_string();
2140                    // Also extract the individual names from the use_list
2141                    extract_rs_use_list_names(source, &child, &mut names);
2142                }
2143                _ => {}
2144            }
2145            if !c.goto_next_sibling() {
2146                break;
2147            }
2148        }
2149    }
2150
2151    if use_path.is_empty() {
2152        return None;
2153    }
2154
2155    let group = classify_group_rs(&use_path);
2156
2157    Some(ImportStatement {
2158        module_path: use_path,
2159        names: names.clone(),
2160        // `default_import` carries the visibility text for the Rust engine
2161        // (e.g. "pub", "pub(crate)"). organize re-emits it verbatim.
2162        default_import: visibility.clone(),
2163        namespace_import: None,
2164        kind: ImportKind::Value,
2165        group,
2166        byte_range,
2167        raw_text,
2168        form: ImportForm::RustUse {
2169            visibility,
2170            named: names,
2171        },
2172    })
2173}
2174
2175/// Extract individual names from a Rust `scoped_use_list` node.
2176fn extract_rs_use_list_names(source: &str, node: &Node, names: &mut Vec<String>) {
2177    let mut c = node.walk();
2178    if c.goto_first_child() {
2179        loop {
2180            let child = c.node();
2181            if child.kind() == "use_list" {
2182                // Walk into the use_list to find identifiers
2183                let mut lc = child.walk();
2184                if lc.goto_first_child() {
2185                    loop {
2186                        let lchild = lc.node();
2187                        if lchild.kind() == "identifier" || lchild.kind() == "scoped_identifier" {
2188                            names.push(source[lchild.byte_range()].to_string());
2189                        }
2190                        if !lc.goto_next_sibling() {
2191                            break;
2192                        }
2193                    }
2194                }
2195            }
2196            if !c.goto_next_sibling() {
2197                break;
2198            }
2199        }
2200    }
2201}
2202
2203/// Generate a Rust import line.
2204fn generate_rs_import_line(module_path: &str, names: &[String], _type_only: bool) -> String {
2205    if names.is_empty() {
2206        format!("use {module_path};")
2207    } else {
2208        let mut sorted_names = names.to_vec();
2209        sort_named_specifiers(&mut sorted_names);
2210        format!("use {module_path}::{{{}}};", sorted_names.join(", "))
2211    }
2212}
2213
2214// ---------------------------------------------------------------------------
2215// Go implementation
2216// ---------------------------------------------------------------------------
2217
2218/// Classify a Go import path into a group.
2219pub fn classify_group_go(module_path: &str) -> ImportGroup {
2220    // stdlib paths don't contain dots (e.g., "fmt", "os", "net/http")
2221    // external paths contain dots (e.g., "github.com/pkg/errors")
2222    if module_path.contains('.') {
2223        ImportGroup::External
2224    } else {
2225        ImportGroup::Stdlib
2226    }
2227}
2228
2229/// Parse imports from a Go file.
2230fn parse_go_imports(source: &str, tree: &Tree) -> ImportBlock {
2231    let root = tree.root_node();
2232    let mut imports = Vec::new();
2233
2234    let mut cursor = root.walk();
2235    if !cursor.goto_first_child() {
2236        return ImportBlock::empty();
2237    }
2238
2239    loop {
2240        let node = cursor.node();
2241        if node.kind() == "import_declaration" {
2242            parse_go_import_declaration(source, &node, &mut imports);
2243        }
2244        if !cursor.goto_next_sibling() {
2245            break;
2246        }
2247    }
2248
2249    let byte_range = import_byte_range(&imports);
2250
2251    ImportBlock {
2252        imports,
2253        byte_range,
2254    }
2255}
2256
2257/// Parse a single Go import_declaration (may contain one or multiple specs).
2258fn parse_go_import_declaration(source: &str, node: &Node, imports: &mut Vec<ImportStatement>) {
2259    let mut c = node.walk();
2260    if c.goto_first_child() {
2261        loop {
2262            let child = c.node();
2263            match child.kind() {
2264                "import_spec" => {
2265                    if let Some(imp) = parse_go_import_spec(source, &child) {
2266                        imports.push(imp);
2267                    }
2268                }
2269                "import_spec_list" => {
2270                    // Grouped imports: walk into the list
2271                    let mut lc = child.walk();
2272                    if lc.goto_first_child() {
2273                        loop {
2274                            if lc.node().kind() == "import_spec" {
2275                                if let Some(imp) = parse_go_import_spec(source, &lc.node()) {
2276                                    imports.push(imp);
2277                                }
2278                            }
2279                            if !lc.goto_next_sibling() {
2280                                break;
2281                            }
2282                        }
2283                    }
2284                }
2285                _ => {}
2286            }
2287            if !c.goto_next_sibling() {
2288                break;
2289            }
2290        }
2291    }
2292}
2293
2294/// Parse a single Go import_spec node.
2295fn parse_go_import_spec(source: &str, node: &Node) -> Option<ImportStatement> {
2296    let raw_text = source[node.byte_range()].to_string();
2297    let byte_range = node.byte_range();
2298
2299    let mut import_path = String::new();
2300    let mut alias = None;
2301
2302    let mut c = node.walk();
2303    if c.goto_first_child() {
2304        loop {
2305            let child = c.node();
2306            match child.kind() {
2307                "interpreted_string_literal" => {
2308                    // Extract the path without quotes
2309                    let text = source[child.byte_range()].to_string();
2310                    import_path = text.trim_matches('"').to_string();
2311                }
2312                "identifier" | "blank_identifier" | "dot" => {
2313                    // This is an alias (e.g., `alias "path"` or `. "path"` or `_ "path"`)
2314                    alias = Some(source[child.byte_range()].to_string());
2315                }
2316                _ => {}
2317            }
2318            if !c.goto_next_sibling() {
2319                break;
2320            }
2321        }
2322    }
2323
2324    if import_path.is_empty() {
2325        return None;
2326    }
2327
2328    let group = classify_group_go(&import_path);
2329
2330    Some(ImportStatement {
2331        module_path: import_path,
2332        names: Vec::new(),
2333        default_import: alias.clone(),
2334        namespace_import: None,
2335        kind: ImportKind::Value,
2336        group,
2337        byte_range,
2338        raw_text,
2339        form: ImportForm::Go { alias },
2340    })
2341}
2342
2343/// Public API for Go import line generation (used by add_import handler).
2344pub fn generate_go_import_line_pub(
2345    module_path: &str,
2346    alias: Option<&str>,
2347    in_group: bool,
2348) -> String {
2349    generate_go_import_line(module_path, alias, in_group)
2350}
2351
2352/// Generate a Go import line (public API for command handler).
2353///
2354/// `in_group` controls whether to generate a spec for insertion into an
2355/// existing grouped import (`\t"path"`) or a standalone import (`import "path"`).
2356fn generate_go_import_line(module_path: &str, alias: Option<&str>, in_group: bool) -> String {
2357    if in_group {
2358        // Spec for grouped import block
2359        match alias {
2360            Some(a) => format!("\t{a} \"{module_path}\""),
2361            None => format!("\t\"{module_path}\""),
2362        }
2363    } else {
2364        // Standalone import
2365        match alias {
2366            Some(a) => format!("import {a} \"{module_path}\""),
2367            None => format!("import \"{module_path}\""),
2368        }
2369    }
2370}
2371
2372/// Check if a Go import block has a grouped import declaration.
2373/// Returns the byte range of the full import_declaration if found.
2374pub fn go_has_grouped_import(_source: &str, tree: &Tree) -> Option<Range<usize>> {
2375    let root = tree.root_node();
2376    let mut cursor = root.walk();
2377    if !cursor.goto_first_child() {
2378        return None;
2379    }
2380
2381    loop {
2382        let node = cursor.node();
2383        if node.kind() == "import_declaration" && go_import_declaration_is_grouped(&node) {
2384            return Some(node.byte_range());
2385        }
2386        if !cursor.goto_next_sibling() {
2387            break;
2388        }
2389    }
2390    None
2391}
2392
2393pub fn go_import_declarations_range(_source: &str, tree: &Tree) -> Option<Range<usize>> {
2394    let root = tree.root_node();
2395    let mut cursor = root.walk();
2396    let mut range: Option<Range<usize>> = None;
2397    if !cursor.goto_first_child() {
2398        return None;
2399    }
2400
2401    loop {
2402        let node = cursor.node();
2403        if node.kind() == "import_declaration" {
2404            let node_range = node.byte_range();
2405            range = Some(match range {
2406                Some(existing) => {
2407                    existing.start.min(node_range.start)..existing.end.max(node_range.end)
2408                }
2409                None => node_range,
2410            });
2411        }
2412        if !cursor.goto_next_sibling() {
2413            break;
2414        }
2415    }
2416
2417    range
2418}
2419
2420pub fn go_offset_is_in_grouped_import(_source: &str, tree: &Tree, offset: usize) -> bool {
2421    let root = tree.root_node();
2422    let mut cursor = root.walk();
2423    if !cursor.goto_first_child() {
2424        return false;
2425    }
2426
2427    loop {
2428        let node = cursor.node();
2429        if node.kind() == "import_declaration"
2430            && node.start_byte() < offset
2431            && offset < node.end_byte()
2432            && go_import_declaration_is_grouped(&node)
2433        {
2434            return true;
2435        }
2436        if !cursor.goto_next_sibling() {
2437            break;
2438        }
2439    }
2440
2441    false
2442}
2443
2444fn go_import_declaration_is_grouped(node: &Node) -> bool {
2445    let mut c = node.walk();
2446    if c.goto_first_child() {
2447        loop {
2448            if c.node().kind() == "import_spec_list" {
2449                return true;
2450            }
2451            if !c.goto_next_sibling() {
2452                break;
2453            }
2454        }
2455    }
2456    false
2457}
2458
2459// ---------------------------------------------------------------------------
2460// Solidity implementation
2461// ---------------------------------------------------------------------------
2462
2463/// Classify a Solidity import path: relative (`./`, `../`) is internal,
2464/// everything else (remappings, `@scope/...`, bare) is external. No stdlib.
2465pub fn classify_group_solidity(module_path: &str) -> ImportGroup {
2466    if module_path.starts_with('.') {
2467        ImportGroup::Internal
2468    } else {
2469        ImportGroup::External
2470    }
2471}
2472
2473fn parse_solidity_imports(source: &str, tree: &Tree) -> ImportBlock {
2474    let root = tree.root_node();
2475    let mut imports = Vec::new();
2476    let mut cursor = root.walk();
2477    if cursor.goto_first_child() {
2478        loop {
2479            let node = cursor.node();
2480            if node.kind() == "import_directive" {
2481                if let Some(imp) = parse_solidity_import_directive(source, &node) {
2482                    imports.push(imp);
2483                }
2484            }
2485            if !cursor.goto_next_sibling() {
2486                break;
2487            }
2488        }
2489    }
2490    let byte_range = import_byte_range(&imports);
2491    ImportBlock {
2492        imports,
2493        byte_range,
2494    }
2495}
2496
2497fn strip_one_matching_solidity_quote_pair(value: &str) -> &str {
2498    if value.len() < 2 {
2499        return value;
2500    }
2501
2502    let bytes = value.as_bytes();
2503    let quote = bytes[0];
2504    if matches!(quote, b'\'' | b'"') && bytes.last() == Some(&quote) {
2505        &value[1..value.len() - 1]
2506    } else {
2507        value
2508    }
2509}
2510
2511/// Parse one `import_directive`. The Solidity grammar emits a flat token
2512/// sequence (verified by grammar fixture test), so the four forms are
2513/// distinguished by the presence of `{` (named), `*` (namespace), a trailing
2514/// `as` (whole-file alias), or none (side-effect).
2515fn parse_solidity_import_directive(source: &str, node: &Node) -> Option<ImportStatement> {
2516    let raw_text = source[node.byte_range()].to_string();
2517    let byte_range = node.byte_range();
2518
2519    let mut children: Vec<(String, String)> = Vec::new();
2520    let mut c = node.walk();
2521    if c.goto_first_child() {
2522        loop {
2523            let ch = c.node();
2524            children.push((ch.kind().to_string(), source[ch.byte_range()].to_string()));
2525            if !c.goto_next_sibling() {
2526                break;
2527            }
2528        }
2529    }
2530
2531    // Every form carries exactly one string literal: the imported file path.
2532    let module_path = children
2533        .iter()
2534        .find(|(k, _)| k == "string")
2535        .map(|(_, text)| strip_one_matching_solidity_quote_pair(text).to_string())?;
2536    if module_path.is_empty() {
2537        return None;
2538    }
2539
2540    let has_brace = children.iter().any(|(k, _)| k == "{");
2541    let has_star = children.iter().any(|(k, _)| k == "*");
2542
2543    let mut named: Vec<String> = Vec::new();
2544    let mut namespace: Option<String> = None;
2545    let mut alias: Option<String> = None;
2546
2547    if has_brace {
2548        named = parse_solidity_named_specifiers(&children);
2549    } else if has_star {
2550        namespace = solidity_identifier_after_as(&children);
2551    } else {
2552        // No `{`, no `*`: a trailing `as IDENT` is a whole-file alias;
2553        // otherwise a bare side-effect import.
2554        alias = solidity_identifier_after_as(&children);
2555    }
2556
2557    let kind = if named.is_empty() && namespace.is_none() && alias.is_none() {
2558        ImportKind::SideEffect
2559    } else {
2560        ImportKind::Value
2561    };
2562    let group = classify_group_solidity(&module_path);
2563
2564    Some(ImportStatement {
2565        module_path,
2566        names: named.clone(),
2567        default_import: None,
2568        // Namespace maps to the flat slot so existing readers (dedup) see it;
2569        // the whole-file alias has no flat slot and lives only in `form`.
2570        namespace_import: namespace.clone(),
2571        kind,
2572        group,
2573        byte_range,
2574        raw_text,
2575        form: ImportForm::Solidity {
2576            named,
2577            namespace,
2578            alias,
2579        },
2580    })
2581}
2582
2583/// Return the `identifier` token immediately following the first `as`.
2584fn solidity_identifier_after_as(children: &[(String, String)]) -> Option<String> {
2585    let as_pos = children.iter().position(|(k, _)| k == "as")?;
2586    children[as_pos + 1..]
2587        .iter()
2588        .find(|(k, _)| k == "identifier")
2589        .map(|(_, t)| t.clone())
2590}
2591
2592/// Collect named specifiers between `{` and `}` into verbatim strings,
2593/// combining `A as B` into `"A as B"` to match the ES specifier convention.
2594fn parse_solidity_named_specifiers(children: &[(String, String)]) -> Vec<String> {
2595    let mut names = Vec::new();
2596    let mut in_braces = false;
2597    let mut current: Option<String> = None;
2598    let mut expect_alias = false;
2599    for (k, t) in children {
2600        match k.as_str() {
2601            "{" => in_braces = true,
2602            "}" => {
2603                if let Some(n) = current.take() {
2604                    names.push(n);
2605                }
2606                in_braces = false;
2607            }
2608            _ if !in_braces => {}
2609            "identifier" => {
2610                if expect_alias {
2611                    if let Some(n) = current.take() {
2612                        names.push(format!("{n} as {t}"));
2613                    }
2614                    expect_alias = false;
2615                } else {
2616                    if let Some(n) = current.take() {
2617                        names.push(n);
2618                    }
2619                    current = Some(t.clone());
2620                }
2621            }
2622            "as" => expect_alias = true,
2623            "," => {
2624                if let Some(n) = current.take() {
2625                    names.push(n);
2626                }
2627                expect_alias = false;
2628            }
2629            _ => {}
2630        }
2631    }
2632    names
2633}
2634
2635/// Generate a Solidity import line in the appropriate form.
2636fn generate_solidity_import_line(req: &ImportRequest) -> String {
2637    if !req.names.is_empty() {
2638        format!(
2639            "import {{ {} }} from \"{}\";",
2640            req.names.join(", "),
2641            req.module_path
2642        )
2643    } else if let Some(ns) = req.namespace {
2644        format!("import * as {} from \"{}\";", ns, req.module_path)
2645    } else if let Some(al) = req.alias {
2646        format!("import \"{}\" as {};", req.module_path, al)
2647    } else {
2648        format!("import \"{}\";", req.module_path)
2649    }
2650}
2651
2652/// Skip past a newline character at the given position.
2653fn skip_newline(source: &str, pos: usize) -> usize {
2654    if pos < source.len() {
2655        let bytes = source.as_bytes();
2656        if bytes[pos] == b'\n' {
2657            return pos + 1;
2658        }
2659        if bytes[pos] == b'\r' {
2660            if pos + 1 < source.len() && bytes[pos + 1] == b'\n' {
2661                return pos + 2;
2662            }
2663            return pos + 1;
2664        }
2665    }
2666    pos
2667}
2668
2669// ---------------------------------------------------------------------------
2670// Unit tests
2671// ---------------------------------------------------------------------------
2672
2673#[cfg(test)]
2674mod tests {
2675    use super::*;
2676
2677    // --- ImportForm field-mapping contract (Stream M) ---
2678    //
2679    // These assert the additive `form` field faithfully mirrors the flat
2680    // fields each parser populates. They are the executable field-mapping
2681    // contract from the migration plan: when a reader is moved off a flat
2682    // field onto `form`, these guarantee no information was lost in the
2683    // de-overloading (Rust `pub`, Go alias) or restructuring.
2684
2685    #[test]
2686    fn form_es_mirrors_flat_fields() {
2687        let (_, block) = parse_ts(
2688            "import Default, { a, b as c } from \"ext\";\nimport type { T } from \"./t\";\nimport \"./side\";\nimport * as ns from \"nspkg\";\n",
2689        );
2690        // import Default, { a, b as c } from "ext"
2691        match &block.imports[0].form {
2692            ImportForm::Es {
2693                default_import,
2694                namespace_import,
2695                named,
2696                type_only,
2697                side_effect,
2698            } => {
2699                assert_eq!(default_import.as_deref(), Some("Default"));
2700                assert_eq!(namespace_import, &None);
2701                assert_eq!(named, &block.imports[0].names);
2702                assert!(!type_only);
2703                assert!(!side_effect);
2704            }
2705            other => panic!("expected Es, got {other:?}"),
2706        }
2707        // import type { T } from "./t"
2708        match &block.imports[1].form {
2709            ImportForm::Es {
2710                type_only, named, ..
2711            } => {
2712                assert!(type_only);
2713                assert_eq!(named, &block.imports[1].names);
2714            }
2715            other => panic!("expected Es type-only, got {other:?}"),
2716        }
2717        // import "./side"
2718        match &block.imports[2].form {
2719            ImportForm::Es { side_effect, .. } => assert!(side_effect),
2720            other => panic!("expected Es side-effect, got {other:?}"),
2721        }
2722        // import * as ns from "nspkg"
2723        match &block.imports[3].form {
2724            ImportForm::Es {
2725                namespace_import, ..
2726            } => assert_eq!(namespace_import.as_deref(), Some("ns")),
2727            other => panic!("expected Es namespace, got {other:?}"),
2728        }
2729    }
2730
2731    #[test]
2732    fn form_python_mirrors_flat_fields() {
2733        let (_, block) = parse_py("import os\nfrom sys import argv, path\n");
2734        match &block.imports[0].form {
2735            ImportForm::Python { from_import, named } => {
2736                assert!(!from_import, "`import os` is not a from-import");
2737                assert_eq!(named, &["os"]);
2738            }
2739            other => panic!("expected Python import, got {other:?}"),
2740        }
2741        match &block.imports[1].form {
2742            ImportForm::Python { from_import, named } => {
2743                assert!(from_import, "`from sys import ...` is a from-import");
2744                assert_eq!(named, &block.imports[1].names);
2745            }
2746            other => panic!("expected Python from-import, got {other:?}"),
2747        }
2748    }
2749
2750    #[test]
2751    fn form_rust_de_overloads_pub_from_default_import() {
2752        let (_, block) = parse_rust("pub use crate::a::Exported;\nuse std::fmt::Debug;\n");
2753        // pub use -> visibility=Some("pub"); flat field still carries the "pub" hack.
2754        match &block.imports[0].form {
2755            ImportForm::RustUse { visibility, named } => {
2756                assert_eq!(visibility.as_deref(), Some("pub"));
2757                assert_eq!(named, &block.imports[0].names);
2758            }
2759            other => panic!("expected RustUse, got {other:?}"),
2760        }
2761        assert_eq!(
2762            block.imports[0].default_import.as_deref(),
2763            Some("pub"),
2764            "flat field unchanged during additive migration"
2765        );
2766        // plain use -> visibility=None
2767        match &block.imports[1].form {
2768            ImportForm::RustUse { visibility, .. } => assert_eq!(visibility, &None),
2769            other => panic!("expected RustUse, got {other:?}"),
2770        }
2771        assert_eq!(block.imports[1].default_import, None);
2772    }
2773
2774    #[test]
2775    fn form_go_de_overloads_alias_from_default_import() {
2776        // The current Go parser only captures blank (`_`) / dot bindings as the
2777        // alias (regular package aliases like `al "path"` are a pre-existing
2778        // parser gap — not extracted into `default_import` today). The contract
2779        // locked here is that `form.alias` mirrors `default_import` exactly,
2780        // whatever the parser captures, so the de-overload is information-faithful.
2781        let (_, block) =
2782            parse_go("package main\n\nimport (\n\t_ \"github.com/x/y\"\n\t\"fmt\"\n)\n");
2783        let blank = block
2784            .imports
2785            .iter()
2786            .find(|i| i.module_path == "github.com/x/y")
2787            .expect("blank import parsed");
2788        match &blank.form {
2789            ImportForm::Go { alias } => assert_eq!(alias.as_deref(), Some("_")),
2790            other => panic!("expected Go blank-aliased, got {other:?}"),
2791        }
2792        assert_eq!(
2793            blank.default_import.as_deref(),
2794            Some("_"),
2795            "form.alias mirrors the flat default_import field exactly"
2796        );
2797        let plain = block
2798            .imports
2799            .iter()
2800            .find(|i| i.module_path == "fmt")
2801            .expect("plain import parsed");
2802        match &plain.form {
2803            ImportForm::Go { alias } => assert_eq!(alias, &None),
2804            other => panic!("expected Go plain, got {other:?}"),
2805        }
2806        assert_eq!(plain.default_import, None);
2807    }
2808
2809    fn parse_ts(source: &str) -> (Tree, ImportBlock) {
2810        let grammar = grammar_for(LangId::TypeScript);
2811        let mut parser = Parser::new();
2812        parser.set_language(&grammar).unwrap();
2813        let tree = parser.parse(source, None).unwrap();
2814        let block = parse_imports(source, &tree, LangId::TypeScript);
2815        (tree, block)
2816    }
2817
2818    fn parse_js(source: &str) -> (Tree, ImportBlock) {
2819        let grammar = grammar_for(LangId::JavaScript);
2820        let mut parser = Parser::new();
2821        parser.set_language(&grammar).unwrap();
2822        let tree = parser.parse(source, None).unwrap();
2823        let block = parse_imports(source, &tree, LangId::JavaScript);
2824        (tree, block)
2825    }
2826
2827    fn parse_vue(source: &str) -> (Tree, ImportBlock) {
2828        let grammar = grammar_for(LangId::Vue);
2829        let mut parser = Parser::new();
2830        parser.set_language(&grammar).unwrap();
2831        let tree = parser.parse(source, None).unwrap();
2832        let block = parse_imports(source, &tree, LangId::Vue);
2833        (tree, block)
2834    }
2835
2836    /// Locks the tree-sitter-vue node kinds the Vue engine depends on: the
2837    /// `<script>` body is exposed as a single `raw_text` node inside a
2838    /// `script_element`. If a grammar bump changes this, the engine breaks
2839    /// silently, so assert it here.
2840    #[test]
2841    fn vue_grammar_node_kinds_are_stable() {
2842        let src = "<template>\n  <div />\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\n</script>\n";
2843        let grammar = grammar_for(LangId::Vue);
2844        let mut parser = Parser::new();
2845        parser.set_language(&grammar).unwrap();
2846        let tree = parser.parse(src, None).unwrap();
2847        let root = tree.root_node();
2848        let mut cursor = root.walk();
2849        let script = root
2850            .named_children(&mut cursor)
2851            .find(|n| n.kind() == "script_element")
2852            .expect("expected a script_element node");
2853        let mut inner = script.walk();
2854        assert!(
2855            script
2856                .named_children(&mut inner)
2857                .any(|n| n.kind() == "raw_text"),
2858            "expected script body exposed as raw_text"
2859        );
2860    }
2861
2862    #[test]
2863    fn vue_parses_script_imports_with_whole_file_offsets() {
2864        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";
2865        let (_tree, block) = parse_vue(src);
2866        assert_eq!(block.imports.len(), 2, "should find both script imports");
2867        // Byte ranges must be whole-file (inside the <script> block), not
2868        // script-relative — verify the raw slice round-trips.
2869        for imp in &block.imports {
2870            assert_eq!(&src[imp.byte_range.clone()], imp.raw_text);
2871            assert!(
2872                imp.byte_range.start > src.find("<script").unwrap(),
2873                "import offset must fall inside the script block"
2874            );
2875        }
2876        assert_eq!(block.imports[0].module_path, "vue");
2877        assert_eq!(block.imports[1].module_path, "./Foo.vue");
2878    }
2879
2880    #[test]
2881    fn vue_without_script_block_has_no_imports() {
2882        let src = "<template>\n  <div />\n</template>\n\n<style>.x{}</style>\n";
2883        let (_tree, block) = parse_vue(src);
2884        assert!(block.imports.is_empty());
2885        assert!(block.byte_range.is_none());
2886    }
2887
2888    // --- Basic parsing ---
2889
2890    #[test]
2891    fn parse_ts_named_imports() {
2892        let source = "import { useState, useEffect } from 'react';\n";
2893        let (_, block) = parse_ts(source);
2894        assert_eq!(block.imports.len(), 1);
2895        let imp = &block.imports[0];
2896        assert_eq!(imp.module_path, "react");
2897        assert!(imp.names.contains(&"useState".to_string()));
2898        assert!(imp.names.contains(&"useEffect".to_string()));
2899        assert_eq!(imp.kind, ImportKind::Value);
2900        assert_eq!(imp.group, ImportGroup::External);
2901    }
2902
2903    #[test]
2904    fn parse_ts_default_import() {
2905        let source = "import React from 'react';\n";
2906        let (_, block) = parse_ts(source);
2907        assert_eq!(block.imports.len(), 1);
2908        let imp = &block.imports[0];
2909        assert_eq!(imp.default_import.as_deref(), Some("React"));
2910        assert_eq!(imp.kind, ImportKind::Value);
2911    }
2912
2913    #[test]
2914    fn parse_ts_side_effect_import() {
2915        let source = "import './styles.css';\n";
2916        let (_, block) = parse_ts(source);
2917        assert_eq!(block.imports.len(), 1);
2918        assert_eq!(block.imports[0].kind, ImportKind::SideEffect);
2919        assert_eq!(block.imports[0].module_path, "./styles.css");
2920    }
2921
2922    #[test]
2923    fn parse_ts_relative_import() {
2924        let source = "import { helper } from './utils';\n";
2925        let (_, block) = parse_ts(source);
2926        assert_eq!(block.imports.len(), 1);
2927        assert_eq!(block.imports[0].group, ImportGroup::Internal);
2928    }
2929
2930    #[test]
2931    fn parse_ts_multiple_groups() {
2932        let source = "\
2933import React from 'react';
2934import { useState } from 'react';
2935import { helper } from './utils';
2936import { Config } from '../config';
2937";
2938        let (_, block) = parse_ts(source);
2939        assert_eq!(block.imports.len(), 4);
2940
2941        let external: Vec<_> = block
2942            .imports
2943            .iter()
2944            .filter(|i| i.group == ImportGroup::External)
2945            .collect();
2946        let relative: Vec<_> = block
2947            .imports
2948            .iter()
2949            .filter(|i| i.group == ImportGroup::Internal)
2950            .collect();
2951        assert_eq!(external.len(), 2);
2952        assert_eq!(relative.len(), 2);
2953    }
2954
2955    #[test]
2956    fn parse_ts_namespace_import() {
2957        let source = "import * as path from 'path';\n";
2958        let (_, block) = parse_ts(source);
2959        assert_eq!(block.imports.len(), 1);
2960        let imp = &block.imports[0];
2961        assert_eq!(imp.namespace_import.as_deref(), Some("path"));
2962        assert_eq!(imp.kind, ImportKind::Value);
2963    }
2964
2965    #[test]
2966    fn parse_js_imports() {
2967        let source = "import { readFile } from 'fs';\nimport { helper } from './helper';\n";
2968        let (_, block) = parse_js(source);
2969        assert_eq!(block.imports.len(), 2);
2970        assert_eq!(block.imports[0].group, ImportGroup::External);
2971        assert_eq!(block.imports[1].group, ImportGroup::Internal);
2972    }
2973
2974    // --- Group classification ---
2975
2976    #[test]
2977    fn classify_external() {
2978        assert_eq!(classify_group_ts("react"), ImportGroup::External);
2979        assert_eq!(classify_group_ts("@scope/pkg"), ImportGroup::External);
2980        assert_eq!(classify_group_ts("lodash/map"), ImportGroup::External);
2981    }
2982
2983    #[test]
2984    fn classify_relative() {
2985        assert_eq!(classify_group_ts("./utils"), ImportGroup::Internal);
2986        assert_eq!(classify_group_ts("../config"), ImportGroup::Internal);
2987        assert_eq!(classify_group_ts("./"), ImportGroup::Internal);
2988    }
2989
2990    // --- Dedup ---
2991
2992    #[test]
2993    fn dedup_detects_same_named_import() {
2994        let source = "import { useState } from 'react';\n";
2995        let (_, block) = parse_ts(source);
2996        assert!(is_duplicate(
2997            &block,
2998            "react",
2999            &["useState".to_string()],
3000            None,
3001            false
3002        ));
3003    }
3004
3005    #[test]
3006    fn dedup_misses_different_name() {
3007        let source = "import { useState } from 'react';\n";
3008        let (_, block) = parse_ts(source);
3009        assert!(!is_duplicate(
3010            &block,
3011            "react",
3012            &["useEffect".to_string()],
3013            None,
3014            false
3015        ));
3016    }
3017
3018    #[test]
3019    fn dedup_detects_default_import() {
3020        let source = "import React from 'react';\n";
3021        let (_, block) = parse_ts(source);
3022        assert!(is_duplicate(&block, "react", &[], Some("React"), false));
3023    }
3024
3025    #[test]
3026    fn dedup_side_effect() {
3027        let source = "import './styles.css';\n";
3028        let (_, block) = parse_ts(source);
3029        assert!(is_duplicate(&block, "./styles.css", &[], None, false));
3030    }
3031
3032    #[test]
3033    fn dedup_namespace_import_distinct_from_side_effect_import() {
3034        let side_effect_source = "import 'fs';\n";
3035        let (_, side_effect_block) = parse_ts(side_effect_source);
3036        assert!(!is_duplicate_with_namespace(
3037            &side_effect_block,
3038            "fs",
3039            &[],
3040            None,
3041            Some("fs"),
3042            false
3043        ));
3044
3045        let namespace_source = "import * as fs from 'fs';\n";
3046        let (_, namespace_block) = parse_ts(namespace_source);
3047        assert!(!is_duplicate(&namespace_block, "fs", &[], None, false));
3048        assert!(is_duplicate_with_namespace(
3049            &namespace_block,
3050            "fs",
3051            &[],
3052            None,
3053            Some("fs"),
3054            false
3055        ));
3056        assert!(!is_duplicate_with_namespace(
3057            &namespace_block,
3058            "fs",
3059            &[],
3060            None,
3061            Some("other"),
3062            false
3063        ));
3064    }
3065
3066    #[test]
3067    fn dedup_type_vs_value() {
3068        let source = "import { FC } from 'react';\n";
3069        let (_, block) = parse_ts(source);
3070        // Type import should NOT match a value import of the same name
3071        assert!(!is_duplicate(
3072            &block,
3073            "react",
3074            &["FC".to_string()],
3075            None,
3076            true
3077        ));
3078    }
3079
3080    // --- Generation ---
3081
3082    #[test]
3083    fn generate_named_import() {
3084        let line = generate_import_line(
3085            LangId::TypeScript,
3086            "react",
3087            &["useState".to_string(), "useEffect".to_string()],
3088            None,
3089            false,
3090        );
3091        assert_eq!(line, "import { useEffect, useState } from 'react';");
3092    }
3093
3094    #[test]
3095    fn generate_named_import_sorts_by_imported_name() {
3096        let line = generate_import_line(
3097            LangId::TypeScript,
3098            "x",
3099            &[
3100                "useState".to_string(),
3101                "type Foo".to_string(),
3102                "stdin as input".to_string(),
3103                "type Bar".to_string(),
3104            ],
3105            None,
3106            false,
3107        );
3108        assert_eq!(
3109            line,
3110            "import { type Bar, type Foo, stdin as input, useState } from 'x';"
3111        );
3112    }
3113
3114    #[test]
3115    fn generate_default_import() {
3116        let line = generate_import_line(LangId::TypeScript, "react", &[], Some("React"), false);
3117        assert_eq!(line, "import React from 'react';");
3118    }
3119
3120    #[test]
3121    fn generate_type_import() {
3122        let line =
3123            generate_import_line(LangId::TypeScript, "react", &["FC".to_string()], None, true);
3124        assert_eq!(line, "import type { FC } from 'react';");
3125    }
3126
3127    #[test]
3128    fn generate_side_effect_import() {
3129        let line = generate_import_line(LangId::TypeScript, "./styles.css", &[], None, false);
3130        assert_eq!(line, "import './styles.css';");
3131    }
3132
3133    #[test]
3134    fn generate_default_and_named() {
3135        let line = generate_import_line(
3136            LangId::TypeScript,
3137            "react",
3138            &["useState".to_string()],
3139            Some("React"),
3140            false,
3141        );
3142        assert_eq!(line, "import React, { useState } from 'react';");
3143    }
3144
3145    #[test]
3146    fn parse_ts_type_import() {
3147        let source = "import type { FC } from 'react';\n";
3148        let (_, block) = parse_ts(source);
3149        assert_eq!(block.imports.len(), 1);
3150        let imp = &block.imports[0];
3151        assert_eq!(imp.kind, ImportKind::Type);
3152        assert!(imp.names.contains(&"FC".to_string()));
3153        assert_eq!(imp.group, ImportGroup::External);
3154    }
3155
3156    // --- Insertion point ---
3157
3158    #[test]
3159    fn insertion_empty_file() {
3160        let source = "";
3161        let (_, block) = parse_ts(source);
3162        let (offset, _, _) =
3163            find_insertion_point(source, &block, ImportGroup::External, "react", false);
3164        assert_eq!(offset, 0);
3165    }
3166
3167    #[test]
3168    fn insertion_alphabetical_within_group() {
3169        let source = "\
3170import { a } from 'alpha';
3171import { c } from 'charlie';
3172";
3173        let (_, block) = parse_ts(source);
3174        let (offset, _, _) =
3175            find_insertion_point(source, &block, ImportGroup::External, "bravo", false);
3176        // Should insert before 'charlie' (which starts at line 2)
3177        let before_charlie = source.find("import { c }").unwrap();
3178        assert_eq!(offset, before_charlie);
3179    }
3180
3181    // --- Python parsing ---
3182
3183    fn parse_py(source: &str) -> (Tree, ImportBlock) {
3184        let grammar = grammar_for(LangId::Python);
3185        let mut parser = Parser::new();
3186        parser.set_language(&grammar).unwrap();
3187        let tree = parser.parse(source, None).unwrap();
3188        let block = parse_imports(source, &tree, LangId::Python);
3189        (tree, block)
3190    }
3191
3192    #[test]
3193    fn parse_py_import_statement() {
3194        let source = "import os\nimport sys\n";
3195        let (_, block) = parse_py(source);
3196        assert_eq!(block.imports.len(), 2);
3197        assert_eq!(block.imports[0].module_path, "os");
3198        assert_eq!(block.imports[1].module_path, "sys");
3199        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3200    }
3201
3202    #[test]
3203    fn parse_py_import_statement_preserves_aliases_and_siblings() {
3204        let source = "import alpha, beta as local_beta\n";
3205        let (_, block) = parse_py(source);
3206        let imp = &block.imports[0];
3207        assert_eq!(imp.module_path, "alpha");
3208        assert!(imp.names.is_empty());
3209        match &imp.form {
3210            ImportForm::Python { from_import, named } => {
3211                assert!(!from_import);
3212                assert_eq!(named, &["alpha", "beta as local_beta"]);
3213                assert!(specifier_matches(&named[1], "beta"));
3214                assert!(specifier_matches(&named[1], "local_beta"));
3215            }
3216            other => panic!("expected Python import, got {other:?}"),
3217        }
3218    }
3219
3220    #[test]
3221    fn parse_py_from_import() {
3222        let source = "from collections import OrderedDict\nfrom typing import List, Optional\n";
3223        let (_, block) = parse_py(source);
3224        assert_eq!(block.imports.len(), 2);
3225        assert_eq!(block.imports[0].module_path, "collections");
3226        assert!(block.imports[0].names.contains(&"OrderedDict".to_string()));
3227        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3228        assert_eq!(block.imports[1].module_path, "typing");
3229        assert!(block.imports[1].names.contains(&"List".to_string()));
3230        assert!(block.imports[1].names.contains(&"Optional".to_string()));
3231    }
3232
3233    #[test]
3234    fn parse_py_from_import_preserves_aliases() {
3235        let source = "from module import alpha, beta as local_beta\n";
3236        let (_, block) = parse_py(source);
3237        let imp = &block.imports[0];
3238        assert_eq!(imp.names, ["alpha", "beta as local_beta"]);
3239        assert!(specifier_matches(&imp.names[1], "beta"));
3240        assert!(specifier_matches(&imp.names[1], "local_beta"));
3241        assert_eq!(
3242            imp.form,
3243            ImportForm::Python {
3244                from_import: true,
3245                named: vec!["alpha".to_string(), "beta as local_beta".to_string()],
3246            }
3247        );
3248    }
3249
3250    #[test]
3251    fn parse_py_relative_import() {
3252        let source = "from . import utils\nfrom ..config import Settings\n";
3253        let (_, block) = parse_py(source);
3254        assert_eq!(block.imports.len(), 2);
3255        assert_eq!(block.imports[0].module_path, ".");
3256        assert!(block.imports[0].names.contains(&"utils".to_string()));
3257        assert_eq!(block.imports[0].group, ImportGroup::Internal);
3258        assert_eq!(block.imports[1].module_path, "..config");
3259        assert_eq!(block.imports[1].group, ImportGroup::Internal);
3260    }
3261
3262    #[test]
3263    fn classify_py_groups() {
3264        assert_eq!(classify_group_py("os"), ImportGroup::Stdlib);
3265        assert_eq!(classify_group_py("sys"), ImportGroup::Stdlib);
3266        assert_eq!(classify_group_py("json"), ImportGroup::Stdlib);
3267        assert_eq!(classify_group_py("collections"), ImportGroup::Stdlib);
3268        assert_eq!(classify_group_py("os.path"), ImportGroup::Stdlib);
3269        assert_eq!(classify_group_py("requests"), ImportGroup::External);
3270        assert_eq!(classify_group_py("flask"), ImportGroup::External);
3271        assert_eq!(classify_group_py("."), ImportGroup::Internal);
3272        assert_eq!(classify_group_py("..config"), ImportGroup::Internal);
3273        assert_eq!(classify_group_py(".utils"), ImportGroup::Internal);
3274    }
3275
3276    #[test]
3277    fn parse_py_three_groups() {
3278        let source = "import os\nimport sys\n\nimport requests\n\nfrom . import utils\n";
3279        let (_, block) = parse_py(source);
3280        let stdlib: Vec<_> = block
3281            .imports
3282            .iter()
3283            .filter(|i| i.group == ImportGroup::Stdlib)
3284            .collect();
3285        let external: Vec<_> = block
3286            .imports
3287            .iter()
3288            .filter(|i| i.group == ImportGroup::External)
3289            .collect();
3290        let internal: Vec<_> = block
3291            .imports
3292            .iter()
3293            .filter(|i| i.group == ImportGroup::Internal)
3294            .collect();
3295        assert_eq!(stdlib.len(), 2);
3296        assert_eq!(external.len(), 1);
3297        assert_eq!(internal.len(), 1);
3298    }
3299
3300    #[test]
3301    fn generate_py_import() {
3302        let line = generate_import_line(LangId::Python, "os", &[], None, false);
3303        assert_eq!(line, "import os");
3304    }
3305
3306    #[test]
3307    fn generate_py_from_import() {
3308        let line = generate_import_line(
3309            LangId::Python,
3310            "collections",
3311            &["OrderedDict".to_string()],
3312            None,
3313            false,
3314        );
3315        assert_eq!(line, "from collections import OrderedDict");
3316    }
3317
3318    #[test]
3319    fn generate_py_from_import_multiple() {
3320        let line = generate_import_line(
3321            LangId::Python,
3322            "typing",
3323            &["Optional".to_string(), "List".to_string()],
3324            None,
3325            false,
3326        );
3327        assert_eq!(line, "from typing import List, Optional");
3328    }
3329
3330    // --- Rust parsing ---
3331
3332    fn parse_rust(source: &str) -> (Tree, ImportBlock) {
3333        let grammar = grammar_for(LangId::Rust);
3334        let mut parser = Parser::new();
3335        parser.set_language(&grammar).unwrap();
3336        let tree = parser.parse(source, None).unwrap();
3337        let block = parse_imports(source, &tree, LangId::Rust);
3338        (tree, block)
3339    }
3340
3341    #[test]
3342    fn parse_rs_use_std() {
3343        let source = "use std::collections::HashMap;\nuse std::io::Read;\n";
3344        let (_, block) = parse_rust(source);
3345        assert_eq!(block.imports.len(), 2);
3346        assert_eq!(block.imports[0].module_path, "std::collections::HashMap");
3347        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3348        assert_eq!(block.imports[1].group, ImportGroup::Stdlib);
3349    }
3350
3351    #[test]
3352    fn parse_rs_use_external() {
3353        let source = "use serde::{Deserialize, Serialize};\n";
3354        let (_, block) = parse_rust(source);
3355        assert_eq!(block.imports.len(), 1);
3356        assert_eq!(block.imports[0].group, ImportGroup::External);
3357        assert!(block.imports[0].names.contains(&"Deserialize".to_string()));
3358        assert!(block.imports[0].names.contains(&"Serialize".to_string()));
3359    }
3360
3361    #[test]
3362    fn parse_rs_use_crate() {
3363        let source = "use crate::config::Settings;\nuse super::parent::Thing;\n";
3364        let (_, block) = parse_rust(source);
3365        assert_eq!(block.imports.len(), 2);
3366        assert_eq!(block.imports[0].group, ImportGroup::Internal);
3367        assert_eq!(block.imports[1].group, ImportGroup::Internal);
3368    }
3369
3370    #[test]
3371    fn parse_rs_pub_use() {
3372        let source = "pub use super::parent::Thing;\n";
3373        let (_, block) = parse_rust(source);
3374        assert_eq!(block.imports.len(), 1);
3375        // `pub` is stored in default_import as a marker
3376        assert_eq!(block.imports[0].default_import.as_deref(), Some("pub"));
3377    }
3378
3379    #[test]
3380    fn classify_rs_groups() {
3381        assert_eq!(
3382            classify_group_rs("std::collections::HashMap"),
3383            ImportGroup::Stdlib
3384        );
3385        assert_eq!(classify_group_rs("core::mem"), ImportGroup::Stdlib);
3386        assert_eq!(classify_group_rs("alloc::vec"), ImportGroup::Stdlib);
3387        assert_eq!(
3388            classify_group_rs("serde::Deserialize"),
3389            ImportGroup::External
3390        );
3391        assert_eq!(classify_group_rs("tokio::runtime"), ImportGroup::External);
3392        assert_eq!(classify_group_rs("crate::config"), ImportGroup::Internal);
3393        assert_eq!(classify_group_rs("self::utils"), ImportGroup::Internal);
3394        assert_eq!(classify_group_rs("super::parent"), ImportGroup::Internal);
3395    }
3396
3397    #[test]
3398    fn generate_rs_use() {
3399        let line = generate_import_line(LangId::Rust, "std::fmt::Display", &[], None, false);
3400        assert_eq!(line, "use std::fmt::Display;");
3401    }
3402
3403    // --- Go parsing ---
3404
3405    fn parse_go(source: &str) -> (Tree, ImportBlock) {
3406        let grammar = grammar_for(LangId::Go);
3407        let mut parser = Parser::new();
3408        parser.set_language(&grammar).unwrap();
3409        let tree = parser.parse(source, None).unwrap();
3410        let block = parse_imports(source, &tree, LangId::Go);
3411        (tree, block)
3412    }
3413
3414    #[test]
3415    fn parse_go_single_import() {
3416        let source = "package main\n\nimport \"fmt\"\n";
3417        let (_, block) = parse_go(source);
3418        assert_eq!(block.imports.len(), 1);
3419        assert_eq!(block.imports[0].module_path, "fmt");
3420        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3421    }
3422
3423    #[test]
3424    fn parse_go_grouped_import() {
3425        let source =
3426            "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/pkg/errors\"\n)\n";
3427        let (_, block) = parse_go(source);
3428        assert_eq!(block.imports.len(), 3);
3429        assert_eq!(block.imports[0].module_path, "fmt");
3430        assert_eq!(block.imports[0].group, ImportGroup::Stdlib);
3431        assert_eq!(block.imports[1].module_path, "os");
3432        assert_eq!(block.imports[1].group, ImportGroup::Stdlib);
3433        assert_eq!(block.imports[2].module_path, "github.com/pkg/errors");
3434        assert_eq!(block.imports[2].group, ImportGroup::External);
3435    }
3436
3437    #[test]
3438    fn parse_go_mixed_imports() {
3439        // Single + grouped
3440        let source = "package main\n\nimport \"fmt\"\n\nimport (\n\t\"os\"\n\t\"github.com/pkg/errors\"\n)\n";
3441        let (_, block) = parse_go(source);
3442        assert_eq!(block.imports.len(), 3);
3443    }
3444
3445    #[test]
3446    fn classify_go_groups() {
3447        assert_eq!(classify_group_go("fmt"), ImportGroup::Stdlib);
3448        assert_eq!(classify_group_go("os"), ImportGroup::Stdlib);
3449        assert_eq!(classify_group_go("net/http"), ImportGroup::Stdlib);
3450        assert_eq!(classify_group_go("encoding/json"), ImportGroup::Stdlib);
3451        assert_eq!(
3452            classify_group_go("github.com/pkg/errors"),
3453            ImportGroup::External
3454        );
3455        assert_eq!(
3456            classify_group_go("golang.org/x/tools"),
3457            ImportGroup::External
3458        );
3459    }
3460
3461    #[test]
3462    fn generate_go_standalone() {
3463        let line = generate_go_import_line("fmt", None, false);
3464        assert_eq!(line, "import \"fmt\"");
3465    }
3466
3467    #[test]
3468    fn generate_go_grouped_spec() {
3469        let line = generate_go_import_line("fmt", None, true);
3470        assert_eq!(line, "\t\"fmt\"");
3471    }
3472
3473    #[test]
3474    fn generate_go_with_alias() {
3475        let line = generate_go_import_line("github.com/pkg/errors", Some("errs"), false);
3476        assert_eq!(line, "import errs \"github.com/pkg/errors\"");
3477    }
3478
3479    // --- Solidity ---
3480
3481    fn parse_solidity(source: &str) -> (Tree, ImportBlock) {
3482        let grammar = grammar_for(LangId::Solidity);
3483        let mut parser = Parser::new();
3484        parser.set_language(&grammar).unwrap();
3485        let tree = parser.parse(source, None).unwrap();
3486        let block = parse_imports(source, &tree, LangId::Solidity);
3487        (tree, block)
3488    }
3489
3490    /// Grammar fixture (council #6): lock the tree-sitter-solidity node kinds the
3491    /// parser depends on. If the grammar updates and renames these, this test
3492    /// fails loudly before the parser silently mis-parses.
3493    #[test]
3494    fn solidity_grammar_node_kinds_are_stable() {
3495        let grammar = grammar_for(LangId::Solidity);
3496        let mut parser = Parser::new();
3497        parser.set_language(&grammar).unwrap();
3498        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";
3499        let tree = parser.parse(src, None).unwrap();
3500        let mut kinds: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3501        fn walk(node: tree_sitter::Node, kinds: &mut std::collections::BTreeSet<String>) {
3502            kinds.insert(node.kind().to_string());
3503            let mut c = node.walk();
3504            if c.goto_first_child() {
3505                loop {
3506                    walk(c.node(), kinds);
3507                    if !c.goto_next_sibling() {
3508                        break;
3509                    }
3510                }
3511            }
3512        }
3513        walk(tree.root_node(), &mut kinds);
3514        for required in [
3515            "import_directive",
3516            "string",
3517            "identifier",
3518            "as",
3519            "from",
3520            "*",
3521            "{",
3522            "}",
3523        ] {
3524            assert!(
3525                kinds.contains(required),
3526                "solidity grammar missing node kind {required:?}; present: {kinds:?}"
3527            );
3528        }
3529    }
3530
3531    #[test]
3532    fn solidity_string_path_strips_one_matching_quote_pair() {
3533        assert_eq!(
3534            strip_one_matching_solidity_quote_pair("\"./A.sol\""),
3535            "./A.sol"
3536        );
3537        assert_eq!(
3538            strip_one_matching_solidity_quote_pair("'./A.sol'"),
3539            "./A.sol"
3540        );
3541        assert_eq!(
3542            strip_one_matching_solidity_quote_pair("\"./a'b.sol\""),
3543            "./a'b.sol"
3544        );
3545        assert_eq!(
3546            strip_one_matching_solidity_quote_pair("\"\"./A.sol\"\""),
3547            "\"./A.sol\""
3548        );
3549        assert_eq!(
3550            strip_one_matching_solidity_quote_pair("'./A.sol\""),
3551            "'./A.sol\""
3552        );
3553        assert_eq!(
3554            strip_one_matching_solidity_quote_pair("\"./A.sol"),
3555            "\"./A.sol"
3556        );
3557        assert_eq!(strip_one_matching_solidity_quote_pair("\""), "\"");
3558    }
3559
3560    #[test]
3561    fn parse_solidity_all_four_forms() {
3562        let (_, block) = parse_solidity(
3563            "import \"./A.sol\";\nimport \"./B.sol\" as B;\nimport * as C from \"./C.sol\";\nimport { Foo, Bar as Baz } from \"./D.sol\";\nimport './E.sol';\nimport './F.sol' as F;\nimport * as G from './G.sol';\nimport { Quux, Corge as Grault } from './H.sol';\n",
3564        );
3565        assert_eq!(block.imports.len(), 8);
3566
3567        // side-effect
3568        assert_eq!(block.imports[0].module_path, "./A.sol");
3569        assert_eq!(block.imports[0].kind, ImportKind::SideEffect);
3570        assert_eq!(
3571            block.imports[0].form,
3572            ImportForm::Solidity {
3573                named: vec![],
3574                namespace: None,
3575                alias: None
3576            }
3577        );
3578
3579        // whole-file alias
3580        assert_eq!(
3581            block.imports[1].form,
3582            ImportForm::Solidity {
3583                named: vec![],
3584                namespace: None,
3585                alias: Some("B".to_string())
3586            }
3587        );
3588
3589        // namespace
3590        match &block.imports[2].form {
3591            ImportForm::Solidity { namespace, .. } => assert_eq!(namespace.as_deref(), Some("C")),
3592            other => panic!("expected Solidity namespace, got {other:?}"),
3593        }
3594        assert_eq!(block.imports[2].namespace_import.as_deref(), Some("C"));
3595
3596        // named with alias (verbatim specifier convention)
3597        match &block.imports[3].form {
3598            ImportForm::Solidity { named, .. } => {
3599                assert_eq!(named, &vec!["Foo".to_string(), "Bar as Baz".to_string()]);
3600            }
3601            other => panic!("expected Solidity named, got {other:?}"),
3602        }
3603        assert_eq!(
3604            block.imports[3].names,
3605            vec!["Foo".to_string(), "Bar as Baz".to_string()]
3606        );
3607
3608        assert_eq!(
3609            block.imports[4..]
3610                .iter()
3611                .map(|import| import.module_path.as_str())
3612                .collect::<Vec<_>>(),
3613            ["./E.sol", "./F.sol", "./G.sol", "./H.sol"]
3614        );
3615        assert_eq!(block.imports[4].kind, ImportKind::SideEffect);
3616        assert!(matches!(
3617            &block.imports[5].form,
3618            ImportForm::Solidity {
3619                alias: Some(alias),
3620                ..
3621            } if alias == "F"
3622        ));
3623        assert_eq!(block.imports[6].namespace_import.as_deref(), Some("G"));
3624        assert_eq!(
3625            block.imports[7].names,
3626            vec!["Quux".to_string(), "Corge as Grault".to_string()]
3627        );
3628    }
3629
3630    #[test]
3631    fn generate_solidity_all_forms() {
3632        // side-effect
3633        assert_eq!(
3634            generate_import(
3635                LangId::Solidity,
3636                &ImportRequest::legacy("./A.sol", &[], None, None, false)
3637            ),
3638            "import \"./A.sol\";"
3639        );
3640        // named
3641        let names = vec!["Foo".to_string(), "Bar as Baz".to_string()];
3642        assert_eq!(
3643            generate_import(
3644                LangId::Solidity,
3645                &ImportRequest::legacy("./D.sol", &names, None, None, false)
3646            ),
3647            "import { Foo, Bar as Baz } from \"./D.sol\";"
3648        );
3649        // namespace
3650        assert_eq!(
3651            generate_import(
3652                LangId::Solidity,
3653                &ImportRequest::legacy("./C.sol", &[], None, Some("C"), false)
3654            ),
3655            "import * as C from \"./C.sol\";"
3656        );
3657        // whole-file alias
3658        assert_eq!(
3659            generate_import(
3660                LangId::Solidity,
3661                &ImportRequest {
3662                    module_path: "./B.sol",
3663                    names: &[],
3664                    default_import: None,
3665                    namespace: None,
3666                    alias: Some("B"),
3667                    type_only: false,
3668                    modifiers: &[],
3669                    import_kind: None,
3670                }
3671            ),
3672            "import \"./B.sol\" as B;"
3673        );
3674    }
3675
3676    #[test]
3677    fn solidity_round_trips_through_parse_generate() {
3678        // Every generated form must parse back to the same structured shape.
3679        for src in [
3680            "import \"./A.sol\";",
3681            "import \"./B.sol\" as B;",
3682            "import * as C from \"./C.sol\";",
3683            "import { Foo, Bar as Baz } from \"./D.sol\";",
3684        ] {
3685            let (_, block) = parse_solidity(src);
3686            assert_eq!(block.imports.len(), 1, "parse {src:?}");
3687            let imp = &block.imports[0];
3688            let (namespace, alias) = match &imp.form {
3689                ImportForm::Solidity {
3690                    namespace, alias, ..
3691                } => (namespace.as_deref(), alias.as_deref()),
3692                other => panic!("expected Solidity, got {other:?}"),
3693            };
3694            let regenerated = generate_import(
3695                LangId::Solidity,
3696                &ImportRequest {
3697                    module_path: &imp.module_path,
3698                    names: &imp.names,
3699                    default_import: None,
3700                    namespace,
3701                    alias,
3702                    type_only: false,
3703                    modifiers: &[],
3704                    import_kind: None,
3705                },
3706            );
3707            assert_eq!(regenerated, src, "round-trip mismatch for {src:?}");
3708        }
3709    }
3710
3711    #[test]
3712    fn classify_group_solidity_relative_vs_external() {
3713        assert_eq!(classify_group_solidity("./A.sol"), ImportGroup::Internal);
3714        assert_eq!(
3715            classify_group_solidity("../lib/B.sol"),
3716            ImportGroup::Internal
3717        );
3718        assert_eq!(
3719            classify_group_solidity("@openzeppelin/contracts/token/ERC20/ERC20.sol"),
3720            ImportGroup::External
3721        );
3722    }
3723}