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