Skip to content

Commit

Permalink
Add support for shellcheck
Browse files Browse the repository at this point in the history
  • Loading branch information
shabbyrobe committed May 13, 2022
1 parent c6c7c74 commit 71c6a4a
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 6 deletions.
5 changes: 5 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
@@ -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() !== ''
Expand Down
110 changes: 110 additions & 0 deletions server/src/linter.ts
Original file line number Diff line number Diff line change
@@ -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<LSP.Diagnostic[]> {
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<any> {
// 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}`,
)
}
}
}
31 changes: 25 additions & 6 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<TextDocument> = new LSP.TextDocuments(TextDocument)
private connection: LSP.Connection
Expand All @@ -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
}

/**
Expand All @@ -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.
Expand Down

0 comments on commit 71c6a4a

Please sign in to comment.