diff --git a/README.md b/README.md index 1af9839b..56208fb3 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ console.log("concat Array", M.concat([1, 2], [2, 3])); - `deno.unstable` - If Deno's unstable mode is enabled. Default is `false` +- `deno.lint` - If inline `deno lint` diagnostics are enabled. Because this is experimental, `deno.unstable = true` is required. Default is `false` + We recommend that you do not set global configuration. It should be configured in `.vscode/settings.json` in the project directory: ```json5 @@ -164,6 +166,19 @@ This extension also provides Deno's formatting tools, settings are in `.vscode/s } ``` +This extension also provides inline `deno lint` diagnostics. You can enable this in `.vscode/settings.json`: + +NOTE: Since `deno lint` is still an experimental feature, you need to set `deno.unstable = true` in your VS Code settings. This function may change in the future. + +```json5 +// .vscode/settings.json +{ + "deno.enable": true, + "deno.unstable": true, + "deno.lint": true, +} +``` + ## Contribute Follow these steps to contribute, the community needs your strength. diff --git a/client/src/extension.ts b/client/src/extension.ts index 99ab3e47..81a73bf9 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -20,6 +20,7 @@ import { TextDocument, languages, env, + Position, } from "vscode"; import { LanguageClient, @@ -244,7 +245,10 @@ export class Extension { } const denoDiagnostics: Diagnostic[] = []; for (const diagnostic of context.diagnostics) { - if (diagnostic.source === "Deno Language Server") { + if ( + diagnostic.source === "Deno Language Server" || + diagnostic.source === "deno_lint" + ) { denoDiagnostics.push(diagnostic); } } @@ -356,29 +360,39 @@ Executable ${this.denoInfo.executablePath}`; [command: string]: ( editor: TextEditor, text: string, - range: Range + range: Range, + ...args: unknown[] ) => void | Promise; }) { for (const command in map) { const handler = map[command]; - this.registerCommand(command, async (uri: string, range: Range) => { - const textEditor = window.activeTextEditor; + this.registerCommand( + command, + async (uri: string, range: Range, ...args: unknown[]) => { + const textEditor = window.activeTextEditor; - if (!textEditor || textEditor.document.uri.toString() !== uri) { - return; - } + if (!textEditor || textEditor.document.uri.toString() !== uri) { + return; + } - range = new Range( - range.start.line, - range.start.character, - range.end.line, - range.end.character - ); + range = new Range( + range.start.line, + range.start.character, + range.end.line, + range.end.character + ); - const rangeText = textEditor.document.getText(range); + const rangeText = textEditor.document.getText(range); - return await handler.call(this, textEditor, rangeText, range); - }); + return await handler.call( + this, + textEditor, + rangeText, + range, + ...args + ); + } + ); } } // update diagnostic for a Document @@ -596,6 +610,40 @@ Executable ${this.denoInfo.executablePath}`; this.updateDiagnostic(editor.document.uri); }, + _ignore_next_line_lint: async (editor, _, range, rule: unknown) => { + editor.edit((edit) => { + const currentLineText = editor.document.lineAt(range.start.line); + const previousLineText = editor.document.lineAt(range.start.line - 1); + + const offset = + currentLineText.text.length - currentLineText.text.trim().length; + + if (/^\s*\/\/\s+deno-lint-ignore\s*/.test(previousLineText.text)) { + edit.replace( + previousLineText.range, + previousLineText.text + " " + rule + ); + } else { + edit.replace( + previousLineText.range, + previousLineText.text + + "\n" + + `${" ".repeat(offset)}// deno-lint-ignore ${rule}` + ); + } + }); + return; + }, + _ignore_entry_file: async (editor) => { + editor.edit((edit) => { + const firstLineText = editor.document.lineAt(0); + edit.insert( + new Position(0, 0), + "// deno-lint-ignore-file" + (firstLineText.text ? "\n" : "") + ); + }); + return; + }, }); this.watchConfiguration(() => { diff --git a/core/configuration.test.ts b/core/configuration.test.ts index f5d975ca..e0418b0e 100644 --- a/core/configuration.test.ts +++ b/core/configuration.test.ts @@ -15,6 +15,7 @@ test("core / configuration / resolveFromVscode if it is a valid file", async () enable: true, unstable: true, import_map: "./import_map.json", + lint: false, } as ConfigurationField); }); @@ -29,6 +30,7 @@ test("core / configuration / resolveFromVscode if valid section", async () => { enable: true, unstable: false, import_map: null, + lint: false, } as ConfigurationField); }); @@ -51,6 +53,7 @@ test("core / configuration / resolveFromVscode if config file is empty", async ( enable: false, unstable: false, import_map: null, + lint: false, } as ConfigurationField); }); @@ -65,6 +68,7 @@ test("core / configuration / resolveFromVscode if field is invalid", async () => enable: true, unstable: true, import_map: "1,2,3", + lint: false, } as ConfigurationField); }); @@ -87,5 +91,6 @@ test("core / configuration / update", async () => { enable: true, unstable: false, import_map: null, + lint: false, } as ConfigurationField); }); diff --git a/core/configuration.ts b/core/configuration.ts index 4eb38a18..cc29a043 100644 --- a/core/configuration.ts +++ b/core/configuration.ts @@ -11,12 +11,14 @@ export const DenoPluginConfigurationField: (keyof ConfigurationField)[] = [ "enable", "unstable", "import_map", + "lint", ]; export type ConfigurationField = { enable?: boolean; unstable?: boolean; import_map?: string | null; + lint?: boolean; }; interface ConfigurationInterface { @@ -31,6 +33,7 @@ export class Configuration implements ConfigurationInterface { enable: false, unstable: false, import_map: null, + lint: false, }; private readonly _configUpdatedListeners = new Set<() => void>(); @@ -76,6 +79,7 @@ export class Configuration implements ConfigurationInterface { // Make sure the type of each configuration item is correct this._configuration.enable = !!this._configuration.enable; this._configuration.unstable = !!this._configuration.unstable; + this._configuration.lint = !!this._configuration.lint; this._configuration.import_map = this._configuration.import_map ? this._configuration.import_map + "" : null; diff --git a/package.json b/package.json index 770d2710..c9482eec 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,16 @@ true, false ] + }, + "deno.lint": { + "type": "boolean", + "default": false, + "markdownDescription": "Controls if `deno lint` is enabled. It is currently experimental, so make sure `deno.unstable: true` is enabled. \n\n**Not recommended in global configuration**", + "scope": "resource", + "examples": [ + true, + false + ] } } }, diff --git a/server/src/bridge.ts b/server/src/bridge.ts index 0be77201..16b6d8f1 100644 --- a/server/src/bridge.ts +++ b/server/src/bridge.ts @@ -6,6 +6,8 @@ import { Request } from "../../core/const"; type Configuration = { enable: boolean; import_map?: string; + unstable?: boolean; + lint?: boolean; }; /** diff --git a/server/src/deno.ts b/server/src/deno.ts index 3020594d..77d5b4a3 100644 --- a/server/src/deno.ts +++ b/server/src/deno.ts @@ -3,6 +3,7 @@ import { Readable } from "stream"; import execa from "execa"; import which from "which"; import * as semver from "semver"; +import { Cache } from "../../core/cache"; type Version = { deno: string; @@ -15,6 +16,34 @@ type FormatOptions = { cwd: string; }; +interface LintLocation { + line: number; // one base number + col: number; // zero base number +} + +interface LintDiagnostic { + code: string; + filename: string; + message: string; + range: { + start: LintLocation; + end: LintLocation; + }; +} + +interface LintError { + file_path: string; + message: string; +} + +interface LintOutput { + diagnostics: LintDiagnostic[]; + errors: LintError[]; +} + +// caching Deno lint's rules for 120s or 100 referenced times +const denoLintRulesCache = Cache.create(1000 * 120, 100); + class Deno { public version!: Version | void; public executablePath!: string | void; @@ -33,9 +62,9 @@ class Deno { return; } - // If the currently used Deno is less than 0.33.0 + // If the currently used Deno is less than 1.3.3 // We will give an warning to upgrade. - const minimumDenoVersion = "0.35.0"; + const minimumDenoVersion = "1.3.3"; if (!semver.gte(this.version.deno, minimumDenoVersion)) { throw new Error(`Please upgrade to Deno ${minimumDenoVersion} or above.`); } @@ -43,15 +72,13 @@ class Deno { public async getTypes(unstable: boolean): Promise { const { stdout } = await execa(this.executablePath as string, [ "types", - ...(unstable && this.version && semver.gte(this.version.deno, "0.43.0") - ? ["--unstable"] - : []), + ...(unstable ? ["--unstable"] : []), ]); return Buffer.from(stdout, "utf8"); } // format code - // echo "console.log(123)" | deno fmt --stdin + // echo "console.log(123)" | deno fmt - public async format(code: string, options: FormatOptions): Promise { const reader = Readable.from([code]); @@ -72,22 +99,71 @@ class Deno { resolve(stdout); } }); - subprocess.on("error", (err: Error) => { - reject(err); - }); - subprocess.stdout?.on("data", (data: Buffer) => { - stdout += data; - }); - - subprocess.stderr?.on("data", (data: Buffer) => { - stderr += data; - }); - + subprocess.on("error", (err: Error) => reject(err)); + subprocess.stdout?.on("data", (data: Buffer) => (stdout += data)); + subprocess.stderr?.on("data", (data: Buffer) => (stderr += data)); subprocess.stdin && reader.pipe(subprocess.stdin); })) as string; return formattedCode; } + + public async getLintRules(): Promise { + const cachedRules = denoLintRulesCache.get(); + if (cachedRules) { + return cachedRules; + } + const subprocess = execa( + this.executablePath as string, + ["lint", "--unstable", "--rules"], + { + stdout: "pipe", + } + ); + + const output = await new Promise((resolve, reject) => { + let stdout = ""; + subprocess.on("exit", () => resolve(stdout)); + subprocess.on("error", (err: Error) => reject(err)); + subprocess.stdout?.on("data", (data: Buffer) => (stdout += data)); + }); + + const rules = output + .split("\n") + .map((v) => v.trim()) + .filter((v) => v.startsWith("-")) + .map((v) => v.replace(/^-\s+/, "")); + + denoLintRulesCache.set(rules); + + return rules; + } + + // lint code + // echo "console.log(123)" | deno lint --unstable --json - + public async lint(code: string): Promise { + const reader = Readable.from([code]); + + const subprocess = execa( + this.executablePath as string, + ["lint", "--unstable", "--json", "-"], + { + stdin: "pipe", + stderr: "pipe", + } + ); + + const output = await new Promise((resolve, reject) => { + let stderr = ""; + subprocess.on("exit", () => resolve(stderr)); + subprocess.on("error", (err: Error) => reject(err)); + subprocess.stderr?.on("data", (data: Buffer) => (stderr += data)); + subprocess.stdin && reader.pipe(subprocess.stdin); + }); + + return JSON.parse(output) as LintOutput; + } + private async getExecutablePath(): Promise { const denoPath = await which("deno").catch(() => Promise.resolve(undefined) diff --git a/server/src/language/diagnostics.ts b/server/src/language/diagnostics.ts index cf40b1d1..f06602bd 100644 --- a/server/src/language/diagnostics.ts +++ b/server/src/language/diagnostics.ts @@ -8,6 +8,8 @@ import { CodeActionKind, Command, TextDocuments, + Range, + Position, } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import * as ts from "typescript"; @@ -17,8 +19,9 @@ import { Bridge } from "../bridge"; import { ModuleResolver } from "../../../core/module_resolver"; import { pathExists, isHttpURL, isValidDenoDocument } from "../../../core/util"; import { ImportMap } from "../../../core/import_map"; -import { getImportModules, Range } from "../../../core/deno_deps"; +import { getImportModules } from "../../../core/deno_deps"; import { Notification } from "../../../core/const"; +import { deno } from "../deno"; type Fix = { title: string; @@ -42,6 +45,8 @@ const FixItems: { [code: number]: Fix } = { }, }; +const DENO_LINT = "deno_lint"; + export class Diagnostics { constructor( private name: string, @@ -52,25 +57,22 @@ export class Diagnostics { connection.onCodeAction(async (params) => { const { context, textDocument } = params; const { diagnostics } = context; - const denoDiagnostics = diagnostics.filter((v) => v.source === this.name); + const denoDiagnostics = diagnostics.filter( + (v) => v.source === this.name || v.source === DENO_LINT + ); if (!denoDiagnostics.length) { return; } - const actions: CodeAction[] = denoDiagnostics - .map((v) => { - const code = v.code; - - if (!code) { - return; - } + const rules = await deno.getLintRules(); - const fixItem: Fix = FixItems[+code]; + const actions: CodeAction[] = []; - if (!fixItem) { - return; - } + const documentAction = denoDiagnostics + .filter((v) => v.code && FixItems[+v.code]) + .map((v) => { + const fixItem: Fix = FixItems[+(v.code as string)]; const action = CodeAction.create( `${fixItem.title} (${this.name})`, @@ -79,25 +81,50 @@ export class Diagnostics { fixItem.command, // argument textDocument.uri, - { - start: { - line: v.range.start.line, - character: v.range.start.character, - }, - end: { - line: v.range.end.line, - character: v.range.end.character, - }, - } + v.range ), CodeActionKind.QuickFix ); return action; - }) - .filter((v) => v) as CodeAction[]; + }); - return actions; + const denoLintAction = denoDiagnostics + .filter((v) => v.code && rules.includes(v.code as string)) + .map((v) => { + const action = CodeAction.create( + `ignore \`${v.code}\` for this line (${DENO_LINT})`, + Command.create( + "Fix lint", + `deno._ignore_next_line_lint`, + // argument + textDocument.uri, + v.range, + v.code + ), + CodeActionKind.QuickFix + ); + + return action; + }); + + if (denoLintAction.length) { + denoLintAction.push( + CodeAction.create( + `ignore entire file (${DENO_LINT})`, + Command.create( + "Fix lint for entry file", + `deno._ignore_entry_file`, + // argument + textDocument.uri, + Range.create(Position.create(0, 0), Position.create(0, 0)) + ), + CodeActionKind.QuickFix + ) + ); + } + + return actions.concat(documentAction).concat(denoLintAction); }); connection.onNotification(Notification.diagnostic, (uri: string) => { @@ -108,6 +135,31 @@ export class Diagnostics { documents.onDidOpen((params) => this.diagnosis(params.document)); documents.onDidChangeContent((params) => this.diagnosis(params.document)); } + /** + * lint document + * @param document + */ + async lint(document: TextDocument): Promise { + const lintOutput = await deno.lint(document.getText()); + + return lintOutput.diagnostics.map((v) => { + const start = Position.create(v.range.start.line - 1, v.range.start.col); + const end = Position.create(v.range.end.line - 1, v.range.end.col); + const range = Range.create(start, end); + + return Diagnostic.create( + range, + v.message, + DiagnosticSeverity.Error, + v.code, + DENO_LINT + ); + }); + } + /** + * generate diagnostic for a document + * @param document + */ async generate(document: TextDocument): Promise { if (!isValidDenoDocument(document.languageId)) { return []; @@ -123,6 +175,16 @@ export class Diagnostics { return []; } + const diagnosticsForThisDocument: Diagnostic[] = []; + + if (config.unstable && config.lint) { + const denoLinterResult = await this.lint(document); + + for (const v of denoLinterResult) { + diagnosticsForThisDocument.push(v); + } + } + const importMapFilepath = config.import_map ? path.isAbsolute(config.import_map) ? config.import_map @@ -142,7 +204,6 @@ export class Diagnostics { const importModules = getImportModules(ts)(sourceFile); - const diagnosticsForThisDocument: Diagnostic[] = []; const resolver = ModuleResolver.create(uri.fsPath, importMapFilepath); const handle = async (originModuleName: string, location: Range) => {