diff --git a/CHANGELOG.md b/CHANGELOG.md index a77db87..a72cdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,20 @@ -## [1.3.0] - TBD 2023 +## [1.3.0] - July 2023 - Moved hosting to https://we.phorge.it/ and update the brand +- `arc lint` will now limit the number of concurrent subprocesses. Waiting calls will be collected to a joint + invocation. +- Will no longer trigger `arc lint` when switching between open editors. ## [1.2.0] - November 2020 - New feature: Hovercards for object mentions ## [1.1.0] - November 2020 - New feature: Browse in Diffusion - Some lints will show better squiggle. ## [1.0.0] - August 2020 - Initial release - Shows lint in editor diff --git a/src/arc_lint.ts b/src/arc_lint.ts index 9589ee8..bfcf979 100644 --- a/src/arc_lint.ts +++ b/src/arc_lint.ts @@ -1,160 +1,195 @@ import * as vscode from 'vscode'; import * as execa from 'execa'; -import * as path from 'path'; import { ArcanistLintMessage } from './arcanist_types'; import { setupCustomTranslators } from './arc_lint_translators'; - +import {TaskGroupingExecutor} from './task_grouping'; var LOG: vscode.OutputChannel; -export function setup(log: vscode.OutputChannel) { +var errorCollection: vscode.DiagnosticCollection; + +export function setup(log: vscode.OutputChannel, diagnosticCollection: vscode.DiagnosticCollection) { LOG = log; + errorCollection = diagnosticCollection; setupCustomTranslators(customLintTranslator); updateLintSeverityMap(); } -export function lintFile(document: vscode.TextDocument, errorCollection: vscode.DiagnosticCollection) { - if (document.uri.scheme !== "file") { return; } +const linterTaskExecutor = new TaskGroupingExecutor(2, lintFiles); + +async function lintFiles(documents: Iterable): Promise { - function handleExecResult(value: execa.ExecaReturnValue) { - if (!value.stdout) { - errorCollection.delete(document.uri); - return; + let docs_by_folder = new Map(); + + for (let document of documents) { + if (document.uri.scheme !== "file") { continue; } + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) { continue; } + + let doc_list: vscode.Uri[]; + if (docs_by_folder.has(folder)) { + doc_list = docs_by_folder.get(folder)!; + } else { + doc_list = new Array(); + docs_by_folder.set(folder, doc_list); } + doc_list.push(document.uri); + } + + const lint_flags = ['lint', '--output', 'json', '--']; + + for (const [folder, docs] of docs_by_folder) { + + const folder_root = folder.uri.fsPath; + + let paths: string[] = new Array(); + for (let document_uri of docs) { + // This looks bad... + paths.push(document_uri.fsPath.substring(folder_root.length+1)); + } + + let result: execa.ExecaReturnValue; try { - const lintMessages = JSON.parse(value.stdout); + result = await execa( + 'arc', lint_flags.concat(paths), + { cwd: folder_root }, + ); + } catch (error) { + result = error as execa.ExecaReturnValue; + } + + for (let document_uri of docs) { + errorCollection.delete(document_uri); + } + handleArcLintWithPath(result, folder, errorCollection); + } +} + +export function lintFile(document: vscode.TextDocument) { + if (document.uri.scheme !== "file") { return; } + + linterTaskExecutor.addTask(document); +} + +function handleArcLintWithPath( + value: execa.ExecaReturnValue, + folder: vscode.WorkspaceFolder, + diagnostics: vscode.DiagnosticCollection) { + // The output is best described as "json lines" - each line is a complete + // json object, with one key (filename). This might be a bug in Arcanist. + for (const line of value.stdout.split(/\r?\n/)) { + try { + const lintMessages = JSON.parse(line); for (const filename in lintMessages) { - // TODO: This only probably works because we call arc with a single file. - errorCollection.set(document.uri, lintJsonToDiagnostics(lintMessages[filename])); + const fileUri = vscode.Uri.joinPath(folder.uri, filename); + diagnostics.set(fileUri, lintJsonToDiagnostics(lintMessages[filename])); } } catch (e) { console.log("Ignoring error", e); } } - - const filename = document.uri.path; - - LOG.appendLine("linting "+ filename); - - execa( - 'arc', ['lint', '--output', 'json', '--', path.basename(filename)], - { cwd: path.dirname(filename) }, - ).then(handleExecResult, handleExecResult); } -export function lintEverything(errorCollection: vscode.DiagnosticCollection) { + +export function lintEverything() { if (!vscode.workspace.workspaceFolders) { return; } for (const folder of vscode.workspace.workspaceFolders) { function handleArcLintEverything(value: execa.ExecaReturnValue) { - // The output is best described as "json lines" - each line is a complete - // json object, with one key (filename). This might be a bug in Arcanist. - for (const line of value.stdout.split(/\r?\n/)) { - try { - const lintMessages = JSON.parse(line); - for (const filename in lintMessages) { - const fileUri = vscode.Uri.joinPath(folder.uri, filename); - errorCollection.set(fileUri, lintJsonToDiagnostics(lintMessages[filename])); - } - } catch (e) { - console.log("Ignoring error", e); - } - } + handleArcLintWithPath(value, folder, errorCollection); } if (folder.uri.scheme === "file") { execa( 'arc', ['lint', '--output', 'json', '--everything'], { cwd: folder.uri.fsPath }, ).then(handleArcLintEverything, handleArcLintEverything); } } } /** Possible Extra features: - quick-fix to apply patch - try to get better message by parsing `description` field (per message code...) - `locations` may be parsed into `relatedInformation`. */ export type LintTranslator = (lint: ArcanistLintMessage) => vscode.Diagnostic; let customLintTranslator: Map = new Map(); export function getRangeForLint(lint: ArcanistLintMessage): vscode.Range { - let line = lint.line == null? 1: lint.line -1; - let char = lint.char == null? 1: lint.char -1; + let line = lint.line == null ? 1 : lint.line - 1; + let char = lint.char == null ? 1 : lint.char - 1; if (lint.original) { let len = (lint.original).length; if (len > 0) { return new vscode.Range( line, char, line, char + len); } } return new vscode.Range( - line, char -1, // it's an artificial 3-chars wide thing. - line, char +1); + line, char - 1, // it's an artificial 3-chars wide thing. + line, char + 1); } export function defaultLintTranslator(lint: ArcanistLintMessage): vscode.Diagnostic { - let range = getRangeForLint(lint); - return { code: lint.code, message: message(lint), severity: severity(lint), source: 'arc lint', range: getRangeForLint(lint), }; } function message(lint: ArcanistLintMessage) { if (lint.description) { return lint.name + ": " + lint.description; } return lint.name; } let lintSeverityMap: Map; export function updateLintSeverityMap(): void { let config = vscode.workspace.getConfiguration('arc-vscode.lint'); let maxLevel: vscode.DiagnosticSeverity; switch (config.maxDiagnosticsLevel as string) { case 'hint': maxLevel = vscode.DiagnosticSeverity.Hint; break; case 'info': maxLevel = vscode.DiagnosticSeverity.Information; break; case 'warning': maxLevel = vscode.DiagnosticSeverity.Warning; break; case 'error': default: maxLevel = vscode.DiagnosticSeverity.Error; break; } function capped(level: vscode.DiagnosticSeverity): vscode.DiagnosticSeverity { return level > maxLevel ? level : maxLevel; } lintSeverityMap = new Map(); lintSeverityMap.set('disabled', capped(vscode.DiagnosticSeverity.Hint)); lintSeverityMap.set('autofix', capped(vscode.DiagnosticSeverity.Information)); lintSeverityMap.set('advice', capped(vscode.DiagnosticSeverity.Information)); lintSeverityMap.set('warning', capped(vscode.DiagnosticSeverity.Warning)); lintSeverityMap.set('error', capped(vscode.DiagnosticSeverity.Error)); } function severity(lint: ArcanistLintMessage): vscode.DiagnosticSeverity { return lintSeverityMap.get(lint.severity as string) || vscode.DiagnosticSeverity.Error; } function lintJsonToDiagnostics(lintResults: Array): vscode.Diagnostic[] { function translate(lint: ArcanistLintMessage): vscode.Diagnostic { let t = customLintTranslator.get(lint.code) || defaultLintTranslator; return t(lint); } return lintResults.map(translate); } diff --git a/src/extension.ts b/src/extension.ts index 3b7529a..c9831ad 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,56 +1,50 @@ import * as vscode from 'vscode'; import * as lint from './arc_lint'; import * as browse from './arc_browse'; import * as hovercard from './hovercard'; export function activate(context: vscode.ExtensionContext) { const log = vscode.window.createOutputChannel("arcanist"); const diagnostics = vscode.languages.createDiagnosticCollection('arc lint'); - lint.setup(log); + lint.setup(log, diagnostics); browse.setup(log); function d(disposable: vscode.Disposable) { context.subscriptions.push(disposable); } d(diagnostics); d(log); d(hovercard.register()); d(vscode.commands.registerCommand("arc-vscode.browseFile", browse.browseFile)); - d(vscode.commands.registerCommand('arc-vscode.clearLint', () => diagnostics.clear())); - d(vscode.commands.registerCommand('arc-vscode.lintEverything', () => lint.lintEverything(diagnostics))); + d(vscode.commands.registerCommand('arc-vscode.clearLint', diagnostics.clear)); + d(vscode.commands.registerCommand('arc-vscode.lintEverything', lint.lintEverything)); d(vscode.workspace.onDidSaveTextDocument(onTextDocumentEvent)); d(vscode.workspace.onDidOpenTextDocument(onTextDocumentEvent)); d(vscode.workspace.onDidChangeConfiguration(onChangeConfig)); if (vscode.window.activeTextEditor) { - lint.lintFile(vscode.window.activeTextEditor.document, diagnostics); + lint.lintFile(vscode.window.activeTextEditor.document); } - d(vscode.window.onDidChangeActiveTextEditor(editor => { - if (editor) { - lint.lintFile(editor.document, diagnostics); - } - })); - d(vscode.workspace.onDidCloseTextDocument(document => diagnostics.delete(document.uri))); function onTextDocumentEvent(document: vscode.TextDocument) { - lint.lintFile(document, diagnostics); + lint.lintFile(document); } } export function deactivate() { } function onChangeConfig(e: vscode.ConfigurationChangeEvent) { if (!e.affectsConfiguration('arc-vscode.lint')) { return; } lint.updateLintSeverityMap(); } diff --git a/src/task_grouping.ts b/src/task_grouping.ts new file mode 100644 index 0000000..f70fe00 --- /dev/null +++ b/src/task_grouping.ts @@ -0,0 +1,41 @@ +/** + * Sort of a limiting-queue - only N Tasks can be executed at the same time, + * and all other requests will wait their turn. + * + * All waiting requests will be collected into a single Task when a slot is + * available. + * + * I really hope extensions are single-threaded, because otherwise this will + * probably not work. + */ +export class TaskGroupingExecutor { + readonly concurrent_limit: number; + readonly pending = new Set(); + readonly task_handler: (tasks: Iterable) => Promise; + + active_tasks: number = 0; + + constructor(limit: number, task_handler: (tasks: Iterable) => Promise) { + this.concurrent_limit = limit; + this.task_handler = task_handler; + } + + addTask(task: Task) { + this.pending.add(task); + if (this.active_tasks < this.concurrent_limit) { + // triggering, but no waiting! + this.triggerHandler(); + } + } + + async triggerHandler() { + while (this.active_tasks < this.concurrent_limit && this.pending.size) { + const tasks = Array.from(this.pending); + this.pending.clear(); + + this.active_tasks ++; + await this.task_handler(tasks); + this.active_tasks --; + } + } +}