Skip to content
Merged
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,10 @@ reflect the order features were added, not execution order (`Step 6` appears twi
Uses `oxc_resolver` with TypeScript-aware configuration:

- Looks for `tsconfig.base.json` in workspace root for path mappings
- Supports extensions: `.ts`, `.tsx`, `.js`, `.jsx`, `.d.ts`
- Supports extensions: `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.d.ts`, `.d.mts`, `.d.cts`
- TypeScript variants are preferred over their JS counterparts, and `.mjs`/`.cjs`
specifiers resolve to `.mts`/`.cts` sources respectively (via `extension_alias`),
matching TypeScript's "import with output extension" convention
- Handles both relative imports and workspace path aliases

### Workspace Specifier Matching (Known Pitfall)
Expand Down
9 changes: 1 addition & 8 deletions src/semantic/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,7 @@ impl WorkspaceAnalyzer {
e.map_err(|err| warn!("Failed to read directory entry: {}", err))
.ok()
})
.filter(|e| {
e.file_type().is_file()
&& e
.path()
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| matches!(ext, "ts" | "tsx" | "js" | "jsx"))
})
.filter(|e| e.file_type().is_file() && crate::utils::is_source_file(e.path()))
.map(|e| {
let abs_path = e.into_path();
let rel_path = abs_path
Expand Down
41 changes: 37 additions & 4 deletions src/semantic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ pub(crate) use resolve_options::is_workspace_specifier;
pub(crate) use resolve_options::parse_tsconfig_path_prefixes;

/// Shared fallback resolution for relative imports when oxc_resolver fails.
/// Handles .js/.jsx → .ts/.tsx remapping and standard extension probing.
/// Handles .js/.jsx/.mjs/.cjs → TypeScript-equivalent remapping and standard
/// extension probing. Mirrors the `extensions` / `extension_alias` config in
/// `create_resolve_options` — kept in sync with it deliberately.
pub(crate) fn simple_resolve_relative(
cwd: &Path,
context: &Path,
Expand All @@ -31,8 +33,29 @@ pub(crate) fn simple_resolve_relative(
}
};

// 1. .js/.jsx → .ts/.tsx remapping (ESM convention)
if let Some(stem) = specifier.strip_suffix(".js") {
// 1. .js/.jsx/.mjs/.cjs → TypeScript-equivalent remapping (ESM convention).
// .mjs and .cjs are handled as separate branches (rather than merged into the
// .js branch) so an ESM-explicit specifier can't silently resolve to a
// CJS-explicit source or vice versa.
if let Some(stem) = specifier.strip_suffix(".mjs") {
let stem_path = context.join(stem);
let stem_str = stem_path.to_string_lossy();
for ext in &[".mts", ".mjs"] {
let candidate = PathBuf::from(format!("{}{}", stem_str, ext));
if let Some(p) = try_candidate(&candidate) {
return Some(p);
}
}
} else if let Some(stem) = specifier.strip_suffix(".cjs") {
let stem_path = context.join(stem);
let stem_str = stem_path.to_string_lossy();
for ext in &[".cts", ".cjs"] {
let candidate = PathBuf::from(format!("{}{}", stem_str, ext));
if let Some(p) = try_candidate(&candidate) {
return Some(p);
}
}
} else if let Some(stem) = specifier.strip_suffix(".js") {
let stem_path = context.join(stem);
let stem_str = stem_path.to_string_lossy();
for ext in &[".ts", ".tsx", ".js"] {
Expand All @@ -52,18 +75,28 @@ pub(crate) fn simple_resolve_relative(
}
}

// 2. Standard extension probing + index file resolution
// 2. Standard extension probing + index file resolution.
// TypeScript variants precede their JS counterparts, matching the ordering
// in `create_resolve_options`.
let base = context.join(specifier);
let base_str = base.to_string_lossy();
for suffix in &[
".ts",
".tsx",
".mts",
".cts",
".js",
".jsx",
".mjs",
".cjs",
"/index.ts",
"/index.tsx",
"/index.mts",
"/index.cts",
"/index.js",
"/index.jsx",
"/index.mjs",
"/index.cjs",
] {
let candidate = if let Some(stripped) = suffix.strip_prefix('/') {
base.join(stripped)
Expand Down
78 changes: 78 additions & 0 deletions src/semantic/reference_finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,84 @@ mod tests {
);
}

#[test]
fn test_simple_resolve_mjs_to_mts_remapping() {
// Test that imports with .mjs extensions resolve to .mts files through the
// `simple_resolve_relative` fallback (used when oxc_resolver fails to resolve).
// This exercises the ESM "import with output extension" convention, e.g.
// import { helper } from './utils.mjs' where the actual file is utils.mts.

let temp_dir = TempDir::new().expect("Failed to create temp dir");
let cwd = temp_dir.path();

// Create a test file: src/utils.mts (but NOT src/utils.mjs)
let src_dir = cwd.join("src");
fs::create_dir_all(&src_dir).expect("Failed to create src dir");
let utils_file = src_dir.join("utils.mts");
fs::write(&utils_file, "export function helper() {}").expect("Failed to write test file");

let profiler = Arc::new(Profiler::new(false));
let analyzer =
WorkspaceAnalyzer::new(vec![], cwd, profiler.clone()).expect("Failed to create analyzer");
let reference_finder = ReferenceFinder::new(&analyzer, cwd, profiler);

// Test: resolve "./utils.mjs" from src directory
// Should find utils.mts by stripping .mjs and trying .mts
let context = src_dir.as_path();
let specifier = "./utils.mjs";
let resolved = reference_finder.simple_resolve(context, specifier);

assert!(
resolved.is_some(),
"Expected to resolve utils.mjs to utils.mts"
);
let resolved_path = resolved.unwrap();
assert_eq!(
resolved_path,
PathBuf::from("src/utils.mts"),
"Expected to resolve ./utils.mjs to utils.mts"
);
}

#[test]
fn test_simple_resolve_cjs_to_cts_remapping() {
// Test that imports with .cjs extensions resolve to .cts files through the
// `simple_resolve_relative` fallback (used when oxc_resolver fails to resolve).
// This exercises the CJS "import with output extension" convention, e.g.
// import { helper } from './helper.cjs' where the actual file is helper.cts.

let temp_dir = TempDir::new().expect("Failed to create temp dir");
let cwd = temp_dir.path();

// Create a test file: src/helper.cts (but NOT src/helper.cjs)
let src_dir = cwd.join("src");
fs::create_dir_all(&src_dir).expect("Failed to create src dir");
let helper_file = src_dir.join("helper.cts");
fs::write(&helper_file, "export function helper() {}").expect("Failed to write test file");

let profiler = Arc::new(Profiler::new(false));
let analyzer =
WorkspaceAnalyzer::new(vec![], cwd, profiler.clone()).expect("Failed to create analyzer");
let reference_finder = ReferenceFinder::new(&analyzer, cwd, profiler);

// Test: resolve "./helper.cjs" from src directory
// Should find helper.cts by stripping .cjs and trying .cts
let context = src_dir.as_path();
let specifier = "./helper.cjs";
let resolved = reference_finder.simple_resolve(context, specifier);

assert!(
resolved.is_some(),
"Expected to resolve helper.cjs to helper.cts"
);
let resolved_path = resolved.unwrap();
assert_eq!(
resolved_path,
PathBuf::from("src/helper.cts"),
"Expected to resolve ./helper.cjs to helper.cts"
);
}

#[test]
fn test_simple_resolve_js_to_tsx_remapping() {
// Test that .js imports can resolve to .tsx files
Expand Down
23 changes: 21 additions & 2 deletions src/semantic/resolve_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,41 @@ pub fn create_resolve_options(cwd: &Path, projects: &[Project]) -> ResolveOption
.collect::<Vec<_>>();

ResolveOptions {
// TypeScript variants are listed before their JS counterparts (mirroring the
// existing .ts-before-.js ordering) so that when both a TS and a JS file could
// satisfy an extension-less specifier, the TypeScript source wins. The explicit
// ESM/CJS variants (.mts/.cts/.mjs/.cjs) are common in strict-ESM monorepos and
// dual-package (ESM+CJS) libraries; .d.mts/.d.cts fill the same declaration-file
// slot as .d.ts.
extensions: vec![
".ts".into(),
".tsx".into(),
".mts".into(),
".cts".into(),
".js".into(),
".jsx".into(),
".mjs".into(),
".cjs".into(),
".d.ts".into(),
".d.mts".into(),
".d.cts".into(),
],
// Map .js/.jsx imports to their TypeScript equivalents.
// Map .js/.jsx/.mjs/.cjs imports to their TypeScript equivalents.
// Handles the common ESM pattern where .ts files import with .js extensions
// (e.g., import { foo } from './bar.js' where the actual file is bar.ts).
// (e.g., import { foo } from './bar.js' where the actual file is bar.ts), and
// the analogous TypeScript "import with output extension" convention where a
// .mts/.cts source is imported via its compiled .mjs/.cjs extension.
// .mjs/.cjs are kept separate from .js (rather than folded into one shared
// alias) so an ESM-explicit specifier can't silently resolve to a CJS-explicit
// source or vice versa.
extension_alias: vec![
(
".js".into(),
vec![".ts".into(), ".tsx".into(), ".js".into()],
),
(".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
(".mjs".into(), vec![".mts".into(), ".mjs".into()]),
(".cjs".into(), vec![".cts".into(), ".cjs".into()]),
],
// Resolve bare package imports to source roots within the monorepo.
alias,
Expand Down
16 changes: 14 additions & 2 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ use rustc_hash::{FxHashMap, FxHashSet};
use std::path::{Path, PathBuf};
use tracing::debug;

/// Extensions considered as source files (analyzed by Oxc parser)
const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx"];
/// Extensions considered as source files (analyzed by Oxc parser).
///
/// Includes the explicit ESM/CJS variants (`.mts`/`.cts`/`.mjs`/`.cjs`) used by
/// strict-ESM monorepos and dual-package (ESM+CJS) libraries. `Path::extension()`
/// only returns the final segment, so this list also covers `.d.mts`/`.d.cts`
/// (which report as `mts`/`cts`) without needing separate entries.
const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];

/// Check if a file is a source file (TypeScript/JavaScript)
/// These are files that can be parsed by the Oxc parser
Expand Down Expand Up @@ -231,6 +236,13 @@ mod tests {
assert!(is_source_file(Path::new("app.jsx")));
assert!(is_source_file(Path::new("path/to/file.ts")));

// ESM/CJS explicit-extension source files (strict-ESM monorepos, dual-package libs)
assert!(is_source_file(Path::new("utils.mts")));
assert!(is_source_file(Path::new("utils.cts")));
assert!(is_source_file(Path::new("utils.mjs")));
assert!(is_source_file(Path::new("utils.cjs")));
assert!(is_source_file(Path::new("path/to/file.mts")));

// Non-source files
assert!(!is_source_file(Path::new("styles.css")));
assert!(!is_source_file(Path::new("template.html")));
Expand Down
Loading
Loading