Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
5 changes: 4 additions & 1 deletion extensions/npm/src/npmMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as vscode from 'vscode';
import { addJSONProviders } from './features/jsonContributions';
import { runSelectedScript, selectAndRunScriptFromFolder } from './commands';
import { NpmScriptsTreeDataProvider } from './npmView';
import { getScriptRunner, getPackageManager, invalidateTasksCache, NpmTaskProvider, hasPackageJson } from './tasks';
import { getScriptRunner, getPackageManager, invalidateTasksCache, NpmTaskProvider, hasPackageJson, initializePackageJsonCache } from './tasks';
import { invalidateHoverScriptsCache, NpmScriptHoverProvider } from './scriptHover';
import { NpmScriptLensProvider } from './npmScriptLens';
import which from 'which';
Expand All @@ -24,6 +24,9 @@ function invalidateScriptCaches() {
}

export async function activate(context: vscode.ExtensionContext): Promise<void> {
// Initialize package.json cache with workspace storage
initializePackageJsonCache(context);

configureHttpRequest();
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('http.proxy') || e.affectsConfiguration('http.proxyStrictSSL')) {
Expand Down
13 changes: 11 additions & 2 deletions extensions/npm/src/readScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { JSONVisitor, visit } from 'jsonc-parser';
import { Location, Position, Range, TextDocument } from 'vscode';
import { Location, Position, Range, Uri } from 'vscode';

export interface INpmScriptReference {
name: string;
Expand All @@ -18,7 +18,16 @@ export interface INpmScriptInfo {
scripts: INpmScriptReference[];
}

export const readScripts = (document: TextDocument, buffer = document.getText()): INpmScriptInfo | undefined => {
export interface IPositionOffsetTransformer {
readonly uri: Uri;
positionAt(offset: number): Position;
getText?(): string;
}

export const readScripts = (document: IPositionOffsetTransformer & { getText?(): string }, buffer?: string): INpmScriptInfo | undefined => {
if (!buffer) {
buffer = document.getText?.() ?? '';
}
let start: Position | undefined;
let end: Position | undefined;
let inScripts = false;
Expand Down
154 changes: 151 additions & 3 deletions extensions/npm/src/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as fs from 'fs';
import minimatch from 'minimatch';
import { Utils } from 'vscode-uri';
import { findPreferredPM } from './preferred-pm';
import { readScripts } from './readScripts';
import { readScripts, IPositionOffsetTransformer } from './readScripts';

const excludeRegex = new RegExp('^(node_modules|.vscode-test)$', 'i');

Expand All @@ -31,6 +31,103 @@ type AutoDetect = 'on' | 'off';

let cachedTasks: ITaskWithLocation[] | undefined = undefined;

interface IPackageJsonCache {
mtime: number;
scripts: ReturnType<typeof readScripts>;
}

// Simple LRU cache implementation for package.json entries
class PackageJsonLRUCache {
private cache = new Map<string, IPackageJsonCache>();
private accessOrder: string[] = [];
private readonly maxSize: number;
private readonly storageKey = 'npm.packageJsonCache';

constructor(private readonly context: ExtensionContext | undefined, maxSize: number = 128) {
this.maxSize = maxSize;
this.loadFromStorage();
}

private loadFromStorage(): void {
if (!this.context) {
return;
}
try {
const stored = this.context.workspaceState.get<Record<string, IPackageJsonCache>>(this.storageKey);
if (stored) {
// Restore cache entries (up to maxSize)
const entries = Object.entries(stored);
for (const [key, value] of entries.slice(-this.maxSize)) {
this.cache.set(key, value);
this.accessOrder.push(key);
}
}
} catch (e) {
// Ignore errors loading from storage
}
}

private async saveToStorage(): Promise<void> {
if (!this.context) {
return;
}
try {
const toStore: Record<string, IPackageJsonCache> = {};
this.cache.forEach((value, key) => {
toStore[key] = value;
});
await this.context.workspaceState.update(this.storageKey, toStore);
} catch (e) {
// Ignore errors saving to storage
}
}

get(key: string): IPackageJsonCache | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.accessOrder = this.accessOrder.filter(k => k !== key);
this.accessOrder.push(key);
}
return value;
}

set(key: string, value: IPackageJsonCache): void {
// If key exists, remove it from access order
if (this.cache.has(key)) {
this.accessOrder = this.accessOrder.filter(k => k !== key);
}

// Add to cache and access order
this.cache.set(key, value);
this.accessOrder.push(key);

// Evict least recently used if over capacity
if (this.cache.size > this.maxSize) {
const lru = this.accessOrder.shift();
if (lru !== undefined) {
this.cache.delete(lru);
}
}

// Persist to storage asynchronously
void this.saveToStorage();
}

clear(): void {
this.cache.clear();
this.accessOrder = [];
void this.saveToStorage();
}
}

// Cache for package.json file reading to avoid unnecessary reads
let packageJsonCache: PackageJsonLRUCache;

export function initializePackageJsonCache(context: ExtensionContext): void {
packageJsonCache = new PackageJsonLRUCache(context, 128);
}

export const INSTALL_SCRIPT = 'install';

export interface ITaskLocation {
Expand Down Expand Up @@ -88,6 +185,7 @@ export class NpmTaskProvider implements TaskProvider {

export function invalidateTasksCache() {
cachedTasks = undefined;
packageJsonCache.clear();
}

const buildNames: string[] = ['build', 'compile', 'watch'];
Expand Down Expand Up @@ -473,6 +571,39 @@ export function findScriptAtPosition(document: TextDocument, buffer: string, pos
return undefined;
}

// Helper to transform offsets to positions for package.json content
class PositionOffsetTransformer implements IPositionOffsetTransformer {
private readonly lineOffsets: number[];

constructor(public readonly uri: Uri, content: string) {
const lines = content.split(/\r?\n/);
this.lineOffsets = [0];
for (let i = 0; i < lines.length; i++) {
const currentOffset = this.lineOffsets[i] + lines[i].length;
// Check if there's a \r character (Windows line ending)
const nextCharOffset = currentOffset < content.length ? content[currentOffset] : undefined;
const lineEndingLength = nextCharOffset === '\r' ? 2 : 1;
this.lineOffsets.push(currentOffset + lineEndingLength);
}
}

positionAt(offset: number): Position {
let low = 0;
let high = this.lineOffsets.length - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (this.lineOffsets[mid] > offset) {
high = mid;
} else {
low = mid + 1;
}
}
const line = low - 1;
const character = offset - this.lineOffsets[line];
return new Position(line, character);
}
}

export async function getScripts(packageJsonUri: Uri) {
if (packageJsonUri.scheme !== 'file') {
return undefined;
Expand All @@ -484,8 +615,25 @@ export async function getScripts(packageJsonUri: Uri) {
}

try {
const document: TextDocument = await workspace.openTextDocument(packageJsonUri);
return readScripts(document);
// Check cache with mtime to avoid unnecessary file reads
const stat = await workspace.fs.stat(packageJsonUri);
const cached = packageJsonCache.get(packageJson);

if (cached && cached.mtime === stat.mtime) {
return cached.scripts;
}

// Use workspace.fs.readFile instead of openTextDocument to avoid triggering
// textDocument/didOpen notifications to language servers
const content = await workspace.fs.readFile(packageJsonUri);
const text = Buffer.from(content).toString('utf8');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use TextDecoder here for portability @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit fc51b35. Replaced Buffer.from(content).toString('utf8') with new TextDecoder('utf-8').decode(content) for better portability.

const transformer = new PositionOffsetTransformer(packageJsonUri, text);
const scripts = readScripts(transformer, text);

// Cache the result
packageJsonCache.set(packageJson, { mtime: stat.mtime, scripts });

return scripts;
} catch (e) {
const localizedParseError = l10n.t("Npm task detection: failed to parse the file {0}", packageJsonUri.fsPath);
throw new Error(localizedParseError);
Expand Down