diff --git a/extensions/npm/src/commands.ts b/extensions/npm/src/commands.ts index 8fda1077740..6847f322631 100644 --- a/extensions/npm/src/commands.ts +++ b/extensions/npm/src/commands.ts @@ -22,10 +22,7 @@ export function runSelectedScript(context: vscode.ExtensionContext) { } let document = editor.document; let contents = document.getText(); - let selection = editor.selection; - let offset = document.offsetAt(selection.anchor); - - let script = findScriptAtPosition(contents, offset); + let script = findScriptAtPosition(editor.document, contents, editor.selection.anchor); if (script) { runScript(context, script, document); } else { diff --git a/extensions/npm/src/npmMain.ts b/extensions/npm/src/npmMain.ts index 568c5ea3d6f..6ed3b7532ab 100644 --- a/extensions/npm/src/npmMain.ts +++ b/extensions/npm/src/npmMain.ts @@ -10,6 +10,7 @@ import { runSelectedScript, selectAndRunScriptFromFolder } from './commands'; import { NpmScriptsTreeDataProvider } from './npmView'; import { getPackageManager, invalidateTasksCache, NpmTaskProvider } from './tasks'; import { invalidateHoverScriptsCache, NpmScriptHoverProvider } from './scriptHover'; +import { NpmScriptLensProvider } from './npmScriptLens'; let treeDataProvider: NpmScriptsTreeDataProvider | undefined; @@ -62,6 +63,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } return ''; })); + context.subscriptions.push(new NpmScriptLensProvider()); } function canRunNpmInCurrentWorkspace() { diff --git a/extensions/npm/src/npmScriptLens.ts b/extensions/npm/src/npmScriptLens.ts new file mode 100644 index 00000000000..067209da334 --- /dev/null +++ b/extensions/npm/src/npmScriptLens.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { + CodeLens, + CodeLensProvider, + Disposable, + EventEmitter, + languages, + TextDocument, + Uri, + workspace +} from 'vscode'; +import * as nls from 'vscode-nls'; +import { findPreferredPM } from './preferred-pm'; +import { readScripts } from './readScripts'; + +const localize = nls.loadMessageBundle(); + +const enum Constants { + ConfigKey = 'debug.javascript.codelens.npmScripts', +} + +const getFreshLensLocation = () => workspace.getConfiguration().get(Constants.ConfigKey); + +/** + * Npm script lens provider implementation. Can show a "Debug" text above any + * npm script, or the npm scripts section. + */ +export class NpmScriptLensProvider implements CodeLensProvider, Disposable { + private lensLocation = getFreshLensLocation(); + private changeEmitter = new EventEmitter(); + private subscriptions: Disposable[] = []; + + /** + * @inheritdoc + */ + public onDidChangeCodeLenses = this.changeEmitter.event; + + constructor() { + this.subscriptions.push( + workspace.onDidChangeConfiguration(evt => { + if (evt.affectsConfiguration(Constants.ConfigKey)) { + this.lensLocation = getFreshLensLocation(); + this.changeEmitter.fire(); + } + }), + languages.registerCodeLensProvider( + { + language: 'json', + pattern: '**/package.json', + }, + this, + ) + ); + } + + /** + * @inheritdoc + */ + public async provideCodeLenses(document: TextDocument): Promise { + if (this.lensLocation === 'never') { + return []; + } + + const tokens = readScripts(document); + if (!tokens) { + return []; + } + + const title = localize('codelens.debug', '{0} Debug', '$(debug-start)'); + const cwd = path.dirname(document.uri.fsPath); + if (this.lensLocation === 'top') { + return [ + new CodeLens( + tokens.location.range, + { + title, + command: 'extension.js-debug.npmScript', + arguments: [cwd], + }, + ), + ]; + } + + if (this.lensLocation === 'all') { + const packageManager = await findPreferredPM(Uri.joinPath(document.uri, '..').fsPath); + return tokens.scripts.map( + ({ name, nameRange }) => + new CodeLens( + nameRange, + { + title, + command: 'extension.js-debug.createDebuggerTerminal', + arguments: [`${packageManager.name} run ${name}`, workspace.getWorkspaceFolder(document.uri), { cwd }], + }, + ), + ); + } + + return []; + } + + /** + * @inheritdoc + */ + public dispose() { + this.subscriptions.forEach(s => s.dispose()); + } +} diff --git a/extensions/npm/src/npmView.ts b/extensions/npm/src/npmView.ts index 45b131e529f..553558de030 100644 --- a/extensions/npm/src/npmView.ts +++ b/extensions/npm/src/npmView.ts @@ -3,21 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { JSONVisitor, visit } from 'jsonc-parser'; import * as path from 'path'; import { commands, Event, EventEmitter, ExtensionContext, Range, Selection, Task, TaskGroup, tasks, TextDocument, TextDocumentShowOptions, ThemeIcon, TreeDataProvider, TreeItem, TreeItemLabel, TreeItemCollapsibleState, Uri, - window, workspace, WorkspaceFolder + window, workspace, WorkspaceFolder, Position, Location } from 'vscode'; import * as nls from 'vscode-nls'; +import { readScripts } from './readScripts'; import { createTask, getPackageManager, getTaskName, isAutoDetectionEnabled, isWorkspaceFolder, NpmTaskDefinition, NpmTaskProvider, startDebugging, - TaskLocation, TaskWithLocation } from './tasks'; @@ -78,7 +77,7 @@ class NpmScript extends TreeItem { task: Task; package: PackageJSON; - constructor(_context: ExtensionContext, packageJson: PackageJSON, task: Task, public taskLocation?: TaskLocation) { + constructor(_context: ExtensionContext, packageJson: PackageJSON, task: Task, public taskLocation?: Location) { super(task.name, TreeItemCollapsibleState.None); const command: ExplorerCommands = workspace.getConfiguration('npm').get('scriptExplorerAction') || 'open'; @@ -87,9 +86,9 @@ class NpmScript extends TreeItem { title: 'Edit Script', command: 'vscode.open', arguments: [ - taskLocation?.document, + taskLocation?.uri, taskLocation ? { - selection: new Range(taskLocation.line, taskLocation.line) + selection: new Range(taskLocation.range.start, taskLocation.range.start) } : undefined ] }, @@ -153,37 +152,18 @@ export class NpmScriptsTreeDataProvider implements TreeDataProvider { startDebugging(this.extensionContext, script.task.definition.script, path.dirname(script.package.resourceUri!.fsPath), script.getFolder()); } - private findScript(document: TextDocument, script?: NpmScript): number { - let scriptOffset = 0; - let inScripts = false; + private findScriptPosition(document: TextDocument, script?: NpmScript) { + const scripts = readScripts(document); + if (!scripts) { + return undefined; + } - let visitor: JSONVisitor = { - onError() { - return scriptOffset; - }, - onObjectEnd() { - if (inScripts) { - inScripts = false; - } - }, - onObjectProperty(property: string, offset: number, _length: number) { - if (property === 'scripts') { - inScripts = true; - if (!script) { // select the script section - scriptOffset = offset; - } - } - else if (inScripts && script) { - let label = getTaskName(property, script.task.definition.path); - if (script.task.name === label) { - scriptOffset = offset; - } - } - } - }; - visit(document.getText(), visitor); - return scriptOffset; + if (!script) { + return scripts.location.range.start; + } + const found = scripts.scripts.find(s => getTaskName(s.name, script.task.definition.path) === script.task.name); + return found?.nameRange.start; } private async runInstall(selection: PackageJSON) { @@ -209,8 +189,7 @@ export class NpmScriptsTreeDataProvider implements TreeDataProvider { return; } let document: TextDocument = await workspace.openTextDocument(uri); - let offset = this.findScript(document, selection instanceof NpmScript ? selection : undefined); - let position = document.positionAt(offset); + let position = this.findScriptPosition(document, selection instanceof NpmScript ? selection : undefined) || new Position(0, 0); await window.showTextDocument(document, { preserveFocus: true, selection: new Selection(position, position) }); } diff --git a/extensions/npm/src/readScripts.ts b/extensions/npm/src/readScripts.ts new file mode 100644 index 00000000000..bcceabf4f01 --- /dev/null +++ b/extensions/npm/src/readScripts.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { JSONVisitor, visit } from 'jsonc-parser'; +import { Location, Position, Range, TextDocument } from 'vscode'; + +export interface INpmScriptReference { + name: string; + value: string; + nameRange: Range; + valueRange: Range; +} + +export interface INpmScriptInfo { + location: Location; + scripts: INpmScriptReference[]; +} + +export const readScripts = (document: TextDocument, buffer = document.getText()): INpmScriptInfo | undefined => { + let start: Position | undefined; + let end: Position | undefined; + let inScripts = false; + let buildingScript: { name: string; nameRange: Range } | void; + let level = 0; + + const scripts: INpmScriptReference[] = []; + const visitor: JSONVisitor = { + onError() { + // no-op + }, + onObjectBegin() { + level++; + }, + onObjectEnd(offset) { + if (inScripts) { + end = document.positionAt(offset); + inScripts = false; + } + level--; + }, + onLiteralValue(value: unknown, offset: number, length: number) { + if (buildingScript && typeof value === 'string') { + scripts.push({ + ...buildingScript, + value, + valueRange: new Range(document.positionAt(offset), document.positionAt(offset + length)), + }); + buildingScript = undefined; + } + }, + onObjectProperty(property: string, offset: number, length: number) { + if (level === 1 && property === 'scripts') { + inScripts = true; + start = document.positionAt(offset); + } else if (inScripts) { + buildingScript = { + name: property, + nameRange: new Range(document.positionAt(offset), document.positionAt(offset + length)) + }; + } + }, + }; + + visit(buffer, visitor); + + if (start === undefined) { + return undefined; + } + + return { location: new Location(document.uri, new Range(start, end ?? start)), scripts }; +}; diff --git a/extensions/npm/src/scriptHover.ts b/extensions/npm/src/scriptHover.ts index d86f9d21caf..02a2d4d5781 100644 --- a/extensions/npm/src/scriptHover.ts +++ b/extensions/npm/src/scriptHover.ts @@ -3,20 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - ExtensionContext, TextDocument, commands, ProviderResult, CancellationToken, - workspace, tasks, Range, HoverProvider, Hover, Position, MarkdownString, Uri -} from 'vscode'; -import { - createTask, startDebugging, findAllScriptRanges, getPackageManager -} from './tasks'; -import * as nls from 'vscode-nls'; import { dirname } from 'path'; +import { + CancellationToken, commands, ExtensionContext, + Hover, HoverProvider, MarkdownString, Position, ProviderResult, + tasks, TextDocument, + Uri, workspace +} from 'vscode'; +import * as nls from 'vscode-nls'; +import { INpmScriptInfo, readScripts } from './readScripts'; +import { + createTask, + getPackageManager, startDebugging +} from './tasks'; const localize = nls.loadMessageBundle(); let cachedDocument: Uri | undefined = undefined; -let cachedScriptsMap: Map | undefined = undefined; +let cachedScripts: INpmScriptInfo | undefined = undefined; export function invalidateHoverScriptsCache(document?: TextDocument) { if (!document) { @@ -42,20 +46,16 @@ export class NpmScriptHoverProvider implements HoverProvider { let hover: Hover | undefined = undefined; if (!cachedDocument || cachedDocument.fsPath !== document.uri.fsPath) { - cachedScriptsMap = findAllScriptRanges(document.getText()); + cachedScripts = readScripts(document); cachedDocument = document.uri; } - cachedScriptsMap!.forEach((value, key) => { - let start = document.positionAt(value[0]); - let end = document.positionAt(value[0] + value[1]); - let range = new Range(start, end); - - if (range.contains(position)) { + cachedScripts?.scripts.forEach(({ name, nameRange }) => { + if (nameRange.contains(position)) { let contents: MarkdownString = new MarkdownString(); contents.isTrusted = true; - contents.appendMarkdown(this.createRunScriptMarkdown(key, document.uri)); - contents.appendMarkdown(this.createDebugScriptMarkdown(key, document.uri)); + contents.appendMarkdown(this.createRunScriptMarkdown(name, document.uri)); + contents.appendMarkdown(this.createDebugScriptMarkdown(name, document.uri)); hover = new Hover(contents); } }); diff --git a/extensions/npm/src/tasks.ts b/extensions/npm/src/tasks.ts index 9b8c7467f88..53bd085caed 100644 --- a/extensions/npm/src/tasks.ts +++ b/extensions/npm/src/tasks.ts @@ -5,15 +5,15 @@ import { TaskDefinition, Task, TaskGroup, WorkspaceFolder, RelativePattern, ShellExecution, Uri, workspace, - DebugConfiguration, debug, TaskProvider, TextDocument, tasks, TaskScope, QuickPickItem, window, Position, ExtensionContext, env, - ShellQuotedString, ShellQuoting + TaskProvider, TextDocument, tasks, TaskScope, QuickPickItem, window, Position, ExtensionContext, env, + ShellQuotedString, ShellQuoting, commands, Location } from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import * as minimatch from 'minimatch'; import * as nls from 'vscode-nls'; -import { JSONVisitor, visit, ParseErrorCode } from 'jsonc-parser'; import { findPreferredPM } from './preferred-pm'; +import { readScripts } from './readScripts'; const localize = nls.loadMessageBundle(); @@ -40,7 +40,7 @@ export interface TaskLocation { export interface TaskWithLocation { task: Task, - location?: TaskLocation + location?: Location } export class NpmTaskProvider implements TaskProvider { @@ -280,24 +280,24 @@ async function provideNpmScriptsForFolder(context: ExtensionContext, packageJson const prePostScripts = getPrePostScripts(scripts); const packageManager = await getPackageManager(context, folder.uri, showWarning); - for (const each of scripts.keys()) { - const scriptValue = scripts.get(each)!; - const task = await createTask(packageManager, each, ['run', each], folder!, packageJsonUri, scriptValue.script); - const lowerCaseTaskName = each.toLowerCase(); + for (const { name, value, nameRange } of scripts.scripts) { + const task = await createTask(packageManager, name, ['run', name], folder!, packageJsonUri, value); + const lowerCaseTaskName = name.toLowerCase(); if (isBuildTask(lowerCaseTaskName)) { task.group = TaskGroup.Build; } else if (isTestTask(lowerCaseTaskName)) { task.group = TaskGroup.Test; } - if (prePostScripts.has(each)) { + if (prePostScripts.has(name)) { task.group = TaskGroup.Clean; // hack: use Clean group to tag pre/post scripts } // todo@connor4312: all scripts are now debuggable, what is a 'debug script'? - if (isDebugScript(scriptValue.script)) { + if (isDebugScript(value)) { task.group = TaskGroup.Rebuild; // hack: use Rebuild group to tag debug scripts } - result.push({ task, location: scriptValue.location }); + + result.push({ task, location: new Location(packageJsonUri, nameRange) }); } // always add npm install (without a problem matcher) @@ -398,145 +398,33 @@ export async function runScript(context: ExtensionContext, script: string, docum } export async function startDebugging(context: ExtensionContext, scriptName: string, cwd: string, folder: WorkspaceFolder) { - const config: DebugConfiguration = { - type: 'pwa-node', - request: 'launch', - name: `Debug ${scriptName}`, - cwd, - runtimeExecutable: await getPackageManager(context, folder.uri), - runtimeArgs: [ - 'run', - scriptName, - ], - }; - - if (folder) { - debug.startDebugging(folder, config); - } + commands.executeCommand( + 'extension.js-debug.createDebuggerTerminal', + `${await getPackageManager(context, folder.uri)} run ${scriptName}`, + folder, + { cwd }, + ); } export type StringMap = { [s: string]: string; }; -async function findAllScripts(document: TextDocument, buffer: string): Promise> { - let scripts: Map = new Map(); - let script: string | undefined = undefined; - let inScripts = false; - let scriptOffset = 0; +export function findScriptAtPosition(document: TextDocument, buffer: string, position: Position): string | undefined { + const read = readScripts(document, buffer); + if (!read) { + return undefined; + } - let visitor: JSONVisitor = { - onError(_error: ParseErrorCode, _offset: number, _length: number) { - console.log(_error); - }, - onObjectEnd() { - if (inScripts) { - inScripts = false; - } - }, - onLiteralValue(value: any, _offset: number, _length: number) { - if (script) { - if (typeof value === 'string') { - scripts.set(script, { script: value, location: { document: document.uri, line: document.positionAt(scriptOffset) } }); - } - script = undefined; - } - }, - onObjectProperty(property: string, offset: number, _length: number) { - if (property === 'scripts') { - inScripts = true; - } - else if (inScripts && !script) { - script = property; - scriptOffset = offset; - } else { // nested object which is invalid, ignore the script - script = undefined; - } + for (const script of read.scripts) { + if (script.nameRange.start.isBeforeOrEqual(position) && script.valueRange.end.isAfterOrEqual(position)) { + return script.name; } - }; - visit(buffer, visitor); - return scripts; + } + + return undefined; } -export function findAllScriptRanges(buffer: string): Map { - let scripts: Map = new Map(); - let script: string | undefined = undefined; - let offset: number; - let length: number; - - let inScripts = false; - - let visitor: JSONVisitor = { - onError(_error: ParseErrorCode, _offset: number, _length: number) { - }, - onObjectEnd() { - if (inScripts) { - inScripts = false; - } - }, - onLiteralValue(value: any, _offset: number, _length: number) { - if (script) { - scripts.set(script, [offset, length, value]); - script = undefined; - } - }, - onObjectProperty(property: string, off: number, len: number) { - if (property === 'scripts') { - inScripts = true; - } - else if (inScripts) { - script = property; - offset = off; - length = len; - } - } - }; - visit(buffer, visitor); - return scripts; -} - -export function findScriptAtPosition(buffer: string, offset: number): string | undefined { - let script: string | undefined = undefined; - let foundScript: string | undefined = undefined; - let inScripts = false; - let scriptStart: number | undefined; - let visitor: JSONVisitor = { - onError(_error: ParseErrorCode, _offset: number, _length: number) { - }, - onObjectEnd() { - if (inScripts) { - inScripts = false; - scriptStart = undefined; - } - }, - onLiteralValue(value: any, nodeOffset: number, nodeLength: number) { - if (inScripts && scriptStart) { - if (typeof value === 'string' && offset >= scriptStart && offset < nodeOffset + nodeLength) { - // found the script - inScripts = false; - foundScript = script; - } else { - script = undefined; - } - } - }, - onObjectProperty(property: string, nodeOffset: number) { - if (property === 'scripts') { - inScripts = true; - } - else if (inScripts) { - scriptStart = nodeOffset; - script = property; - } else { // nested object which is invalid, ignore the script - script = undefined; - } - } - }; - visit(buffer, visitor); - return foundScript; -} - -export async function getScripts(packageJsonUri: Uri): Promise | undefined> { - +export async function getScripts(packageJsonUri: Uri) { if (packageJsonUri.scheme !== 'file') { return undefined; } @@ -548,9 +436,7 @@ export async function getScripts(packageJsonUri: Uri): Promise