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
2 changes: 1 addition & 1 deletion packages/language-server/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class LanguageServer {

if (!uri) return { capabilities: {} };

this.cwd = fileURLToPath(uri);
this.cwd = params.initializationOptions?.cwd ?? fileURLToPath(uri);

this.initConfig = params.initializationOptions?.config;

Expand Down
5 changes: 5 additions & 0 deletions packages/vscode-knip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@
"default": "",
"description": "Path to Knip configuration file (relative to workspace root)"
},
"knip.cwd": {
"type": "string",
"default": "",
"description": "Directory to run Knip from (its cwd), relative to the VS Code workspace folder. Use when the folder isn't the Knip project root, e.g. \"./typescript\", or \"../..\" for a monorepo root."
},
"knip.nodeRuntimePath": {
"type": "string",
"default": "",
Expand Down
19 changes: 15 additions & 4 deletions packages/vscode-knip/scripts/publish.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -69,6 +70,16 @@ await bundle('src/index.js', 'extension.js', [...extSession, '@knip/language-ser

const knipNm = join(dirname(fileURLToPath(import.meta.resolve('knip'))), '..', 'node_modules');

// Pin native bindings to the exact version of the bundled JS wrapper. `oxc-parser`
// is copied as-is and `oxc-resolver` is bundled by rolldown, both at the version
// resolved from knip's deps. `npm pack` without a version pulls the *latest*
// binding, which silently mismatches the JS wrapper and corrupts parsing.
const knipRequire = createRequire(join(knipNm, '..', 'package.json'));
const bindingVersion = pkgName => JSON.parse(readFileSync(knipRequire.resolve(`${pkgName}/package.json`), 'utf8')).version;
const oxcParserVersion = bindingVersion('oxc-parser');
const oxcResolverVersion = bindingVersion('oxc-resolver');
console.log(`Pinning bindings: @oxc-parser@${oxcParserVersion}, @oxc-resolver@${oxcResolverVersion}`);

cpSync(join(knipNm, 'jiti'), join(nm, 'jiti'), { recursive: true, dereference: true });
cpSync(join(knipNm, 'oxc-parser'), join(nm, 'oxc-parser'), { recursive: true, dereference: true });

Expand All @@ -91,11 +102,11 @@ const selectedTargets = args.target
? Object.entries(targets)
: [[currentTarget, targets[currentTarget]]];

const packNativeBinding = (scope, name, binding) => {
const packNativeBinding = (scope, name, binding, version) => {
rmSync(join(nm, scope), { recursive: true, force: true });
mkdirSync(join(nm, `${scope}/binding-${binding}`), { recursive: true });
const tmp = mkdtempSync(join(tmpdir(), 'oxc-'));
execSync(`npm pack ${scope}/binding-${binding}`, { cwd: tmp, stdio: 'pipe' });
execSync(`npm pack ${scope}/binding-${binding}@${version}`, { cwd: tmp, stdio: 'pipe' });
execSync('tar -xzf *.tgz', { cwd: tmp, stdio: 'pipe' });
cpSync(
execSync(`find ${tmp}/package -name "*.node"`, { encoding: 'utf-8' }).trim(),
Expand All @@ -106,8 +117,8 @@ const packNativeBinding = (scope, name, binding) => {
};

for (const [target, binding] of selectedTargets) {
packNativeBinding('@oxc-parser', 'parser', binding);
packNativeBinding('@oxc-resolver', 'resolver', binding);
packNativeBinding('@oxc-parser', 'parser', binding, oxcParserVersion);
packNativeBinding('@oxc-resolver', 'resolver', binding, oxcResolverVersion);

execSync(`pnpm vsce package ${flags} --target ${target}`, { cwd: root, stdio: 'inherit' });

Expand Down
51 changes: 44 additions & 7 deletions packages/vscode-knip/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ const require = createRequire(import.meta.url);
/** @param {string} value */
const toPosix = value => value.split(path.sep).join(path.posix.sep);

/**
* Directory Knip runs in (its cwd), from the `knip.cwd` setting. Absolute, or
* relative to the VS Code workspace folder; defaults to the folder itself.
* @param {import('vscode').WorkspaceFolder} folder
* @returns {string}
*/
function resolveKnipCwd(folder) {
const cwd = vscode.workspace.getConfiguration('knip', folder.uri).get('cwd', '');
const base = folder.uri.fsPath;
const trimmed = typeof cwd === 'string' ? cwd.trim() : '';
if (!trimmed) return base;
return path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(base, trimmed);
}

export class Extension {
/** @type {Extension | undefined} */
static #instance;
Expand Down Expand Up @@ -145,7 +159,18 @@ export class Extension {
}
}

this.#outputChannel.info(`Starting Knip Language Server for ${folder.name}`);
const cwd = resolveKnipCwd(folder);
if (cwd !== folder.uri.fsPath && !existsSync(path.join(cwd, 'package.json'))) {
this.#outputChannel.warn(
`knip.cwd for ${folder.name} resolved to ${cwd}, but no package.json is there — Knip will fail. Check the "knip.cwd" setting.`
);
}

this.#outputChannel.info(
cwd === folder.uri.fsPath
? `Starting Knip Language Server for ${folder.name}`
: `Starting Knip Language Server for ${folder.name} (cwd: ${cwd})`
);

const runtime = config.get('nodeRuntimePath', '') || 'node';

Expand All @@ -167,7 +192,7 @@ export class Extension {
fileEvents: [vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(folder, '**/*'))],
},
workspaceFolder: folder,
initializationOptions: { config },
initializationOptions: { config, cwd },
outputChannel: this.#outputChannel,
outputChannelName: 'Knip',
};
Expand Down Expand Up @@ -217,8 +242,18 @@ export class Extension {
}

#logManagedWorkspaces() {
const names = [...this.#clients.keys()].map(uri => fileURLToPath(uri));
this.#outputChannel.info(`Managing ${this.#clients.size} workspace(s): ${names.join(', ')}`);
const parts = [];
for (const key of this.#clients.keys()) {
const folder = vscode.workspace.workspaceFolders?.find(f => f.uri.toString() === key);
if (folder) {
const cwd = resolveKnipCwd(folder);
const ws = folder.uri.fsPath;
parts.push(cwd === ws ? cwd : `${ws} (Knip cwd: ${cwd})`);
} else {
parts.push(vscode.Uri.parse(key).fsPath);
}
}
this.#outputChannel.info(`Managing ${this.#clients.size} workspace(s): ${parts.join(', ')}`);
}

/**
Expand Down Expand Up @@ -450,7 +485,7 @@ export class Extension {

const folder = vscode.workspace.getWorkspaceFolder(document.uri);
if (!folder) return null;
const root = toPosix(folder.uri.fsPath);
const root = toPosix(resolveKnipCwd(folder));

if (path.basename(document.uri.fsPath) === 'package.json') {
if (!config.get('editor.dependencies.hover.enabled', true)) return null;
Expand Down Expand Up @@ -603,12 +638,14 @@ export class Extension {
* @returns {Promise<boolean>}
*/
async #hasKnipConfig(folder) {
const config = vscode.workspace.getConfiguration('knip');
const config = vscode.workspace.getConfiguration('knip', folder.uri);
const configFile = config.get('configFilePath', '');
const locations = configFile ? [configFile] : KNIP_CONFIG_LOCATIONS;

const rootUri = vscode.Uri.file(resolveKnipCwd(folder));

for (const location of locations) {
const candidate = vscode.Uri.joinPath(folder.uri, location);
const candidate = vscode.Uri.joinPath(rootUri, location);
try {
await vscode.workspace.fs.stat(candidate);
return true;
Expand Down
29 changes: 29 additions & 0 deletions templates/vscode-nested-root/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# vscode-nested-root — repro for PR #1667

The VS Code workspace folder (`repository/`) has **no `package.json`**. The
actual project lives one level down in `repository/frontend/`.

- `repository.code-workspace` opens `./repository` and sets
`"knip.cwd": "./frontend"`.

## Expected

| Knip root | Result |
| -------------------- | ------ |
| `repository/` (no `knip.cwd`, current `main`) | ❌ `ERROR: Unable to find package.json` in the Knip Output channel |
| `repository/frontend/` (with `knip.cwd`, PR #1667) | ✅ runs; reports `src/unused.ts` (genuine unused file) |

## CLI check

```sh
cd repository && knip # → ERROR: Unable to find package.json
cd repository/frontend && knip # → src/unused.ts unused (correct)
```

## Test in the editor

```sh
packages/vscode-knip/scripts/dev-install.sh
```

Then open `repository.code-workspace` and watch View → Output → "Knip".
11 changes: 11 additions & 0 deletions templates/vscode-nested-root/repository.code-workspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"folders": [
{
"name": "repository",
"path": "./repository"
}
],
"settings": {
"knip.cwd": "./frontend"
}
}
5 changes: 5 additions & 0 deletions templates/vscode-nested-root/repository/frontend/knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
}
6 changes: 6 additions & 0 deletions templates/vscode-nested-root/repository/frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "haven",
"version": "1.0.0",
"private": true,
"type": "module"
}
3 changes: 3 additions & 0 deletions templates/vscode-nested-root/repository/frontend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { shelter } from './used.ts';

console.log(shelter());
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const stranded = () => 'nobody imports this';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const shelter = () => 'safe';
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true
},
"include": ["src"]
}
Loading