From 71c6a4ae2891cdbdfbbfb8ca0d11a566c900f6d2 Mon Sep 17 00:00:00 2001 From: Blake Williams Date: Wed, 23 Mar 2022 23:57:59 +1100 Subject: [PATCH] Add support for shellcheck --- server/src/config.ts | 5 ++ server/src/linter.ts | 110 +++++++++++++++++++++++++++++++++++++++++++ server/src/server.ts | 31 +++++++++--- 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 server/src/linter.ts diff --git a/server/src/config.ts b/server/src/config.ts index 4215aebf..8697293a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,5 +1,10 @@ export const DEFAULT_GLOB_PATTERN = '**/*@(.sh|.inc|.bash|.command)' +export function getShellcheckPath(): string | null { + const { SHELLCHECK_PATH } = process.env + return typeof SHELLCHECK_PATH === 'string' ? SHELLCHECK_PATH : 'shellcheck' +} + export function getExplainshellEndpoint(): string | null { const { EXPLAINSHELL_ENDPOINT } = process.env return typeof EXPLAINSHELL_ENDPOINT === 'string' && EXPLAINSHELL_ENDPOINT.trim() !== '' diff --git a/server/src/linter.ts b/server/src/linter.ts new file mode 100644 index 00000000..eccd44f4 --- /dev/null +++ b/server/src/linter.ts @@ -0,0 +1,110 @@ +import * as LSP from 'vscode-languageserver' +import { spawn } from 'child_process' + +function formatMessage(comment: any): string { + return (comment.code ? 'SC' + comment.code + ': ' : '') + comment.message +} + +export default class Linter { + private executablePath: string | null + private canLint: boolean + + constructor({ executablePath }: { executablePath: string | null }) { + this.executablePath = executablePath + this.canLint = !!executablePath + } + + public async lint( + document: LSP.TextDocument, + folders: LSP.WorkspaceFolder[], + ): Promise { + if (!this.executablePath || !this.canLint) return [] + + const raw = await this.runShellcheck(this.executablePath, document, folders) + if (!this.canLint) return [] + + if (typeof raw != 'object') + throw new Error(`shellcheck: unexpected json output ${typeof raw}`) + + if (!Array.isArray(raw.comments)) + throw new Error( + `shellcheck: unexpected json output: expected 'comments' array ${typeof raw.comments}`, + ) + + const diags: LSP.Diagnostic[] = [] + for (const idx in raw.comments) { + const comment = raw.comments[idx] + if (typeof comment != 'object') + throw new Error( + `shellcheck: unexpected json comment at idx ${idx}: ${typeof comment}`, + ) + + const start: LSP.Position = { + line: comment.line - 1, + character: comment.column - 1, + } + const end: LSP.Position = { + line: comment.endLine - 1, + character: comment.endColumn - 1, + } + + diags.push({ + message: formatMessage(comment), + severity: comment.level, + code: comment.code, + source: 'shellcheck', + range: { start, end }, + }) + } + + return diags + } + + private async runShellcheck( + executablePath: string, + document: LSP.TextDocument, + folders: LSP.WorkspaceFolder[], + ): Promise { + // FIXME: inject? + const cwd = process.cwd() + + const args = ['--format=json1', '--external-sources', `--source-path=${cwd}`] + for (const folder of folders) { + args.push(`--source-path=${folder.name}`) + } + + const proc = spawn(executablePath, [...args, '-'], { cwd }) + const onErr = new Promise((_, reject) => + proc.on('error', e => { + if ((e as any).code === 'ENOENT') { + // shellcheck path wasn't found, don't try to lint any more: + console.error(`shellcheck not available at path '${this.executablePath}'`) + this.canLint = false + } + reject(e) + }), + ) + + proc.stdin.write(document.getText()) + proc.stdin.end() + + let out = '' + for await (const chunk of proc.stdout) out += chunk + + let err = '' + for await (const chunk of proc.stderr) err += chunk + + // XXX: do we care about exit code? 0 means "ok", 1 possibly means "errors", + // but the presence of parseable errors in the output is also sufficient to + // distinguish. + await Promise.race([new Promise((resolve, _) => proc.on('close', resolve)), onErr]) + + try { + return JSON.parse(out) + } catch (e) { + throw new Error( + `shellcheck: json parse failed with error ${e}\nout:\n${out}\nerr:\n${err}`, + ) + } + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 05dac3ef..f7aac087 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -5,10 +5,13 @@ import * as TurndownService from 'turndown' import * as LSP from 'vscode-languageserver' import { TextDocument } from 'vscode-languageserver-textdocument' +import * as fs from 'fs' + import Analyzer from './analyser' import * as Builtins from './builtins' import * as config from './config' import Executables from './executables' +import Linter from './linter' import { initializeParser } from './parser' import * as ReservedWords from './reservedWords' import { BashCompletionItem, CompletionItemDataType } from './types' @@ -41,15 +44,15 @@ export default class BashServer { return Promise.all([ Executables.fromPath(PATH), Analyzer.fromRoot({ connection, rootPath, parser }), - ]).then(xs => { - const executables = xs[0] - const analyzer = xs[1] - return new BashServer(connection, executables, analyzer) + new Linter({ executablePath: config.getShellcheckPath() }), + ]).then(([executables, analyzer, linter]) => { + return new BashServer(connection, executables, analyzer, linter) }) } private executables: Executables private analyzer: Analyzer + private linter: Linter private documents: LSP.TextDocuments = new LSP.TextDocuments(TextDocument) private connection: LSP.Connection @@ -58,10 +61,12 @@ export default class BashServer { connection: LSP.Connection, executables: Executables, analyzer: Analyzer, + linter: Linter, ) { this.connection = connection this.executables = executables this.analyzer = analyzer + this.linter = linter } /** @@ -72,15 +77,29 @@ export default class BashServer { // The content of a text document has changed. This event is emitted // when the text document first opened or when its content has changed. this.documents.listen(this.connection) - this.documents.onDidChangeContent(change => { + this.documents.onDidChangeContent(async change => { const { uri } = change.document const diagnostics = this.analyzer.analyze(uri, change.document) - if (config.getHighlightParsingError()) { + + if (diagnostics.length && config.getHighlightParsingError()) { connection.sendDiagnostics({ uri: change.document.uri, diagnostics, }) } + + if (!diagnostics.length) { + // FIXME: re-lint on workspace folder change + const folders = await connection.workspace.getWorkspaceFolders() + + const checks = await this.linter.lint(change.document, folders || []) + if (checks) { + connection.sendDiagnostics({ + uri: change.document.uri, + diagnostics: checks, + }) + } + } }) // Register all the handlers for the LSP events.