Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ const resolver = new ResolverFactory();
assert(resolver.sync(process.cwd(), './index.js').path, path.join(cwd, 'index.js'));
```

#### File-based Resolution

For file-based resolution with automatic tsconfig discovery, use `resolveFileSync` or `resolveFileAsync`:

```javascript
const resolver = new ResolverFactory();

// Resolves from a file path (not directory)
const result = resolver.resolveFileSync('/path/to/file.ts', './module');

// Async version
const result = await resolver.resolveFileAsync('/path/to/file.ts', './module');
```

**Key Differences:**

- `sync(directory, specifier)` - Takes a **directory path**, uses manually configured tsconfig if provided
- `resolveFileSync(file, specifier)` - Takes a **file path**, automatically discovers tsconfig.json by traversing parent directories

**Why use `resolveFileSync`?**

When resolving from a specific file (e.g., in bundlers, linters, or language servers), `resolveFileSync` automatically finds the correct `tsconfig.json` by:

- Traversing parent directories from the file location
- Respecting TypeScript project references
- Honoring `include`, `exclude`, and `files` fields to determine which tsconfig applies
- Ensuring tsconfig `paths` aliases work correctly based on the file's context

#### Supports WASM

See https://stackblitz.com/edit/oxc-resolver for usage example.
Expand Down
12 changes: 12 additions & 0 deletions napi/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ export declare class ResolverFactory {
sync(directory: string, request: string): ResolveResult
/** Asynchronously resolve `specifier` at an absolute path to a `directory`. */
async(directory: string, request: string): Promise<ResolveResult>
/**
* Synchronously resolve `specifier` at an absolute path to a `file`.
*
* This method automatically discovers tsconfig.json by traversing parent directories.
*/
resolveFileSync(file: string, request: string): ResolveResult
/**
* Asynchronously resolve `specifier` at an absolute path to a `file`.
*
* This method automatically discovers tsconfig.json by traversing parent directories.
*/
resolveFileAsync(file: string, request: string): Promise<ResolveResult>
}

/** Node.js builtin module when `Options::builtin_modules` is enabled. */
Expand Down
113 changes: 82 additions & 31 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use std::{

use napi::{Either, Task, bindgen_prelude::AsyncTask};
use napi_derive::napi;
use oxc_resolver::{ResolveError, ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions};
use oxc_resolver::{
Resolution, ResolveError, ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions,
};

use self::options::{NapiResolveOptions, StrOrStrList};

Expand Down Expand Up @@ -52,36 +54,6 @@ pub struct Builtin {
pub is_runtime_module: bool,
}

fn resolve(resolver: &Resolver, path: &Path, request: &str) -> ResolveResult {
match resolver.resolve(path, request) {
Ok(resolution) => ResolveResult {
path: Some(resolution.full_path().to_string_lossy().to_string()),
error: None,
builtin: None,
module_type: resolution.module_type().map(ModuleType::from),
package_json_path: resolution
.package_json()
.and_then(|p| p.path().to_str())
.map(|p| p.to_string()),
},
Err(err) => {
let error = err.to_string();
ResolveResult {
path: None,
builtin: match err {
ResolveError::Builtin { resolved, is_runtime_module } => {
Some(Builtin { resolved, is_runtime_module })
}
_ => None,
},
module_type: None,
error: Some(error),
package_json_path: None,
}
}
}
}

#[napi(string_enum = "lowercase")]
pub enum ModuleType {
Module,
Expand Down Expand Up @@ -131,6 +103,26 @@ impl Task for ResolveTask {
}
}

pub struct ResolveFileTask {
resolver: Arc<Resolver>,
file: PathBuf,
request: String,
}

#[napi]
impl Task for ResolveFileTask {
type JsValue = ResolveResult;
type Output = ResolveResult;

fn compute(&mut self) -> napi::Result<Self::Output> {
Ok(resolve_file(&self.resolver, &self.file, &self.request))
}

fn resolve(&mut self, _: napi::Env, result: Self::Output) -> napi::Result<Self::JsValue> {
Ok(result)
}
}

#[napi]
pub struct ResolverFactory {
resolver: Arc<Resolver>,
Expand Down Expand Up @@ -185,6 +177,27 @@ impl ResolverFactory {
AsyncTask::new(ResolveTask { resolver, directory: path, request })
}

/// Synchronously resolve `specifier` at an absolute path to a `file`.
///
/// This method automatically discovers tsconfig.json by traversing parent directories.
#[allow(clippy::needless_pass_by_value)]
#[napi]
pub fn resolve_file_sync(&self, file: String, request: String) -> ResolveResult {
let path = PathBuf::from(file);
resolve_file(&self.resolver, &path, &request)
}

/// Asynchronously resolve `specifier` at an absolute path to a `file`.
///
/// This method automatically discovers tsconfig.json by traversing parent directories.
#[allow(clippy::needless_pass_by_value)]
#[napi]
pub fn resolve_file_async(&self, file: String, request: String) -> AsyncTask<ResolveFileTask> {
let path = PathBuf::from(file);
let resolver = self.resolver.clone();
AsyncTask::new(ResolveFileTask { resolver, file: path, request })
}

fn normalize_options(op: NapiResolveOptions) -> ResolveOptions {
let default = ResolveOptions::default();
// merging options
Expand Down Expand Up @@ -286,3 +299,41 @@ impl ResolverFactory {
}
}
}

fn map_resolution_to_result(result: Result<Resolution, ResolveError>) -> ResolveResult {
match result {
Ok(resolution) => ResolveResult {
path: Some(resolution.full_path().to_string_lossy().to_string()),
error: None,
builtin: None,
module_type: resolution.module_type().map(ModuleType::from),
package_json_path: resolution
.package_json()
.and_then(|p| p.path().to_str())
.map(|p| p.to_string()),
},
Err(err) => {
let error = err.to_string();
ResolveResult {
path: None,
builtin: match err {
ResolveError::Builtin { resolved, is_runtime_module } => {
Some(Builtin { resolved, is_runtime_module })
}
_ => None,
},
module_type: None,
error: Some(error),
package_json_path: None,
}
}
}
}

fn resolve(resolver: &Resolver, path: &Path, request: &str) -> ResolveResult {
map_resolution_to_result(resolver.resolve(path, request))
}

fn resolve_file(resolver: &Resolver, path: &Path, request: &str) -> ResolveResult {
map_resolution_to_result(resolver.resolve_file(path, request))
}
128 changes: 128 additions & 0 deletions napi/tests/file-resolve.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assert, test } from 'vitest';

import { ModuleType, ResolverFactory } from '../index.js';

const currentDir = join(fileURLToPath(import.meta.url), '..');
const cwd = join(currentDir, '..');
const rootDir = join(cwd, '..');
const fixturesDir = join(rootDir, 'fixtures');

test('resolveFileSync basic resolution', () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, './resolver.test.mjs');
assert.equal(result.path, join(currentDir, 'resolver.test.mjs'));
});

test('resolveFileSync with relative import', () => {
const resolver = new ResolverFactory();
const testFile = join(cwd, 'index.js');

const result = resolver.resolveFileSync(testFile, './src/lib.rs');
assert.equal(result.path, join(cwd, 'src', 'lib.rs'));
});

test('resolveFileSync error handling', () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, './nonexistent-file');
assert.isNotNull(result.error);
assert.isAbove(result.error.length, 0);
assert.isUndefined(result.path);
});

test('resolveFileAsync basic resolution', async () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');

const result = await resolver.resolveFileAsync(testFile, './resolver.test.mjs');
assert.equal(result.path, join(currentDir, 'resolver.test.mjs'));
});

test('resolveFileAsync error handling', async () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');

const result = await resolver.resolveFileAsync(testFile, './nonexistent');
assert.isNotNull(result.error);
assert.isAbove(result.error.length, 0);
assert.isUndefined(result.path);
});

test('resolveFileSync with module type', () => {
const resolver = new ResolverFactory({ moduleType: true });
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, '../index.js');
assert.equal(result.path, join(cwd, 'index.js'));
assert.isNotNull(result.moduleType);
});

test('resolveFileSync builtin module', () => {
const resolver = new ResolverFactory({ builtinModules: true });
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, 'node:fs');
assert.deepEqual(result.builtin, {
resolved: 'node:fs',
isRuntimeModule: true,
});
});

test('resolveFileSync builtin module without prefix', () => {
const resolver = new ResolverFactory({ builtinModules: true });
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, 'fs');
assert.deepEqual(result.builtin, {
resolved: 'node:fs',
isRuntimeModule: false,
});
});

test('resolveFileSync with extensions option', () => {
const resolver = new ResolverFactory({
extensions: ['.js', '.mjs', '.json'],
});
const testFile = join(currentDir, 'simple.test.mjs');

const result = resolver.resolveFileSync(testFile, './simple.test');
assert.equal(result.path, join(currentDir, 'simple.test.mjs'));
});

test('resolveFileAsync with module type', async () => {
const resolver = new ResolverFactory({ moduleType: true });
const testFile = join(currentDir, 'simple.test.mjs');

const result = await resolver.resolveFileAsync(testFile, '../index.js');
assert.equal(result.path, join(cwd, 'index.js'));
assert.isNotNull(result.moduleType);
});

test('sync and async return same results', async () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');
const request = './resolver.test.mjs';

const syncResult = resolver.resolveFileSync(testFile, request);
const asyncResult = await resolver.resolveFileAsync(testFile, request);

assert.deepEqual(syncResult, asyncResult);
});

test('sync and async return same errors', async () => {
const resolver = new ResolverFactory();
const testFile = join(currentDir, 'simple.test.mjs');
const request = './nonexistent-module';

const syncResult = resolver.resolveFileSync(testFile, request);
const asyncResult = await resolver.resolveFileAsync(testFile, request);

assert.equal(syncResult.path, asyncResult.path);
assert.isDefined(syncResult.error);
assert.isDefined(asyncResult.error);
});
Loading