Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import * as vscode from 'vscode';
import { Utils } from 'vscode-uri';
import { coalesce } from '../utils/arrays';
import { exists, looksLikeAbsoluteWindowsPath } from '../utils/fs';
import { resolvePackageJsonExports } from '../utils/packageExports';

function mapChildren<R>(node: jsonc.Node | undefined, f: (x: jsonc.Node) => R): R[] {
return node && node.type === 'array' && node.children
? node.children.map(f)
: [];
}

const maxPackageJsonCacheEntries = 100;
const packageJsonCache = new Map<string, { exports?: unknown }>();

const openExtendsLinkCommandId = '_typescript.openExtendsLink';

enum TsConfigLinkType {
Expand Down Expand Up @@ -156,6 +160,149 @@ async function resolveNodeModulesPath(baseDirUri: vscode.Uri, pathCandidates: st
}
}


/**
* Splits a Node module specifier into the package name and the (posix) subpath.
*
* @example
* - `lodash/fp` => { packageName: 'lodash', subpath: 'fp' }
* - `@scope/pkg/base/tsconfig.json` => { packageName: '@scope/pkg', subpath: 'base/tsconfig.json' }
*
* @see https://nodejs.org/api/esm.html#resolution-algorithm-specification (PACKAGE_RESOLVE)
*/
function parseNodeModuleSpecifier(specifier: string): { packageName: string; subpath: string } | undefined {
const parts = specifier.split(posix.sep).filter(Boolean);
if (!parts.length) {
return undefined;
}

if (parts[0].startsWith('@')) {
if (parts.length < 2) {
return undefined;
}
return {
packageName: `${parts[0]}/${parts[1]}`,
subpath: parts.slice(2).join(posix.sep)
};
}

return {
packageName: parts[0],
subpath: parts.slice(1).join(posix.sep)
};
}

/**
* Walks up from `baseDirUri` and looks for `node_modules/<packageName>`.
*
* @see https://nodejs.org/api/esm.html#resolution-algorithm-specification (PACKAGE_RESOLVE)
*/
async function findNodeModulePackageRoot(baseDirUri: vscode.Uri, packageName: string): Promise<vscode.Uri | undefined> {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(baseDirUri);
const workspaceRoot = workspaceFolder?.uri?.toString();

let currentUri = baseDirUri;
while (true) {
const candidate = vscode.Uri.joinPath(currentUri, 'node_modules', ...packageName.split(posix.sep));
try {
const stat = await vscode.workspace.fs.stat(candidate);
if (stat.type & vscode.FileType.Directory) {
return candidate;
}
} catch {
// noop
}

const oldUri = currentUri;
currentUri = vscode.Uri.joinPath(currentUri, '..');

// Stop at workspace or system root
if (oldUri.toString() === workspaceRoot || oldUri.path === currentUri.path) {
return undefined;
}
}
}

/**
* Reads and parses `<packageRoot>/package.json`.
*
* Note: For this feature we use a permissive JSONC parser and ignore parse errors,
* because the goal is best-effort link resolution.
*
* @see https://nodejs.org/api/esm.html#resolution-algorithm-specification (READ_PACKAGE_JSON)
*/
async function tryReadPackageJson(packageRoot: vscode.Uri): Promise<{ exports?: unknown } | undefined> {
const packageJsonUri = vscode.Uri.joinPath(packageRoot, 'package.json');
try {
const stat = await vscode.workspace.fs.stat(packageJsonUri);
const cacheKey = `${packageJsonUri.toString()}@${stat.mtime}`;
const cached = packageJsonCache.get(cacheKey);
if (cached) {
return cached;
}

const bytes = await vscode.workspace.fs.readFile(packageJsonUri);
const text = new TextDecoder('utf-8').decode(bytes);
const parsed = jsonc.parse(text, [], { allowTrailingComma: true });
if (typeof parsed !== 'object' || parsed === null) {
return undefined;
}

const value = parsed as { exports?: unknown };
packageJsonCache.set(cacheKey, value);

while (packageJsonCache.size > maxPackageJsonCacheEntries) {
const oldestKey = packageJsonCache.keys().next().value as string | undefined;
if (!oldestKey) {
break;
}
packageJsonCache.delete(oldestKey);
}

return value;
} catch {
return undefined;
}
}

/**
* Resolve a module specifier using `package.json#exports`.
*
* This is used for `tsconfig.json` `extends` links so that the link resolution
* matches Node/TypeScript behavior when packages use `exports` subpath remapping.
*
* @see https://nodejs.org/api/packages.html#package-entry-points
* @see https://nodejs.org/api/esm.html#resolution-algorithm-specification (PACKAGE_RESOLVE)
* @see https://nodejs.org/api/esm.html#resolution-algorithm-specification (PACKAGE_EXPORTS_RESOLVE)
*/
export async function resolveNodeModulePathUsingExports(baseDirUri: vscode.Uri, specifier: string): Promise<vscode.Uri | undefined> {
const parsed = parseNodeModuleSpecifier(specifier);
if (!parsed) {
return undefined;
}

const packageRoot = await findNodeModulePackageRoot(baseDirUri, parsed.packageName);
if (!packageRoot) {
return undefined;
}

const packageJson = await tryReadPackageJson(packageRoot);
const subpath = parsed.subpath ? `./${parsed.subpath}` : '.';
const resolvedTarget = resolvePackageJsonExports(packageJson?.exports, subpath, ['node', 'import'])
?? resolvePackageJsonExports(packageJson?.exports, subpath, ['node', 'require']);
if (!resolvedTarget) {
return undefined;
}

const normalized = resolvedTarget.startsWith('./') ? resolvedTarget.slice(2) : resolvedTarget;
if (!normalized) {
return undefined;
}

const targetUri = vscode.Uri.joinPath(packageRoot, normalized);
return await exists(targetUri) ? targetUri : undefined;
}

// Reference Extends:https://github.com/microsoft/TypeScript/blob/febfd442cdba343771f478cf433b0892f213ad2f/src/compiler/commandLineParser.ts#L3005
// Reference Project References: https://github.com/microsoft/TypeScript/blob/7377f5cb9db19d79a6167065b323a45611c812b5/src/compiler/tsbuild.ts#L188C1-L194C2
/**
Expand All @@ -181,6 +328,11 @@ async function getTsconfigPath(baseDirUri: vscode.Uri, pathValue: string, linkTy
}

// Otherwise resolve like a module
const exportsResolved = await resolveNodeModulePathUsingExports(baseDirUri, pathValue);
if (exportsResolved) {
return exportsResolved;
}

return resolveNodeModulesPath(baseDirUri, [
pathValue,
...pathValue.endsWith('.json') ? [] : [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import 'mocha';
import { resolvePackageJsonExports } from '../../utils/packageExports';

suite('package.json exports', () => {
test('resolves exports sugar string for main subpath', () => {
assert.strictEqual(resolvePackageJsonExports('./index.js', '.', ['node', 'import']), './index.js');
assert.strictEqual(resolvePackageJsonExports('./index.js', './x', ['node', 'import']), undefined);
});

test('rejects invalid exports sugar string target', () => {
assert.strictEqual(resolvePackageJsonExports('index.js', '.', ['node', 'import']), undefined);
});

test('treats `null` as an explicit exclusion that blocks broader patterns', () => {
const exportsField = {
'./*': './dist/*.js',
'./private/*': null,
};

assert.strictEqual(resolvePackageJsonExports(exportsField, './public/x', ['node', 'import']), './dist/public/x.js');
assert.strictEqual(resolvePackageJsonExports(exportsField, './private/secret', ['node', 'import']), undefined);
});

test('resolves exact subpath export', () => {
const exportsField = {
'./config.json': './configs/tsconfig.json'
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, './config.json', ['default']),
'./configs/tsconfig.json');
});

test('resolves subpath pattern exports', () => {
const exportsField = {
'./*/tsconfig.json': './lib/*/tsconfig.json'
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, './base/tsconfig.json', ['default']),
'./lib/base/tsconfig.json');
});

test('selects most specific wildcard key', () => {
const exportsField = {
'./foo/*.js': './out/foo/*.js',
'./foo/bar/*.js': './out/foo/bar/*.js'
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, './foo/bar/x.js', ['default']),
'./out/foo/bar/x.js');
});

test('conditional exports are matched in object insertion order', () => {
const exportsField = {
'.': {
default: './default.js',
import: './import.js'
}
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, '.', ['import']),
'./default.js');
});

test('conditional exports resolve a matching condition when ordered first', () => {
const exportsField = {
'.': {
import: './import.js',
default: './default.js'
}
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, '.', ['import']),
'./import.js');
});

test('conditional exports ignore invalid targets and continue', () => {
const exportsField = {
'.': {
import: 'import.js',
default: './default.js'
}
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, '.', ['import']),
'./default.js');
});

test('resolves array export by first resolvable entry', () => {
const exportsField = {
'./x': ['x.js', './x.js']
};

assert.strictEqual(
resolvePackageJsonExports(exportsField, './x', ['default']),
'./x.js');
});

test('returns undefined for unknown subpath', () => {
const exportsField = {
'./a': './a.js'
};

assert.strictEqual(resolvePackageJsonExports(exportsField, './b', ['default']), undefined);
});

test('rejects wildcard matches that include path traversal segments', () => {
const exportsField = {
'./*/x': './out/*/x'
};

assert.strictEqual(resolvePackageJsonExports(exportsField, './../x', ['default']), undefined);
});
});
Loading
Loading