Skip to main content

fallow_core/
analyze.rs

1use std::collections::{HashMap, HashSet};
2
3use fallow_config::{PackageJson, ResolvedConfig};
4
5use crate::extract::MemberKind;
6use crate::graph::ModuleGraph;
7use crate::resolve::ResolvedModule;
8use crate::results::*;
9
10/// Convert a byte offset in source text to a 1-based line and 0-based column (byte offset from
11/// start of the line). Uses byte counting to stay consistent with Oxc's byte-offset spans.
12fn byte_offset_to_line_col(source: &str, byte_offset: u32) -> (u32, u32) {
13    let byte_offset = byte_offset as usize;
14    let prefix = &source[..byte_offset.min(source.len())];
15    let line = prefix.bytes().filter(|&b| b == b'\n').count() as u32 + 1;
16    let col = prefix
17        .rfind('\n')
18        .map(|pos| byte_offset - pos - 1)
19        .unwrap_or(byte_offset) as u32;
20    (line, col)
21}
22
23/// Read source content from disk, returning empty string on failure.
24fn read_source(path: &std::path::Path) -> String {
25    std::fs::read_to_string(path).unwrap_or_default()
26}
27
28/// Find all dead code in the project.
29pub fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
30    find_dead_code_with_resolved(graph, config, &[], None)
31}
32
33/// Find all dead code, with optional resolved module data and plugin context.
34pub fn find_dead_code_with_resolved(
35    graph: &ModuleGraph,
36    config: &ResolvedConfig,
37    resolved_modules: &[ResolvedModule],
38    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
39) -> AnalysisResults {
40    find_dead_code_full(graph, config, resolved_modules, plugin_result, &[])
41}
42
43/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
44pub fn find_dead_code_full(
45    graph: &ModuleGraph,
46    config: &ResolvedConfig,
47    resolved_modules: &[ResolvedModule],
48    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
49    workspaces: &[fallow_config::WorkspaceInfo],
50) -> AnalysisResults {
51    let _span = tracing::info_span!("find_dead_code").entered();
52
53    let mut results = AnalysisResults::default();
54
55    if config.detect.unused_files {
56        results.unused_files = find_unused_files(graph);
57    }
58
59    if config.detect.unused_exports || config.detect.unused_types {
60        let (exports, types) = find_unused_exports(graph, config, plugin_result);
61        if config.detect.unused_exports {
62            results.unused_exports = exports;
63        }
64        if config.detect.unused_types {
65            results.unused_types = types;
66        }
67    }
68
69    if config.detect.unused_enum_members || config.detect.unused_class_members {
70        let (enum_members, class_members) = find_unused_members(graph, config, resolved_modules);
71        if config.detect.unused_enum_members {
72            results.unused_enum_members = enum_members;
73        }
74        if config.detect.unused_class_members {
75            results.unused_class_members = class_members;
76        }
77    }
78
79    // Build merged dependency set from root + all workspace package.json files
80    let pkg_path = config.root.join("package.json");
81    if let Ok(pkg) = PackageJson::load(&pkg_path) {
82        if config.detect.unused_dependencies || config.detect.unused_dev_dependencies {
83            let (deps, dev_deps) =
84                find_unused_dependencies(graph, &pkg, config, plugin_result, workspaces);
85            if config.detect.unused_dependencies {
86                results.unused_dependencies = deps;
87            }
88            if config.detect.unused_dev_dependencies {
89                results.unused_dev_dependencies = dev_deps;
90            }
91        }
92
93        if config.detect.unlisted_dependencies {
94            results.unlisted_dependencies =
95                find_unlisted_dependencies(graph, &pkg, config, workspaces);
96        }
97    }
98
99    if config.detect.unresolved_imports && !resolved_modules.is_empty() {
100        results.unresolved_imports = find_unresolved_imports(resolved_modules, config);
101    }
102
103    if config.detect.duplicate_exports {
104        results.duplicate_exports = find_duplicate_exports(graph, config);
105    }
106
107    results
108}
109
110/// Find files that are not reachable from any entry point.
111///
112/// TypeScript declaration files (`.d.ts`) are excluded because they are consumed
113/// by the TypeScript compiler via `tsconfig.json` includes, not via explicit
114/// import statements. Flagging them as unused is a false positive.
115///
116/// Configuration files (e.g., `babel.config.js`, `.eslintrc.js`, `knip.config.ts`)
117/// are also excluded because they are consumed by tools, not via imports.
118///
119/// Barrel files (index.ts that only re-export) are excluded when their re-export
120/// sources are reachable — they serve an organizational purpose even if consumers
121/// import directly from the source files rather than through the barrel.
122fn find_unused_files(graph: &ModuleGraph) -> Vec<UnusedFile> {
123    graph
124        .modules
125        .iter()
126        .filter(|m| !m.is_reachable && !m.is_entry_point)
127        .filter(|m| !is_declaration_file(&m.path))
128        .filter(|m| !is_config_file(&m.path))
129        .filter(|m| !is_barrel_with_reachable_sources(m, graph))
130        .map(|m| UnusedFile {
131            path: m.path.clone(),
132        })
133        .collect()
134}
135
136/// Check if a path is a TypeScript declaration file (`.d.ts`, `.d.mts`, `.d.cts`).
137fn is_declaration_file(path: &std::path::Path) -> bool {
138    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
139    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
140}
141
142/// Check if a module is a barrel file (only re-exports) whose sources are reachable.
143///
144/// A barrel file like `index.ts` that only contains `export { Foo } from './source'`
145/// lines serves an organizational purpose. If the source modules are reachable,
146/// the barrel file should not be reported as unused — consumers may have bypassed
147/// it with direct imports, but the barrel still provides valid re-exports.
148fn is_barrel_with_reachable_sources(
149    module: &crate::graph::ModuleNode,
150    graph: &ModuleGraph,
151) -> bool {
152    // Must have re-exports
153    if module.re_exports.is_empty() {
154        return false;
155    }
156
157    // Must be a pure barrel: no local exports with real spans (only re-export-generated
158    // exports have span 0..0) and no CJS exports
159    let has_local_exports = module
160        .exports
161        .iter()
162        .any(|e| e.span.start != 0 || e.span.end != 0);
163    if has_local_exports || module.has_cjs_exports {
164        return false;
165    }
166
167    // At least one re-export source must be reachable
168    module.re_exports.iter().any(|re| {
169        let source_idx = re.source_file.0 as usize;
170        graph
171            .modules
172            .get(source_idx)
173            .is_some_and(|m| m.is_reachable)
174    })
175}
176
177/// Check if a file is a configuration file consumed by tooling, not via imports.
178///
179/// These files should never be reported as unused because they are loaded by
180/// their respective tools (e.g., Babel reads `babel.config.js`, ESLint reads
181/// `eslint.config.ts`, etc.) rather than being imported by application code.
182fn is_config_file(path: &std::path::Path) -> bool {
183    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
184
185    // Dotfiles with "rc" suffix pattern (e.g., .secretlintrc.cjs, .commitlintrc.js, .prettierrc.js)
186    // Only match files with "rc." before the extension — avoids false matches on arbitrary dotfiles.
187    if name.starts_with('.') && !name.starts_with("..") {
188        let lower = name.to_ascii_lowercase();
189        // .foorc.{ext} pattern — standard for tool configs
190        if lower.contains("rc.") {
191            return true;
192        }
193    }
194
195    // Files matching common config naming patterns.
196    // Each pattern is a prefix — the file must start with it.
197    let config_patterns = [
198        // Build tools
199        "babel.config.",
200        "rollup.config.",
201        "webpack.config.",
202        "postcss.config.",
203        "stencil.config.",
204        "remotion.config.",
205        "metro.config.",
206        "tsup.config.",
207        "unbuild.config.",
208        "esbuild.config.",
209        "swc.config.",
210        "turbo.",
211        // Testing
212        "jest.config.",
213        "jest.setup.",
214        "vitest.config.",
215        "vitest.ci.config.",
216        "vitest.setup.",
217        "vitest.workspace.",
218        "playwright.config.",
219        "cypress.config.",
220        // Linting & formatting
221        "eslint.config.",
222        "prettier.config.",
223        "stylelint.config.",
224        "lint-staged.config.",
225        "commitlint.config.",
226        // Frameworks / CMS
227        "next.config.",
228        "next-sitemap.config.",
229        "nuxt.config.",
230        "astro.config.",
231        "sanity.config.",
232        "vite.config.",
233        "tailwind.config.",
234        "drizzle.config.",
235        "knexfile.",
236        "sentry.client.config.",
237        "sentry.server.config.",
238        "sentry.edge.config.",
239        "react-router.config.",
240        // Documentation
241        "typedoc.",
242        // Analysis & misc
243        "knip.config.",
244        "fallow.config.",
245        "i18next-parser.config.",
246        "codegen.config.",
247        "graphql.config.",
248        "npmpackagejsonlint.config.",
249        "release-it.",
250        "release.config.",
251        "contentlayer.config.",
252        // Environment declarations
253        "next-env.d.",
254        "env.d.",
255        "vite-env.d.",
256    ];
257
258    config_patterns.iter().any(|p| name.starts_with(p))
259}
260
261/// Find exports that are never imported by other files.
262fn find_unused_exports(
263    graph: &ModuleGraph,
264    config: &ResolvedConfig,
265    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
266) -> (Vec<UnusedExport>, Vec<UnusedExport>) {
267    let mut unused_exports = Vec::new();
268    let mut unused_types = Vec::new();
269
270    // Pre-compile glob matchers for ignore rules and framework rules
271    let ignore_matchers: Vec<(globset::GlobMatcher, &[String])> = config
272        .ignore_export_rules
273        .iter()
274        .filter_map(|rule| {
275            globset::Glob::new(&rule.file)
276                .ok()
277                .map(|g| (g.compile_matcher(), rule.exports.as_slice()))
278        })
279        .collect();
280
281    let framework_matchers: Vec<(globset::GlobMatcher, &[String])> = config
282        .framework_rules
283        .iter()
284        .flat_map(|rule| &rule.used_exports)
285        .filter_map(|used| {
286            globset::Glob::new(&used.file_pattern)
287                .ok()
288                .map(|g| (g.compile_matcher(), used.exports.as_slice()))
289        })
290        .collect();
291
292    // Also compile plugin-discovered used_exports rules
293    let plugin_matchers: Vec<(globset::GlobMatcher, Vec<&str>)> = plugin_result
294        .map(|pr| {
295            pr.used_exports
296                .iter()
297                .filter_map(|(file_pat, exports)| {
298                    globset::Glob::new(file_pat).ok().map(|g| {
299                        (
300                            g.compile_matcher(),
301                            exports.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
302                        )
303                    })
304                })
305                .collect()
306        })
307        .unwrap_or_default();
308
309    for module in &graph.modules {
310        // Skip unreachable modules (already reported as unused files)
311        if !module.is_reachable {
312            continue;
313        }
314
315        // Skip entry points (their exports are consumed externally)
316        if module.is_entry_point {
317            continue;
318        }
319
320        // Skip CJS modules with module.exports (hard to track individual exports)
321        if module.has_cjs_exports && module.exports.is_empty() {
322            continue;
323        }
324
325        // Namespace imports are now handled with member-access narrowing in graph.rs:
326        // only specific accessed members get references populated. No blanket skip needed.
327
328        // Svelte files use `export let`/`export const` for component props, which are
329        // consumed by the Svelte runtime rather than imported by other modules. Since we
330        // can't distinguish props from utility exports in the `<script>` block without
331        // Svelte compiler semantics, we skip export analysis entirely for reachable
332        // .svelte files. Unreachable Svelte files are still caught by `find_unused_files`.
333        if module.path.extension().is_some_and(|ext| ext == "svelte") {
334            continue;
335        }
336
337        // Check ignore rules — compute relative path and string once per module
338        let relative_path = module
339            .path
340            .strip_prefix(&config.root)
341            .unwrap_or(&module.path);
342        let file_str = relative_path.to_string_lossy();
343
344        // Pre-check which ignore/framework matchers match this file
345        let matching_ignore: Vec<&[String]> = ignore_matchers
346            .iter()
347            .filter(|(m, _)| m.is_match(file_str.as_ref()))
348            .map(|(_, exports)| *exports)
349            .collect();
350
351        let matching_framework: Vec<&[String]> = framework_matchers
352            .iter()
353            .filter(|(m, _)| m.is_match(file_str.as_ref()))
354            .map(|(_, exports)| *exports)
355            .collect();
356
357        // Check plugin-discovered used_exports rules
358        let matching_plugin: Vec<&Vec<&str>> = plugin_matchers
359            .iter()
360            .filter(|(m, _)| m.is_match(file_str.as_ref()))
361            .map(|(_, exports)| exports)
362            .collect();
363
364        // Lazily load source content for line/col computation
365        let mut source_content: Option<String> = None;
366
367        for export in &module.exports {
368            if export.references.is_empty() {
369                let export_str = export.name.to_string();
370
371                // Check if this export is ignored by config
372                if matching_ignore
373                    .iter()
374                    .any(|exports| exports.iter().any(|e| e == "*" || e == &export_str))
375                {
376                    continue;
377                }
378
379                // Check if this export is considered "used" by a framework rule
380                if matching_framework
381                    .iter()
382                    .any(|exports| exports.iter().any(|e| e == &export_str))
383                {
384                    continue;
385                }
386
387                // Check if this export is considered "used" by a plugin rule
388                if matching_plugin
389                    .iter()
390                    .any(|exports| exports.iter().any(|e| *e == export_str))
391                {
392                    continue;
393                }
394
395                let source = source_content.get_or_insert_with(|| read_source(&module.path));
396                let (line, col) = byte_offset_to_line_col(source, export.span.start);
397
398                let unused = UnusedExport {
399                    path: module.path.clone(),
400                    export_name: export_str,
401                    is_type_only: export.is_type_only,
402                    line,
403                    col,
404                    span_start: export.span.start,
405                };
406
407                if export.is_type_only {
408                    unused_types.push(unused);
409                } else {
410                    unused_exports.push(unused);
411                }
412            }
413        }
414    }
415
416    (unused_exports, unused_types)
417}
418
419/// Find dependencies in package.json that are never imported.
420fn find_unused_dependencies(
421    graph: &ModuleGraph,
422    pkg: &PackageJson,
423    config: &ResolvedConfig,
424    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
425    workspaces: &[fallow_config::WorkspaceInfo],
426) -> (Vec<UnusedDependency>, Vec<UnusedDependency>) {
427    let used_packages: HashSet<&str> = graph.package_usage.keys().map(|s| s.as_str()).collect();
428
429    // Collect deps referenced in config files (discovered by plugins)
430    let plugin_referenced: HashSet<&str> = plugin_result
431        .map(|pr| {
432            pr.referenced_dependencies
433                .iter()
434                .map(|s| s.as_str())
435                .collect()
436        })
437        .unwrap_or_default();
438
439    // Collect tooling deps from plugins
440    let plugin_tooling: HashSet<&str> = plugin_result
441        .map(|pr| pr.tooling_dependencies.iter().map(|s| s.as_str()).collect())
442        .unwrap_or_default();
443
444    // Collect workspace package names — these are internal deps, not npm packages
445    let workspace_names: HashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
446
447    let unused_deps: Vec<UnusedDependency> = pkg
448        .production_dependency_names()
449        .into_iter()
450        .filter(|dep| !used_packages.contains(dep.as_str()))
451        .filter(|dep| !is_implicit_dependency(dep))
452        .filter(|dep| !plugin_referenced.contains(dep.as_str()))
453        .filter(|dep| !config.ignore_dependencies.iter().any(|d| d == dep))
454        // Skip internal workspace packages — they're not npm deps
455        .filter(|dep| !workspace_names.contains(dep.as_str()))
456        .map(|dep| UnusedDependency {
457            package_name: dep,
458            location: DependencyLocation::Dependencies,
459        })
460        .collect();
461
462    let unused_dev_deps: Vec<UnusedDependency> = pkg
463        .dev_dependency_names()
464        .into_iter()
465        .filter(|dep| !used_packages.contains(dep.as_str()))
466        .filter(|dep| !is_tooling_dependency(dep))
467        .filter(|dep| !plugin_tooling.contains(dep.as_str()))
468        .filter(|dep| !plugin_referenced.contains(dep.as_str()))
469        .filter(|dep| !config.ignore_dependencies.iter().any(|d| d == dep))
470        .filter(|dep| !workspace_names.contains(dep.as_str()))
471        .map(|dep| UnusedDependency {
472            package_name: dep,
473            location: DependencyLocation::DevDependencies,
474        })
475        .collect();
476
477    (unused_deps, unused_dev_deps)
478}
479
480/// Find unused enum and class members in exported symbols.
481///
482/// Collects all `Identifier.member` static member accesses from all modules,
483/// maps them to their imported names, and filters out members that are accessed.
484fn find_unused_members(
485    graph: &ModuleGraph,
486    _config: &ResolvedConfig,
487    resolved_modules: &[ResolvedModule],
488) -> (Vec<UnusedMember>, Vec<UnusedMember>) {
489    let mut unused_enum_members = Vec::new();
490    let mut unused_class_members = Vec::new();
491
492    // Build a set of (export_name, member_name) pairs that are accessed across all modules.
493    // We map local import names back to the original imported names.
494    let mut accessed_members: HashSet<(String, String)> = HashSet::new();
495
496    // Also build a per-file set of `this.member` accesses. These indicate internal usage
497    // within a class body — class members accessed via `this.foo` are used internally
498    // even if no external code accesses them via `ClassName.foo`.
499    let mut self_accessed_members: HashMap<crate::discover::FileId, HashSet<String>> =
500        HashMap::new();
501
502    // Build a set of export names that are used as whole objects (Object.values, for..in, etc.).
503    // All members of these exports should be considered used.
504    let mut whole_object_used_exports: HashSet<String> = HashSet::new();
505
506    for resolved in resolved_modules {
507        // Build a map from local name -> imported name for this module's imports
508        let local_to_imported: HashMap<&str, &str> = resolved
509            .resolved_imports
510            .iter()
511            .filter_map(|imp| match &imp.info.imported_name {
512                crate::extract::ImportedName::Named(name) => {
513                    Some((imp.info.local_name.as_str(), name.as_str()))
514                }
515                crate::extract::ImportedName::Default => {
516                    Some((imp.info.local_name.as_str(), "default"))
517                }
518                _ => None,
519            })
520            .collect();
521
522        for access in &resolved.member_accesses {
523            // Track `this.member` accesses per-file for internal class usage
524            if access.object == "this" {
525                self_accessed_members
526                    .entry(resolved.file_id)
527                    .or_default()
528                    .insert(access.member.clone());
529                continue;
530            }
531            // If the object is a local name for an import, map it to the original export name
532            let export_name = local_to_imported
533                .get(access.object.as_str())
534                .copied()
535                .unwrap_or(access.object.as_str());
536            accessed_members.insert((export_name.to_string(), access.member.clone()));
537        }
538
539        // Map whole-object uses from local names to imported names
540        for local_name in &resolved.whole_object_uses {
541            let export_name = local_to_imported
542                .get(local_name.as_str())
543                .copied()
544                .unwrap_or(local_name.as_str());
545            whole_object_used_exports.insert(export_name.to_string());
546        }
547    }
548
549    for module in &graph.modules {
550        if !module.is_reachable || module.is_entry_point {
551            continue;
552        }
553
554        // Lazily load source content for line/col computation
555        let mut source_content: Option<String> = None;
556
557        for export in &module.exports {
558            if export.members.is_empty() {
559                continue;
560            }
561
562            // If the export itself is unused, skip member analysis (whole export is dead)
563            if export.references.is_empty() && !graph.has_namespace_import(module.file_id) {
564                continue;
565            }
566
567            let export_name = export.name.to_string();
568
569            // If this export is used as a whole object (Object.values, for..in, etc.),
570            // all members are considered used — skip individual member analysis.
571            if whole_object_used_exports.contains(&export_name) {
572                continue;
573            }
574
575            // Get `this.member` accesses from this file (internal class usage)
576            let file_self_accesses = self_accessed_members.get(&module.file_id);
577
578            for member in &export.members {
579                // Check if this member is accessed anywhere via external import
580                if accessed_members.contains(&(export_name.clone(), member.name.clone())) {
581                    continue;
582                }
583
584                // Check if this member is accessed via `this.member` within the same file
585                // (internal class usage — e.g., constructor sets this.label, methods use this.label)
586                if matches!(
587                    member.kind,
588                    MemberKind::ClassMethod | MemberKind::ClassProperty
589                ) && file_self_accesses.is_some_and(|accesses| accesses.contains(&member.name))
590                {
591                    continue;
592                }
593
594                // Skip React class component lifecycle methods — they are called by the
595                // React runtime, not user code, so they should never be flagged as unused.
596                // Also skip Angular lifecycle hooks (OnInit, OnDestroy, etc.).
597                if matches!(
598                    member.kind,
599                    MemberKind::ClassMethod | MemberKind::ClassProperty
600                ) && (is_react_lifecycle_method(&member.name)
601                    || is_angular_lifecycle_method(&member.name))
602                {
603                    continue;
604                }
605
606                let source = source_content.get_or_insert_with(|| read_source(&module.path));
607                let (line, col) = byte_offset_to_line_col(source, member.span.start);
608
609                let unused = UnusedMember {
610                    path: module.path.clone(),
611                    parent_name: export_name.clone(),
612                    member_name: member.name.clone(),
613                    kind: member.kind.clone(),
614                    line,
615                    col,
616                };
617
618                match member.kind {
619                    MemberKind::EnumMember => unused_enum_members.push(unused),
620                    MemberKind::ClassMethod | MemberKind::ClassProperty => {
621                        unused_class_members.push(unused);
622                    }
623                }
624            }
625        }
626    }
627
628    (unused_enum_members, unused_class_members)
629}
630
631/// Find dependencies used in imports but not listed in package.json.
632fn find_unlisted_dependencies(
633    graph: &ModuleGraph,
634    pkg: &PackageJson,
635    config: &ResolvedConfig,
636    workspaces: &[fallow_config::WorkspaceInfo],
637) -> Vec<UnlistedDependency> {
638    let all_deps: HashSet<String> = pkg.all_dependency_names().into_iter().collect();
639
640    // Build a set of all deps across all workspace package.json files.
641    // In monorepos, imports in workspace files reference deps from that workspace's package.json.
642    let mut all_workspace_deps: HashSet<String> = all_deps.clone();
643    // Also collect workspace package names — internal workspace deps should not be flagged
644    let mut workspace_names: HashSet<String> = HashSet::new();
645    // Map: canonical workspace root -> set of dep names (for per-file checks)
646    let mut ws_dep_map: Vec<(std::path::PathBuf, HashSet<String>)> = Vec::new();
647
648    for ws in workspaces {
649        workspace_names.insert(ws.name.clone());
650        let ws_pkg_path = ws.root.join("package.json");
651        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
652            let ws_deps: HashSet<String> = ws_pkg.all_dependency_names().into_iter().collect();
653            all_workspace_deps.extend(ws_deps.iter().cloned());
654            let canonical_ws = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
655            ws_dep_map.push((canonical_ws, ws_deps));
656        }
657    }
658
659    let mut unlisted: HashMap<String, Vec<std::path::PathBuf>> = HashMap::new();
660
661    for (package_name, file_ids) in &graph.package_usage {
662        if is_builtin_module(package_name) || is_path_alias(package_name) {
663            continue;
664        }
665        // Skip internal workspace package names
666        if workspace_names.contains(package_name) {
667            continue;
668        }
669        // Quick check: if listed in any root or workspace deps, skip
670        if all_workspace_deps.contains(package_name) {
671            continue;
672        }
673
674        // Slower fallback: check if each importing file belongs to a workspace that lists this dep
675        let mut unlisted_paths: Vec<std::path::PathBuf> = Vec::new();
676        for id in file_ids {
677            if let Some(module) = graph.modules.get(id.0 as usize) {
678                let file_canonical = module.path.canonicalize().unwrap_or(module.path.clone());
679                let listed_in_ws = ws_dep_map.iter().any(|(ws_root, ws_deps)| {
680                    file_canonical.starts_with(ws_root) && ws_deps.contains(package_name)
681                });
682                // Also check root deps
683                let listed_in_root = all_deps.contains(package_name);
684                if !listed_in_ws && !listed_in_root {
685                    unlisted_paths.push(module.path.clone());
686                }
687            }
688        }
689
690        if !unlisted_paths.is_empty() {
691            unlisted_paths.sort();
692            unlisted_paths.dedup();
693            unlisted.insert(package_name.clone(), unlisted_paths);
694        }
695    }
696
697    let _ = config; // future use
698    unlisted
699        .into_iter()
700        .map(|(name, paths)| UnlistedDependency {
701            package_name: name,
702            imported_from: paths,
703        })
704        .collect()
705}
706
707/// Check if a package name looks like a TypeScript path alias rather than an npm package.
708///
709/// Common patterns: `@/components`, `@app/utils`, `~/lib`, `#internal/module`,
710/// `@Components/Button` (PascalCase tsconfig paths).
711/// These are typically defined in tsconfig.json `paths` or package.json `imports`.
712fn is_path_alias(name: &str) -> bool {
713    // `#` prefix is Node.js imports maps (package.json "imports" field)
714    if name.starts_with('#') {
715        return true;
716    }
717    // `~/` prefix is a common alias convention (e.g., Nuxt, custom tsconfig)
718    if name.starts_with("~/") {
719        return true;
720    }
721    // `@/` is a very common path alias (e.g., `@/components/Foo`)
722    if name.starts_with("@/") {
723        return true;
724    }
725    // npm scoped packages MUST be lowercase (npm registry requirement).
726    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
727    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
728    if name.starts_with('@') {
729        let scope = name.split('/').next().unwrap_or(name);
730        if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
731            return true;
732        }
733    }
734
735    false
736}
737
738/// Find imports that could not be resolved.
739fn find_unresolved_imports(
740    resolved_modules: &[ResolvedModule],
741    _config: &ResolvedConfig,
742) -> Vec<UnresolvedImport> {
743    let mut unresolved = Vec::new();
744
745    for module in resolved_modules {
746        // Lazily load source content for line/col computation
747        let mut source_content: Option<String> = None;
748
749        for import in &module.resolved_imports {
750            if let crate::resolve::ResolveResult::Unresolvable(spec) = &import.target {
751                let source = source_content.get_or_insert_with(|| read_source(&module.path));
752                let (line, col) = byte_offset_to_line_col(source, import.info.span.start);
753
754                unresolved.push(UnresolvedImport {
755                    path: module.path.clone(),
756                    specifier: spec.clone(),
757                    line,
758                    col,
759                });
760            }
761        }
762    }
763
764    unresolved
765}
766
767/// Find exports that appear with the same name in multiple files (potential duplicates).
768///
769/// Barrel re-exports (files that only re-export from other modules via `export { X } from './source'`)
770/// are excluded — having an index.ts re-export the same name as the source module is the normal
771/// barrel file pattern, not a true duplicate.
772fn find_duplicate_exports(graph: &ModuleGraph, config: &ResolvedConfig) -> Vec<DuplicateExport> {
773    // Build a set of re-export relationships: (re-exporting module idx) -> set of (source module idx)
774    let mut re_export_sources: HashMap<usize, HashSet<usize>> = HashMap::new();
775    for (idx, module) in graph.modules.iter().enumerate() {
776        for re in &module.re_exports {
777            re_export_sources
778                .entry(idx)
779                .or_default()
780                .insert(re.source_file.0 as usize);
781        }
782    }
783
784    let mut export_locations: HashMap<String, Vec<(usize, std::path::PathBuf)>> = HashMap::new();
785
786    for (idx, module) in graph.modules.iter().enumerate() {
787        if !module.is_reachable || module.is_entry_point {
788            continue;
789        }
790
791        for export in &module.exports {
792            if matches!(export.name, crate::extract::ExportName::Default) {
793                continue; // Skip default exports
794            }
795            // Skip synthetic re-export entries (span 0..0) — these are generated by
796            // graph construction for re-exports, not real local declarations
797            if export.span.start == 0 && export.span.end == 0 {
798                continue;
799            }
800            let name = export.name.to_string();
801            export_locations
802                .entry(name)
803                .or_default()
804                .push((idx, module.path.clone()));
805        }
806    }
807
808    // Filter: only keep truly independent duplicates (not re-export chains)
809    let _ = config; // used for consistency
810    export_locations
811        .into_iter()
812        .filter_map(|(name, locations)| {
813            if locations.len() <= 1 {
814                return None;
815            }
816            // Remove entries where one module re-exports from another in the set.
817            // For each pair (A, B), if A re-exports from B or B re-exports from A,
818            // they are part of the same export chain, not true duplicates.
819            let module_indices: HashSet<usize> = locations.iter().map(|(idx, _)| *idx).collect();
820            let independent: Vec<std::path::PathBuf> = locations
821                .into_iter()
822                .filter(|(idx, _)| {
823                    // Keep this module only if it doesn't re-export from another module in the set
824                    // AND no other module in the set re-exports from it (unless both are sources)
825                    let sources = re_export_sources.get(idx);
826                    let has_source_in_set = sources
827                        .map(|s| s.iter().any(|src| module_indices.contains(src)))
828                        .unwrap_or(false);
829                    !has_source_in_set
830                })
831                .map(|(_, path)| path)
832                .collect();
833
834            if independent.len() > 1 {
835                Some(DuplicateExport {
836                    export_name: name,
837                    locations: independent,
838                })
839            } else {
840                None
841            }
842        })
843        .collect()
844}
845
846/// Check if a package name is a Node.js built-in module.
847fn is_builtin_module(name: &str) -> bool {
848    let builtins = [
849        "assert",
850        "assert/strict",
851        "async_hooks",
852        "buffer",
853        "child_process",
854        "cluster",
855        "console",
856        "constants",
857        "crypto",
858        "dgram",
859        "diagnostics_channel",
860        "dns",
861        "dns/promises",
862        "domain",
863        "events",
864        "fs",
865        "fs/promises",
866        "http",
867        "http2",
868        "https",
869        "inspector",
870        "inspector/promises",
871        "module",
872        "net",
873        "os",
874        "path",
875        "path/posix",
876        "path/win32",
877        "perf_hooks",
878        "process",
879        "punycode",
880        "querystring",
881        "readline",
882        "readline/promises",
883        "repl",
884        "stream",
885        "stream/consumers",
886        "stream/promises",
887        "stream/web",
888        "string_decoder",
889        "sys",
890        "test",
891        "test/reporters",
892        "timers",
893        "timers/promises",
894        "tls",
895        "trace_events",
896        "tty",
897        "url",
898        "util",
899        "util/types",
900        "v8",
901        "vm",
902        "wasi",
903        "worker_threads",
904        "zlib",
905    ];
906    let stripped = name.strip_prefix("node:").unwrap_or(name);
907    // Check exact match or subpath (e.g., "fs/promises" matches "fs/promises",
908    // "assert/strict" matches "assert/strict")
909    builtins.contains(&stripped) || {
910        // Handle deep subpaths like "stream/consumers" or "test/reporters"
911        stripped
912            .split('/')
913            .next()
914            .is_some_and(|root| builtins.contains(&root))
915    }
916}
917
918/// Dependencies that are used implicitly (not via imports).
919fn is_implicit_dependency(name: &str) -> bool {
920    if name.starts_with("@types/") {
921        return true;
922    }
923
924    // Framework runtime dependencies that are used implicitly (e.g., JSX runtime,
925    // bundler injection) and never appear as explicit imports in source code.
926    let implicit_deps = [
927        "react-dom",
928        "react-dom/client",
929        "react-native",
930        "@next/font",
931        "@next/mdx",
932        "@next/bundle-analyzer",
933        "@next/env",
934        // WebSocket optional native addons (peer deps of ws)
935        "utf-8-validate",
936        "bufferutil",
937    ];
938    implicit_deps.contains(&name)
939}
940
941/// Dev dependencies that are tooling (used by CLI, not imported in code).
942fn is_tooling_dependency(name: &str) -> bool {
943    let tooling_prefixes = [
944        "@types/",
945        "eslint",
946        "@typescript-eslint",
947        "husky",
948        "lint-staged",
949        "commitlint",
950        "@commitlint",
951        "stylelint",
952        "postcss",
953        "autoprefixer",
954        "tailwindcss",
955        "@tailwindcss",
956        "@vitest/",
957        "@jest/",
958        "@testing-library/",
959        "@playwright/",
960        "@storybook/",
961        "storybook",
962        "@babel/",
963        "babel-",
964        "@react-native-community/cli",
965        "@react-native/",
966        "secretlint",
967        "@secretlint/",
968        "oxlint",
969        // Release & publishing tooling
970        "@semantic-release/",
971        "semantic-release",
972        "@release-it/",
973        "@lerna-lite/",
974        "@changesets/",
975        // Build tool plugins (used in config)
976        "@graphql-codegen/",
977        "@rollup/",
978        // Biome tooling
979        "@biomejs/",
980    ];
981
982    let exact_matches = [
983        "typescript",
984        "prettier",
985        "turbo",
986        "concurrently",
987        "cross-env",
988        "rimraf",
989        "npm-run-all",
990        "npm-run-all2",
991        "nodemon",
992        "ts-node",
993        "tsx",
994        "knip",
995        "fallow",
996        "jest",
997        "vitest",
998        "happy-dom",
999        "jsdom",
1000        "vite",
1001        "sass",
1002        "sass-embedded",
1003        "webpack",
1004        "webpack-cli",
1005        "webpack-dev-server",
1006        "esbuild",
1007        "rollup",
1008        "swc",
1009        "@swc/core",
1010        "@swc/jest",
1011        "terser",
1012        "cssnano",
1013        "sharp",
1014        // Release & publishing
1015        "release-it",
1016        "lerna",
1017        // Dotenv CLI tools
1018        "dotenv-cli",
1019        "dotenv-flow",
1020        // Code quality & analysis
1021        "oxfmt",
1022        "jscpd",
1023        "npm-check-updates",
1024        "markdownlint-cli",
1025        "npm-package-json-lint",
1026        "synp",
1027        "flow-bin",
1028        // i18n tooling
1029        "i18next-parser",
1030        "i18next-conv",
1031        // Bundle analysis & build tooling
1032        "webpack-bundle-analyzer",
1033        // Vite plugins (used in config, not imported)
1034        "vite-plugin-svgr",
1035        "vite-plugin-eslint",
1036        "@vitejs/plugin-vue",
1037        "@vitejs/plugin-react",
1038        // Site generation / SEO
1039        "next-sitemap",
1040        // Build tools (used by CLI, not imported in code)
1041        "tsup",
1042        "unbuild",
1043        "typedoc",
1044        // Monorepo tools
1045        "nx",
1046        "@manypkg/cli",
1047        // Vue tooling
1048        "vue-tsc",
1049        "@vue/tsconfig",
1050        "@tsconfig/node20",
1051        "@tsconfig/react-native",
1052        // TypeScript experimental
1053        "@typescript/native-preview",
1054        // CSS-only deps (not imported in JS)
1055        "tw-animate-css",
1056        // Formatting
1057        "@ianvs/prettier-plugin-sort-imports",
1058        "prettier-plugin-tailwindcss",
1059        "prettier-plugin-organize-imports",
1060        // Additional build tooling
1061        "@vitejs/plugin-react-swc",
1062        "@vitejs/plugin-legacy",
1063    ];
1064
1065    tooling_prefixes.iter().any(|p| name.starts_with(p)) || exact_matches.contains(&name)
1066}
1067
1068/// Angular lifecycle hooks and framework-invoked methods.
1069///
1070/// These should never be flagged as unused class members because they are
1071/// called by the Angular framework, not user code.
1072fn is_angular_lifecycle_method(name: &str) -> bool {
1073    matches!(
1074        name,
1075        "ngOnInit"
1076            | "ngOnDestroy"
1077            | "ngOnChanges"
1078            | "ngDoCheck"
1079            | "ngAfterContentInit"
1080            | "ngAfterContentChecked"
1081            | "ngAfterViewInit"
1082            | "ngAfterViewChecked"
1083            | "ngAcceptInputType"
1084            // Angular guard/resolver/interceptor methods
1085            | "canActivate"
1086            | "canDeactivate"
1087            | "canActivateChild"
1088            | "canMatch"
1089            | "resolve"
1090            | "intercept"
1091            | "transform"
1092            // Angular form-related methods
1093            | "validate"
1094            | "registerOnChange"
1095            | "registerOnTouched"
1096            | "writeValue"
1097            | "setDisabledState"
1098    )
1099}
1100
1101fn is_react_lifecycle_method(name: &str) -> bool {
1102    matches!(
1103        name,
1104        "render"
1105            | "componentDidMount"
1106            | "componentDidUpdate"
1107            | "componentWillUnmount"
1108            | "shouldComponentUpdate"
1109            | "getSnapshotBeforeUpdate"
1110            | "getDerivedStateFromProps"
1111            | "getDerivedStateFromError"
1112            | "componentDidCatch"
1113            | "componentWillMount"
1114            | "componentWillReceiveProps"
1115            | "componentWillUpdate"
1116            | "UNSAFE_componentWillMount"
1117            | "UNSAFE_componentWillReceiveProps"
1118            | "UNSAFE_componentWillUpdate"
1119            | "getChildContext"
1120            | "contextType"
1121    )
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127
1128    // is_builtin_module tests
1129    #[test]
1130    fn builtin_module_fs() {
1131        assert!(is_builtin_module("fs"));
1132    }
1133
1134    #[test]
1135    fn builtin_module_path() {
1136        assert!(is_builtin_module("path"));
1137    }
1138
1139    #[test]
1140    fn builtin_module_with_node_prefix() {
1141        assert!(is_builtin_module("node:fs"));
1142        assert!(is_builtin_module("node:path"));
1143        assert!(is_builtin_module("node:crypto"));
1144    }
1145
1146    #[test]
1147    fn builtin_module_all_known() {
1148        let known = [
1149            "assert",
1150            "buffer",
1151            "child_process",
1152            "cluster",
1153            "console",
1154            "constants",
1155            "crypto",
1156            "dgram",
1157            "dns",
1158            "domain",
1159            "events",
1160            "fs",
1161            "http",
1162            "http2",
1163            "https",
1164            "module",
1165            "net",
1166            "os",
1167            "path",
1168            "perf_hooks",
1169            "process",
1170            "punycode",
1171            "querystring",
1172            "readline",
1173            "repl",
1174            "stream",
1175            "string_decoder",
1176            "sys",
1177            "timers",
1178            "tls",
1179            "tty",
1180            "url",
1181            "util",
1182            "v8",
1183            "vm",
1184            "wasi",
1185            "worker_threads",
1186            "zlib",
1187        ];
1188        for name in &known {
1189            assert!(is_builtin_module(name), "{name} should be a builtin module");
1190        }
1191    }
1192
1193    #[test]
1194    fn not_builtin_module() {
1195        assert!(!is_builtin_module("react"));
1196        assert!(!is_builtin_module("lodash"));
1197        assert!(!is_builtin_module("express"));
1198        assert!(!is_builtin_module("@scope/pkg"));
1199    }
1200
1201    #[test]
1202    fn not_builtin_similar_names() {
1203        assert!(!is_builtin_module("filesystem"));
1204        assert!(!is_builtin_module("pathlib"));
1205        assert!(!is_builtin_module("node:react"));
1206    }
1207
1208    // is_implicit_dependency tests
1209    #[test]
1210    fn implicit_dep_types_packages() {
1211        assert!(is_implicit_dependency("@types/node"));
1212        assert!(is_implicit_dependency("@types/react"));
1213        assert!(is_implicit_dependency("@types/jest"));
1214    }
1215
1216    #[test]
1217    fn not_implicit_dep() {
1218        assert!(!is_implicit_dependency("react"));
1219        assert!(!is_implicit_dependency("@scope/types"));
1220        assert!(!is_implicit_dependency("types"));
1221        assert!(!is_implicit_dependency("typescript"));
1222        assert!(!is_implicit_dependency("prettier"));
1223        assert!(!is_implicit_dependency("eslint"));
1224    }
1225
1226    // is_tooling_dependency tests
1227    #[test]
1228    fn tooling_dep_prefixes() {
1229        assert!(is_tooling_dependency("@types/node"));
1230        assert!(is_tooling_dependency("eslint"));
1231        assert!(is_tooling_dependency("eslint-plugin-react"));
1232        assert!(is_tooling_dependency("prettier"));
1233        assert!(is_tooling_dependency("@typescript-eslint/parser"));
1234        assert!(is_tooling_dependency("husky"));
1235        assert!(is_tooling_dependency("lint-staged"));
1236        assert!(is_tooling_dependency("commitlint"));
1237        assert!(is_tooling_dependency("@commitlint/config-conventional"));
1238        assert!(is_tooling_dependency("stylelint"));
1239        assert!(is_tooling_dependency("postcss"));
1240        assert!(is_tooling_dependency("autoprefixer"));
1241        assert!(is_tooling_dependency("tailwindcss"));
1242        assert!(is_tooling_dependency("@tailwindcss/forms"));
1243    }
1244
1245    #[test]
1246    fn tooling_dep_exact_matches() {
1247        assert!(is_tooling_dependency("typescript"));
1248        assert!(is_tooling_dependency("prettier"));
1249        assert!(is_tooling_dependency("turbo"));
1250        assert!(is_tooling_dependency("concurrently"));
1251        assert!(is_tooling_dependency("cross-env"));
1252        assert!(is_tooling_dependency("rimraf"));
1253        assert!(is_tooling_dependency("npm-run-all"));
1254        assert!(is_tooling_dependency("nodemon"));
1255        assert!(is_tooling_dependency("ts-node"));
1256        assert!(is_tooling_dependency("tsx"));
1257    }
1258
1259    #[test]
1260    fn not_tooling_dep() {
1261        assert!(!is_tooling_dependency("react"));
1262        assert!(!is_tooling_dependency("next"));
1263        assert!(!is_tooling_dependency("lodash"));
1264        assert!(!is_tooling_dependency("express"));
1265        assert!(!is_tooling_dependency("@emotion/react"));
1266    }
1267
1268    // New tooling dependency tests (Issue 2)
1269    #[test]
1270    fn tooling_dep_testing_frameworks() {
1271        assert!(is_tooling_dependency("jest"));
1272        assert!(is_tooling_dependency("vitest"));
1273        assert!(is_tooling_dependency("@jest/globals"));
1274        assert!(is_tooling_dependency("@vitest/coverage-v8"));
1275        assert!(is_tooling_dependency("@testing-library/react"));
1276        assert!(is_tooling_dependency("@testing-library/jest-dom"));
1277        assert!(is_tooling_dependency("@playwright/test"));
1278    }
1279
1280    #[test]
1281    fn tooling_dep_environments_and_cli() {
1282        assert!(is_tooling_dependency("happy-dom"));
1283        assert!(is_tooling_dependency("jsdom"));
1284        assert!(is_tooling_dependency("knip"));
1285    }
1286
1287    // React lifecycle method tests (Issue 1)
1288    #[test]
1289    fn react_lifecycle_standard_methods() {
1290        assert!(is_react_lifecycle_method("render"));
1291        assert!(is_react_lifecycle_method("componentDidMount"));
1292        assert!(is_react_lifecycle_method("componentDidUpdate"));
1293        assert!(is_react_lifecycle_method("componentWillUnmount"));
1294        assert!(is_react_lifecycle_method("shouldComponentUpdate"));
1295        assert!(is_react_lifecycle_method("getSnapshotBeforeUpdate"));
1296    }
1297
1298    #[test]
1299    fn react_lifecycle_static_methods() {
1300        assert!(is_react_lifecycle_method("getDerivedStateFromProps"));
1301        assert!(is_react_lifecycle_method("getDerivedStateFromError"));
1302    }
1303
1304    #[test]
1305    fn react_lifecycle_error_boundary() {
1306        assert!(is_react_lifecycle_method("componentDidCatch"));
1307    }
1308
1309    #[test]
1310    fn react_lifecycle_deprecated_and_unsafe() {
1311        assert!(is_react_lifecycle_method("componentWillMount"));
1312        assert!(is_react_lifecycle_method("componentWillReceiveProps"));
1313        assert!(is_react_lifecycle_method("componentWillUpdate"));
1314        assert!(is_react_lifecycle_method("UNSAFE_componentWillMount"));
1315        assert!(is_react_lifecycle_method(
1316            "UNSAFE_componentWillReceiveProps"
1317        ));
1318        assert!(is_react_lifecycle_method("UNSAFE_componentWillUpdate"));
1319    }
1320
1321    #[test]
1322    fn react_lifecycle_context_methods() {
1323        assert!(is_react_lifecycle_method("getChildContext"));
1324        assert!(is_react_lifecycle_method("contextType"));
1325    }
1326
1327    #[test]
1328    fn not_react_lifecycle_method() {
1329        assert!(!is_react_lifecycle_method("handleClick"));
1330        assert!(!is_react_lifecycle_method("fetchData"));
1331        assert!(!is_react_lifecycle_method("constructor"));
1332        assert!(!is_react_lifecycle_method("setState"));
1333        assert!(!is_react_lifecycle_method("forceUpdate"));
1334        assert!(!is_react_lifecycle_method("customMethod"));
1335    }
1336
1337    // Declaration file tests (Issue 4)
1338    #[test]
1339    fn declaration_file_dts() {
1340        assert!(is_declaration_file(std::path::Path::new("styled.d.ts")));
1341        assert!(is_declaration_file(std::path::Path::new(
1342            "src/types/styled.d.ts"
1343        )));
1344        assert!(is_declaration_file(std::path::Path::new("env.d.ts")));
1345    }
1346
1347    #[test]
1348    fn declaration_file_dmts_dcts() {
1349        assert!(is_declaration_file(std::path::Path::new("module.d.mts")));
1350        assert!(is_declaration_file(std::path::Path::new("module.d.cts")));
1351    }
1352
1353    #[test]
1354    fn not_declaration_file() {
1355        assert!(!is_declaration_file(std::path::Path::new("index.ts")));
1356        assert!(!is_declaration_file(std::path::Path::new("component.tsx")));
1357        assert!(!is_declaration_file(std::path::Path::new("utils.js")));
1358        assert!(!is_declaration_file(std::path::Path::new("styles.d.css")));
1359    }
1360
1361    // byte_offset_to_line_col tests
1362    #[test]
1363    fn byte_offset_empty_source() {
1364        assert_eq!(byte_offset_to_line_col("", 0), (1, 0));
1365    }
1366
1367    #[test]
1368    fn byte_offset_single_line_start() {
1369        assert_eq!(byte_offset_to_line_col("hello", 0), (1, 0));
1370    }
1371
1372    #[test]
1373    fn byte_offset_single_line_middle() {
1374        assert_eq!(byte_offset_to_line_col("hello", 4), (1, 4));
1375    }
1376
1377    #[test]
1378    fn byte_offset_multiline_start_of_line2() {
1379        // "line1\nline2\nline3"
1380        //  01234 5 678901 2
1381        // offset 6 = start of "line2"
1382        let source = "line1\nline2\nline3";
1383        assert_eq!(byte_offset_to_line_col(source, 6), (2, 0));
1384    }
1385
1386    #[test]
1387    fn byte_offset_multiline_middle_of_line3() {
1388        // "line1\nline2\nline3"
1389        //  01234 5 67890 1 23456
1390        //                1 12345
1391        // offset 14 = 'n' in "line3" (col 2)
1392        let source = "line1\nline2\nline3";
1393        assert_eq!(byte_offset_to_line_col(source, 14), (3, 2));
1394    }
1395
1396    #[test]
1397    fn byte_offset_at_newline_boundary() {
1398        // "line1\nline2"
1399        // offset 5 = the '\n' character itself
1400        let source = "line1\nline2";
1401        assert_eq!(byte_offset_to_line_col(source, 5), (1, 5));
1402    }
1403
1404    #[test]
1405    fn byte_offset_beyond_source_length() {
1406        // Line count is clamped (prefix is sliced to source.len()), but the
1407        // byte-offset column is passed through unclamped because the function
1408        // uses the raw byte_offset for the column fallback.
1409        let source = "hello";
1410        assert_eq!(byte_offset_to_line_col(source, 100), (1, 100));
1411    }
1412
1413    #[test]
1414    fn byte_offset_multibyte_utf8() {
1415        // Emoji is 4 bytes: "hi\n" (3 bytes) + emoji (4 bytes) + "x" (1 byte)
1416        let source = "hi\n\u{1F600}x";
1417        // offset 3 = start of line 2, col 0
1418        assert_eq!(byte_offset_to_line_col(source, 3), (2, 0));
1419        // offset 7 = 'x' (after 4-byte emoji), col 4 (byte-based)
1420        assert_eq!(byte_offset_to_line_col(source, 7), (2, 4));
1421    }
1422
1423    #[test]
1424    fn byte_offset_multibyte_accented_chars() {
1425        // 'e' with accent (U+00E9) is 2 bytes in UTF-8
1426        let source = "caf\u{00E9}\nbar";
1427        // "caf\u{00E9}" = 3 + 2 = 5 bytes, then '\n' at offset 5
1428        // 'b' at offset 6 → line 2, col 0
1429        assert_eq!(byte_offset_to_line_col(source, 6), (2, 0));
1430        // '\u{00E9}' starts at offset 3, col 3 (byte-based)
1431        assert_eq!(byte_offset_to_line_col(source, 3), (1, 3));
1432    }
1433
1434    // is_path_alias tests
1435    #[test]
1436    fn path_alias_at_slash() {
1437        assert!(is_path_alias("@/components"));
1438    }
1439
1440    #[test]
1441    fn path_alias_tilde() {
1442        assert!(is_path_alias("~/lib"));
1443    }
1444
1445    #[test]
1446    fn path_alias_hash_imports_map() {
1447        assert!(is_path_alias("#internal/module"));
1448    }
1449
1450    #[test]
1451    fn path_alias_pascal_case_scope() {
1452        assert!(is_path_alias("@Components/Button"));
1453    }
1454
1455    #[test]
1456    fn not_path_alias_regular_package() {
1457        assert!(!is_path_alias("react"));
1458    }
1459
1460    #[test]
1461    fn not_path_alias_scoped_npm_package() {
1462        assert!(!is_path_alias("@scope/pkg"));
1463    }
1464
1465    #[test]
1466    fn not_path_alias_emotion_react() {
1467        assert!(!is_path_alias("@emotion/react"));
1468    }
1469
1470    #[test]
1471    fn not_path_alias_lodash() {
1472        assert!(!is_path_alias("lodash"));
1473    }
1474
1475    #[test]
1476    fn not_path_alias_lowercase_short_scope() {
1477        assert!(!is_path_alias("@s/lowercase"));
1478    }
1479
1480    // is_angular_lifecycle_method tests
1481    #[test]
1482    fn angular_lifecycle_core_hooks() {
1483        assert!(is_angular_lifecycle_method("ngOnInit"));
1484        assert!(is_angular_lifecycle_method("ngOnDestroy"));
1485        assert!(is_angular_lifecycle_method("ngOnChanges"));
1486        assert!(is_angular_lifecycle_method("ngAfterViewInit"));
1487    }
1488
1489    #[test]
1490    fn angular_lifecycle_check_hooks() {
1491        assert!(is_angular_lifecycle_method("ngDoCheck"));
1492        assert!(is_angular_lifecycle_method("ngAfterContentChecked"));
1493        assert!(is_angular_lifecycle_method("ngAfterViewChecked"));
1494    }
1495
1496    #[test]
1497    fn angular_lifecycle_content_hooks() {
1498        assert!(is_angular_lifecycle_method("ngAfterContentInit"));
1499        assert!(is_angular_lifecycle_method("ngAcceptInputType"));
1500    }
1501
1502    #[test]
1503    fn angular_lifecycle_guard_resolver_methods() {
1504        assert!(is_angular_lifecycle_method("canActivate"));
1505        assert!(is_angular_lifecycle_method("canDeactivate"));
1506        assert!(is_angular_lifecycle_method("canActivateChild"));
1507        assert!(is_angular_lifecycle_method("canMatch"));
1508        assert!(is_angular_lifecycle_method("resolve"));
1509        assert!(is_angular_lifecycle_method("intercept"));
1510        assert!(is_angular_lifecycle_method("transform"));
1511    }
1512
1513    #[test]
1514    fn angular_lifecycle_form_methods() {
1515        assert!(is_angular_lifecycle_method("validate"));
1516        assert!(is_angular_lifecycle_method("registerOnChange"));
1517        assert!(is_angular_lifecycle_method("registerOnTouched"));
1518        assert!(is_angular_lifecycle_method("writeValue"));
1519        assert!(is_angular_lifecycle_method("setDisabledState"));
1520    }
1521
1522    #[test]
1523    fn not_angular_lifecycle_method() {
1524        assert!(!is_angular_lifecycle_method("onClick"));
1525        assert!(!is_angular_lifecycle_method("handleSubmit"));
1526        assert!(!is_angular_lifecycle_method("render"));
1527    }
1528}