Skip to content

Use zod for ShellCheck result parsing #608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion scripts/release-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ yarn install
yarn run verify:bail

cd server
npm publish --tag beta
npm publish
# npm publish --tag beta # for releasing beta versions
tagRelease $tag
7 changes: 7 additions & 0 deletions server/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Bash Language Server

## 4.0.0

- **Breaking**: Drop support for Node 12, which reached its official end of life on April 30th 2022. Doing so enables new features. https://github.com/bash-lsp/bash-language-server/pull/584
- ShellCheck: support code actions, remove duplicated error codes, add URLs and tags, support parsing dialects (sh, bash, dash, ksh) but still fallback to bash, enable configuring ShellCheck arguments using the `shellcheckArguments` configuration parameter (legacy environment variable: `SHELLCHECK_ARGUMENTS`)
- Support workspace configuration instead of environment variables which enables updating configuration without reloading the server. We still support environment variables, but clients **should migrate to the new workspace configuration**. https://github.com/bash-lsp/bash-language-server/pull/599
- Allow disabling background analysis by setting `backgroundAnalysisMaxFiles: 0`.

## 3.3.1

- Fix missing documentation for some help pages https://github.com/bash-lsp/bash-language-server/pull/577
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "A language server for Bash",
"author": "Mads Hartmann",
"license": "MIT",
"version": "4.0.0-beta.6",
"version": "4.0.0",
"publisher": "mads-hartmann",
"main": "./out/server.js",
"typings": "./out/server.d.ts",
Expand Down
63 changes: 63 additions & 0 deletions server/src/shellcheck/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ShellCheckResultSchema } from '../types'

describe('shellcheck', () => {
it('asserts one valid shellcheck JSON comment', async () => {
// prettier-ignore
const shellcheckJSON = {
comments: [
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, },
],
}
ShellCheckResultSchema.parse(shellcheckJSON)
})

it('asserts two valid shellcheck JSON comment', async () => {
// prettier-ignore
const shellcheckJSON = {
comments: [
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, },
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 45, endLine: 45, column: 2, endColumn: 8, level: 'warning', code: 2035, message: 'bork bork', fix: null, },
],
}
ShellCheckResultSchema.parse(shellcheckJSON)
})

it('fails shellcheck JSON with null comments', async () => {
const shellcheckJSON = { comments: null }
expect(() => ShellCheckResultSchema.parse(shellcheckJSON)).toThrow()
})

it('fails shellcheck JSON with string commment', async () => {
const shellcheckJSON = { comments: ['foo'] }
expect(() => ShellCheckResultSchema.parse(shellcheckJSON)).toThrow()
})

it('fails shellcheck JSON with invalid comment', async () => {
const make = (tweaks = {}) => ({
comments: [
{
file: 'testing/fixtures/comment-doc-on-hover.sh',
line: 43,
endLine: 43,
column: 1,
endColumn: 7,
level: 'warning',
code: 2034,
message: 'bork bork',
fix: null,
...tweaks,
},
],
})
ShellCheckResultSchema.parse(make()) // Defaults should work

expect(() => ShellCheckResultSchema.parse(make({ file: 9 }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ line: '9' }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ endLine: '9' }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ column: '9' }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ endColumn: '9' }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ level: 9 }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ code: '9' }))).toThrow()
expect(() => ShellCheckResultSchema.parse(make({ message: 9 }))).toThrow()
})
})
64 changes: 1 addition & 63 deletions server/src/shellcheck/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'

import { FIXTURE_DOCUMENT, FIXTURE_FOLDER } from '../../../../testing/fixtures'
import { getMockConnection } from '../../../../testing/mocks'
import { assertShellCheckResult, Linter } from '../index'
import { Linter } from '../index'

const mockConsole = getMockConnection().console

Expand Down Expand Up @@ -235,65 +235,3 @@ describe('linter', () => {
})
})
})

describe('shellcheck', () => {
it('asserts one valid shellcheck JSON comment', async () => {
// prettier-ignore
const shellcheckJSON = {
comments: [
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, },
],
}
assertShellCheckResult(shellcheckJSON)
})

it('asserts two valid shellcheck JSON comment', async () => {
// prettier-ignore
const shellcheckJSON = {
comments: [
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 43, endLine: 43, column: 1, endColumn: 7, level: 'warning', code: 2034, message: 'bork bork', fix: null, },
{ file: 'testing/fixtures/comment-doc-on-hover.sh', line: 45, endLine: 45, column: 2, endColumn: 8, level: 'warning', code: 2035, message: 'bork bork', fix: null, },
],
}
assertShellCheckResult(shellcheckJSON)
})

it('fails shellcheck JSON with null comments', async () => {
const shellcheckJSON = { comments: null }
expect(() => assertShellCheckResult(shellcheckJSON)).toThrow()
})

it('fails shellcheck JSON with string commment', async () => {
const shellcheckJSON = { comments: ['foo'] }
expect(() => assertShellCheckResult(shellcheckJSON)).toThrow()
})

it('fails shellcheck JSON with invalid comment', async () => {
const make = (tweaks = {}) => ({
comments: [
{
file: 'testing/fixtures/comment-doc-on-hover.sh',
line: 43,
endLine: 43,
column: 1,
endColumn: 7,
level: 'warning',
code: 2034,
message: 'bork bork',
fix: null,
...tweaks,
},
],
})
assertShellCheckResult(make()) // Defaults should work

expect(() => assertShellCheckResult(make({ file: 9 }))).toThrow()
expect(() => assertShellCheckResult(make({ line: '9' }))).toThrow()
expect(() => assertShellCheckResult(make({ endLine: '9' }))).toThrow()
expect(() => assertShellCheckResult(make({ column: '9' }))).toThrow()
expect(() => assertShellCheckResult(make({ endColumn: '9' }))).toThrow()
expect(() => assertShellCheckResult(make({ level: 9 }))).toThrow()
expect(() => assertShellCheckResult(make({ code: '9' }))).toThrow()
expect(() => assertShellCheckResult(make({ message: 9 }))).toThrow()
})
})
7 changes: 6 additions & 1 deletion server/src/shellcheck/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as LSP from 'vscode-languageserver/node'

import { ShellCheckCommentLevel } from './types'

export const SUPPORTED_BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh']

// https://github.com/koalaman/shellcheck/wiki
Expand All @@ -14,7 +16,10 @@ export const CODE_TO_TAGS: Record<number, LSP.DiagnosticTag[] | undefined> = {

// https://github.com/koalaman/shellcheck/blob/364c33395e2f2d5500307f01989f70241c247d5a/src/ShellCheck/Formatter/Format.hs#L50

export const LEVEL_TO_SEVERITY: Record<string, LSP.DiagnosticSeverity | undefined> = {
export const LEVEL_TO_SEVERITY: Record<
ShellCheckCommentLevel,
LSP.DiagnosticSeverity | undefined
> = {
error: LSP.DiagnosticSeverity.Error,
warning: LSP.DiagnosticSeverity.Warning,
info: LSP.DiagnosticSeverity.Information,
Expand Down
66 changes: 10 additions & 56 deletions server/src/shellcheck/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { TextDocument } from 'vscode-languageserver-textdocument'

import { analyzeShebang } from '../util/shebang'
import { CODE_TO_TAGS, LEVEL_TO_SEVERITY } from './config'
import { ShellCheckComment, ShellCheckReplacement, ShellCheckResult } from './types'
import {
ShellCheckComment,
ShellCheckReplacement,
ShellCheckResult,
ShellCheckResultSchema,
} from './types'

const SUPPORTED_BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh']

Expand Down Expand Up @@ -60,6 +65,9 @@ export class Linter {
const documentText = document.getText()

const { shellDialect } = analyzeShebang(documentText)
// NOTE: that ShellCheck actually does shebang parsing, but we manually
// do it here in order to fallback to bash. This enables parsing files
// with a bash syntax.
const shellName =
shellDialect && SUPPORTED_BASH_DIALECTS.includes(shellDialect)
? shellDialect
Expand Down Expand Up @@ -128,64 +136,10 @@ export class Linter {
`ShellCheck: json parse failed with error ${e}\nout:\n${out}\nerr:\n${err}`,
)
}
assertShellCheckResult(raw)
return raw
}
}
export function assertShellCheckResult(val: any): asserts val is ShellCheckResult {
// TODO: use zod
if (val !== null && typeof val !== 'object') {
throw new Error(`shellcheck: unexpected json output ${typeof val}`)
}

if (!Array.isArray(val.comments)) {
throw new Error(
`shellcheck: unexpected json output: expected 'comments' array ${typeof val.comments}`,
)
}

for (const idx in val.comments) {
const comment = val.comments[idx]
if (comment !== null && typeof comment != 'object') {
throw new Error(
`shellcheck: expected comment at index ${idx} to be object, found ${typeof comment}`,
)
}
if (typeof comment.file !== 'string')
throw new Error(
`shellcheck: expected comment file at index ${idx} to be string, found ${typeof comment.file}`,
)
if (typeof comment.level !== 'string')
throw new Error(
`shellcheck: expected comment level at index ${idx} to be string, found ${typeof comment.level}`,
)
if (typeof comment.message !== 'string')
throw new Error(
`shellcheck: expected comment message at index ${idx} to be string, found ${typeof comment.level}`,
)
if (typeof comment.line !== 'number')
throw new Error(
`shellcheck: expected comment line at index ${idx} to be number, found ${typeof comment.line}`,
)
if (typeof comment.endLine !== 'number')
throw new Error(
`shellcheck: expected comment endLine at index ${idx} to be number, found ${typeof comment.endLine}`,
)
if (typeof comment.column !== 'number')
throw new Error(
`shellcheck: expected comment column at index ${idx} to be number, found ${typeof comment.column}`,
)
if (typeof comment.endColumn !== 'number')
throw new Error(
`shellcheck: expected comment endColumn at index ${idx} to be number, found ${typeof comment.endColumn}`,
)
if (typeof comment.code !== 'number')
throw new Error(
`shellcheck: expected comment code at index ${idx} to be number, found ${typeof comment.code}`,
)
return ShellCheckResultSchema.parse(raw)
}
}

function mapShellCheckResult({
document,
result,
Expand Down
62 changes: 37 additions & 25 deletions server/src/shellcheck/types.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
export type ShellCheckResult = Readonly<{
comments: ReadonlyArray<ShellCheckComment>
}>
import { z } from 'zod'

const ReplacementSchema = z.object({
precedence: z.number(),
line: z.number(),
endLine: z.number(),
column: z.number(),
endColumn: z.number(),
insertionPoint: z.string(),
replacement: z.string(),
})

// https://github.com/koalaman/shellcheck/blob/364c33395e2f2d5500307f01989f70241c247d5a/src/ShellCheck/Formatter/Format.hs#L50
const LevelSchema = z.enum(['error', 'warning', 'info', 'style'])

// Constituent structures defined here:
// https://github.com/koalaman/shellcheck/blob/master/src/ShellCheck/Interface.hs
export type ShellCheckComment = Readonly<{
file: string
line: number // 1-based
endLine: number // 1-based
column: number // 1-based
endColumn: number // 1-based
level: string // See LEVEL_TO_SEVERITY
code: number
message: string
fix?: {
replacements: ReadonlyArray<ShellCheckReplacement>
}
}>

export type ShellCheckReplacement = {
precedence: number
line: number
endLine: number
column: number
endColumn: number
insertionPoint: string
replacement: string
}
export const ShellCheckResultSchema = z.object({
comments: z.array(
z.object({
file: z.string(),
line: z.number(), // 1-based
endLine: z.number(), // 1-based
column: z.number(), // 1-based
endColumn: z.number(), // 1-based
level: LevelSchema,
code: z.number(),
message: z.string(),
fix: z
.object({
replacements: z.array(ReplacementSchema),
})
.nullable(),
}),
),
})
export type ShellCheckResult = z.infer<typeof ShellCheckResultSchema>
export type ShellCheckComment = ShellCheckResult['comments'][number]
export type ShellCheckCommentLevel = z.infer<typeof LevelSchema>
export type ShellCheckReplacement = z.infer<typeof ReplacementSchema>