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
62 changes: 62 additions & 0 deletions src/test/extension/nodeConfigurationProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,68 @@ describe('NodeDebugConfigurationProvider', () => {
});
});

describe('workspaces outFiles', () => {
it('narrows to the debugged package and its workspace deps', async () => {
createFileTree(testFixturesDir, {
'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/*'] }),
packages: {
a: {
'index.js': '',
'package.json': JSON.stringify({ name: 'pkg-a', dependencies: { 'pkg-b': '^1.0.0' } }),
},
b: {
'index.js': '',
'package.json': JSON.stringify({ name: 'pkg-b' }),
},
c: {
'index.js': '',
'package.json': JSON.stringify({ name: 'pkg-c' }),
},
},
});

const result = await provider.resolveDebugConfiguration(folder, {
type: DebugType.Node,
name: '',
request: 'launch',
program: 'packages/a/index.js',
});

expect(result?.outFiles).to.deep.equal([
'${workspaceFolder}/**/*.(m|c|)js',
'!**/node_modules/**',
'${workspaceFolder}/packages/a/**/*.js',
'!${workspaceFolder}/packages/a/**/node_modules/**',
'${workspaceFolder}/packages/b/**/*.js',
'!${workspaceFolder}/packages/b/**/node_modules/**',
]);
});

it('does not narrow when there is no workspaces field', async () => {
createFileTree(testFixturesDir, {
'package.json': JSON.stringify({ name: 'root' }),
packages: {
a: {
'index.js': '',
'package.json': JSON.stringify({ name: 'pkg-a' }),
},
},
});

const result = await provider.resolveDebugConfiguration(folder, {
type: DebugType.Node,
name: '',
request: 'launch',
program: 'packages/a/index.js',
});

expect(result?.outFiles).to.deep.equal([
'${workspaceFolder}/**/*.(m|c|)js',
'!**/node_modules/**',
]);
});
});

describe('deno', () => {
it('fills in default deno options', async () => {
const result = (await provider.resolveDebugConfiguration(folder, {
Expand Down
193 changes: 191 additions & 2 deletions src/ui/configuration/nodeDebugConfigurationResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*--------------------------------------------------------*/

import * as l10n from '@vscode/l10n';
import { promises as fs } from 'fs';
import { Dirent, promises as fs } from 'fs';
import { inject, injectable } from 'inversify';
import * as path from 'path';
import * as vscode from 'vscode';
Expand Down Expand Up @@ -266,6 +266,13 @@ function getAbsoluteLocation(folder: vscode.WorkspaceFolder | undefined, relpath
*
* This used to narrow (#326), but I think this is undesirable behavior for
* most users (vscode#142641), so now it only widens the `outFiles`.
*
* As an exception, when the workspace folder itself is an npm/yarn
* workspaces root, we narrow `outFiles` down to the package being debugged
* plus its workspace-local dependencies. Without this, debugging any
* package in a large monorepo ends up scanning the whole repo for source
* maps, since the program's folder is already "inside" the workspace
* folder and the widen-only logic above never kicks in (#1730).
*/
async function guessOutFiles(
fsUtils: LocalFsUtils,
Expand All @@ -286,7 +293,15 @@ async function guessOutFiles(
programLocation = getAbsoluteLocation(folder, config.cwd);
}

if (!programLocation || isSubpathOrEqualTo(folder.uri.fsPath, programLocation)) {
if (!programLocation) {
return;
}

if (await tryGuessWorkspaceOutFiles(fsUtils, folder, programLocation, config)) {
return;
}

if (isSubpathOrEqualTo(folder.uri.fsPath, programLocation)) {
return;
}

Expand All @@ -310,6 +325,180 @@ async function guessOutFiles(
}
}

interface IWorkspacesPackageJson {
name?: string;
workspaces?: string[] | { packages?: string[] };
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

async function readPackageJsonAt(dir: string): Promise<IWorkspacesPackageJson | undefined> {
try {
return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8'));
} catch {
return undefined;
}
}

function getWorkspacePatterns(pkg: IWorkspacesPackageJson | undefined): string[] {
if (!pkg?.workspaces) {
return [];
}

return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
}

/**
* Expands a single npm/yarn `workspaces` glob entry into the workspace
* package directories it matches. Only literal paths and a single trailing
* `*` wildcard segment (e.g. `packages/*`) are supported, which covers the
* overwhelming majority of real-world workspace configs; anything more
* exotic (`**`, mid-pattern wildcards) is skipped rather than mishandled.
*/
async function expandWorkspacePattern(workspaceRoot: string, pattern: string): Promise<string[]> {
const segments = pattern.split('/').filter(Boolean);
const wildcardIndex = segments.indexOf('*');

if (wildcardIndex === -1) {
const dir = path.join(workspaceRoot, ...segments);
return (await existsInjected(fs, path.join(dir, 'package.json'))) ? [dir] : [];
}

if (wildcardIndex !== segments.length - 1 || segments.includes('**')) {
return [];
}

const base = path.join(workspaceRoot, ...segments.slice(0, wildcardIndex));
let entries: Dirent[];
try {
entries = await fs.readdir(base, { withFileTypes: true });
} catch {
return [];
}

const dirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.')) {
continue;
}
const dir = path.join(base, entry.name);
if (await existsInjected(fs, path.join(dir, 'package.json'))) {
dirs.push(dir);
}
}

return dirs;
}

/**
* Given the workspace packages' directories, returns the directory of the
* package being debugged plus the directories of any workspace-local
* package it transitively depends on.
*/
async function collectWorkspaceDependencyDirs(
packageDirs: string[],
pkgDir: string,
): Promise<string[]> {
const graph = new Map<string, { dir: string; deps: string[] }>();
const nameByDir = new Map<string, string>();

await Promise.all(
packageDirs.map(async dir => {
const pkg = await readPackageJsonAt(dir);
if (!pkg?.name) {
return;
}
const deps = Object.keys({
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies,
});
graph.set(pkg.name, { dir, deps });
nameByDir.set(dir, pkg.name);
}),
);

const included = new Set<string>([pkgDir]);
const startName = nameByDir.get(pkgDir);
const queue = startName ? [startName] : [];
const visited = new Set(queue);

while (queue.length) {
const node = graph.get(queue.shift()!);
if (!node) {
continue;
}

included.add(node.dir);
for (const dep of node.deps) {
if (graph.has(dep) && !visited.has(dep)) {
visited.add(dep);
queue.push(dep);
}
}
}

return [...included];
}

/**
* If the workspace folder is an npm/yarn workspaces root and the program
* being debugged lives in one of its packages, narrows `outFiles` to that
* package plus its workspace-local dependencies. Returns whether it
* applied (successfully or not -- either way the caller should not fall
* back to the widen-only logic once a workspaces root is found, since
* scanning the whole monorepo is the exact problem being avoided).
*/
async function tryGuessWorkspaceOutFiles(
fsUtils: LocalFsUtils,
folder: vscode.WorkspaceFolder,
programLocation: string,
config: ResolvingNodeLaunchConfiguration,
): Promise<boolean> {
const patterns = getWorkspacePatterns(await readPackageJsonAt(folder.uri.fsPath));
if (!patterns.length) {
return false;
}

const pkgDir = await nearestDirectoryWhere(
programLocation,
async p =>
!p.includes('node_modules') && (await fsUtils.exists(path.join(p, 'package.json')))
? p
: undefined,
);

if (!pkgDir || !isSubpathOrEqualTo(folder.uri.fsPath, pkgDir)) {
return false;
}

const packageDirs = (
await Promise.all(patterns.map(pattern => expandWorkspacePattern(folder.uri.fsPath, pattern)))
).flat();

const dirs = await collectWorkspaceDependencyDirs(packageDirs, pkgDir);
const outFiles = [...baseDefaults.outFiles];
let any = false;
for (const dir of dirs) {
const rel = forceForwardSlashes(path.relative(folder.uri.fsPath, dir));
if (!rel.length || rel.startsWith('..')) {
continue;
}
outFiles.push(
`\${workspaceFolder}/${rel}/**/*.js`,
`!\${workspaceFolder}/${rel}/**/node_modules/**`,
);
any = true;
}

if (any) {
config.outFiles = outFiles;
}

return true;
}

interface ITSConfig {
compilerOptions?: {
outDir: string;
Expand Down