Skip to content

Commit 34f77db

Browse files
authored
Merge pull request #747 from bash-lsp/parse-shellcheck-directives
Use ShellCheck directives when sourcing files
2 parents 217ef05 + 1db2623 commit 34f77db

File tree

10 files changed

+354
-12
lines changed

10 files changed

+354
-12
lines changed

server/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Bash Language Server
22

3+
## 4.8.0
4+
5+
- Use ShellCheck directives when analyzing source commands https://github.com/bash-lsp/bash-language-server/pull/747
6+
37
## 4.7.0
48

59
- Support for bash options auto completions when using Brew or when `pkg-config` fails, but bash completions are found in `"${PREFIX:-/usr}/share/bash-completion/bash_completion"` https://github.com/bash-lsp/bash-language-server/pull/717

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "A language server for Bash",
44
"author": "Mads Hartmann",
55
"license": "MIT",
6-
"version": "4.7.0",
6+
"version": "4.8.0",
77
"main": "./out/server.js",
88
"typings": "./out/server.d.ts",
99
"bin": {

server/src/__tests__/analyzer.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ describe('analyze', () => {
9898
{
9999
"message": "Source command could not be analyzed: non-constant source not supported.
100100
101-
Note that enabling the configuration flag "includeAllWorkspaceSymbols"
102-
would include all symbols in the workspace regardless of source commands.
103-
But be aware that this will lead to false positive suggestions.",
101+
Consider adding a ShellCheck directive above this line to fix or ignore this:
102+
# shellcheck source=/my-file.sh # specify the file to source
103+
# shellcheck source-path=my_script_folder # specify the folder to search in
104+
# shellcheck source=/dev/null # to ignore the error",
104105
"range": {
105106
"end": {
106107
"character": 16,

server/src/analyser.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ export default class Analyzer {
100100
sourceCommand.range,
101101
[
102102
`Source command could not be analyzed: ${sourceCommand.error}.\n`,
103-
'Note that enabling the configuration flag "includeAllWorkspaceSymbols"',
104-
'would include all symbols in the workspace regardless of source commands.',
105-
'But be aware that this will lead to false positive suggestions.',
103+
'Consider adding a ShellCheck directive above this line to fix or ignore this:',
104+
'# shellcheck source=/my-file.sh # specify the file to source',
105+
'# shellcheck source-path=my_script_folder # specify the folder to search in',
106+
'# shellcheck source=/dev/null # to ignore the error',
106107
].join('\n'),
107108
LSP.DiagnosticSeverity.Information,
108109
undefined,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { parseShellCheckDirective } from '../directive'
2+
3+
describe('parseShellCheckDirective', () => {
4+
it('parses a disable directive', () => {
5+
expect(parseShellCheckDirective('# shellcheck disable=SC1000')).toEqual([
6+
{
7+
type: 'disable',
8+
rules: ['SC1000'],
9+
},
10+
])
11+
})
12+
13+
it('parses a disable directive with multiple args', () => {
14+
expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([
15+
{
16+
type: 'disable',
17+
rules: ['SC1000', 'SC1001'],
18+
},
19+
])
20+
21+
expect(
22+
parseShellCheckDirective(
23+
'# shellcheck disable=SC1000,SC2000-SC2002,SC1001 # this is a comment',
24+
),
25+
).toEqual([
26+
{
27+
type: 'disable',
28+
rules: ['SC1000', 'SC2000', 'SC2001', 'SC2002', 'SC1001'],
29+
},
30+
])
31+
32+
expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([
33+
{
34+
type: 'disable',
35+
rules: ['SC1000', 'SC1001'],
36+
},
37+
])
38+
39+
expect(parseShellCheckDirective('# shellcheck disable=SC1000,SC1001')).toEqual([
40+
{
41+
type: 'disable',
42+
rules: ['SC1000', 'SC1001'],
43+
},
44+
])
45+
})
46+
47+
// SC1000-SC9999
48+
it('parses a disable directive with a range', () => {
49+
expect(parseShellCheckDirective('# shellcheck disable=SC1000-SC1005')).toEqual([
50+
{
51+
type: 'disable',
52+
rules: ['SC1000', 'SC1001', 'SC1002', 'SC1003', 'SC1004', 'SC1005'],
53+
},
54+
])
55+
})
56+
57+
it('parses a disable directive with all', () => {
58+
expect(parseShellCheckDirective('# shellcheck disable=all')).toEqual([
59+
{
60+
type: 'disable',
61+
rules: ['all'],
62+
},
63+
])
64+
})
65+
66+
it('parses an enable directive', () => {
67+
expect(
68+
parseShellCheckDirective('# shellcheck enable=require-variable-braces'),
69+
).toEqual([
70+
{
71+
type: 'enable',
72+
rules: ['require-variable-braces'],
73+
},
74+
])
75+
})
76+
77+
it('parses source directive', () => {
78+
expect(parseShellCheckDirective('# shellcheck source=foo.sh')).toEqual([
79+
{
80+
type: 'source',
81+
path: 'foo.sh',
82+
},
83+
])
84+
85+
expect(parseShellCheckDirective('# shellcheck source=/dev/null # a comment')).toEqual(
86+
[
87+
{
88+
type: 'source',
89+
path: '/dev/null',
90+
},
91+
],
92+
)
93+
})
94+
95+
it('parses source-path directive', () => {
96+
expect(parseShellCheckDirective('# shellcheck source-path=src/examples')).toEqual([
97+
{
98+
type: 'source-path',
99+
path: 'src/examples',
100+
},
101+
])
102+
103+
expect(parseShellCheckDirective('# shellcheck source-path=SCRIPTDIR')).toEqual([
104+
{
105+
type: 'source-path',
106+
path: 'SCRIPTDIR',
107+
},
108+
])
109+
})
110+
111+
it('supports multiple directives on the same line', () => {
112+
expect(
113+
parseShellCheckDirective(
114+
`# shellcheck cats=dogs disable=SC1234,SC2345 enable="foo" shell=bash`,
115+
),
116+
).toEqual([
117+
{
118+
type: 'disable',
119+
rules: ['SC1234', 'SC2345'],
120+
},
121+
{
122+
type: 'enable',
123+
rules: ['"foo"'],
124+
},
125+
{
126+
type: 'shell',
127+
shell: 'bash',
128+
},
129+
])
130+
})
131+
132+
it('parses a line with no directive', () => {
133+
expect(parseShellCheckDirective('# foo bar')).toEqual([])
134+
})
135+
})

server/src/shellcheck/directive.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const DIRECTIVE_TYPES = ['enable', 'disable', 'source', 'source-path', 'shell'] as const
2+
type DirectiveType = (typeof DIRECTIVE_TYPES)[number]
3+
4+
type Directive =
5+
| {
6+
type: 'enable'
7+
rules: string[]
8+
}
9+
| {
10+
type: 'disable'
11+
rules: string[]
12+
}
13+
| {
14+
type: 'source'
15+
path: string
16+
}
17+
| {
18+
type: 'source-path'
19+
path: string
20+
}
21+
| {
22+
type: 'shell'
23+
shell: string
24+
}
25+
26+
const DIRECTIVE_REG_EXP = /^(#\s*shellcheck\s+)([^#]*)/
27+
28+
export function parseShellCheckDirective(line: string): Directive[] {
29+
const match = line.match(DIRECTIVE_REG_EXP)
30+
31+
if (!match) {
32+
return []
33+
}
34+
35+
const commands = match[2]
36+
.split(' ')
37+
.map((command) => command.trim())
38+
.filter((command) => command !== '')
39+
40+
const directives: Directive[] = []
41+
42+
for (const command of commands) {
43+
const [typeKey, directiveValue] = command.split('=')
44+
const type = DIRECTIVE_TYPES.includes(typeKey as any)
45+
? (typeKey as DirectiveType)
46+
: null
47+
48+
if (!type) {
49+
continue
50+
}
51+
52+
if (type === 'source-path' || type === 'source') {
53+
directives.push({
54+
type,
55+
path: directiveValue,
56+
})
57+
} else if (type === 'shell') {
58+
directives.push({
59+
type,
60+
shell: directiveValue,
61+
})
62+
continue
63+
} else if (type === 'enable' || type === 'disable') {
64+
const rules = []
65+
66+
for (const arg of directiveValue.split(',')) {
67+
const ruleRangeMatch = arg.match(/^SC(\d*)-SC(\d*)$/)
68+
if (ruleRangeMatch) {
69+
for (
70+
let i = parseInt(ruleRangeMatch[1], 10);
71+
i <= parseInt(ruleRangeMatch[2], 10);
72+
i++
73+
) {
74+
rules.push(`SC${i}`)
75+
}
76+
} else {
77+
arg
78+
.split(',')
79+
.map((arg) => arg.trim())
80+
.filter((arg) => arg !== '')
81+
.forEach((arg) => rules.push(arg))
82+
}
83+
}
84+
85+
directives.push({
86+
type,
87+
rules,
88+
})
89+
}
90+
}
91+
92+
return directives
93+
}

server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`getSourcedUris returns a set of sourced files 2`] = `
3+
exports[`getSourcedUris returns a set of sourced files (but ignores some unhandled cases) 2`] = `
44
[
55
{
66
"error": null,

server/src/util/__tests__/sourcing.test.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'fs'
22
import * as os from 'os'
33
import * as Parser from 'web-tree-sitter'
44

5+
import { REPO_ROOT_FOLDER } from '../../../../testing/fixtures'
56
import { initializeParser } from '../../parser'
67
import { getSourceCommands } from '../sourcing'
78

@@ -13,8 +14,6 @@ beforeAll(async () => {
1314
parser = await initializeParser()
1415
})
1516

16-
jest.spyOn(fs, 'existsSync').mockImplementation(() => true)
17-
1817
// mock os.homedir() to return a fixed path
1918
jest.spyOn(os, 'homedir').mockImplementation(() => '/Users/bash-user')
2019

@@ -29,7 +28,9 @@ describe('getSourcedUris', () => {
2928
expect(sourceCommands).toEqual([])
3029
})
3130

32-
it('returns a set of sourced files', () => {
31+
it('returns a set of sourced files (but ignores some unhandled cases)', () => {
32+
jest.spyOn(fs, 'existsSync').mockImplementation(() => true)
33+
3334
const fileContent = `
3435
source file-in-path.sh # does not contain a slash (i.e. is maybe somewhere on the path)
3536
@@ -73,7 +74,7 @@ describe('getSourcedUris', () => {
7374
done
7475
7576
# ======================================
76-
# example of sourcing through a function
77+
# Example of sourcing through a function
7778
# ======================================
7879
7980
loadlib () {
@@ -149,4 +150,67 @@ describe('getSourcedUris', () => {
149150

150151
expect(sourceCommands).toMatchSnapshot()
151152
})
153+
154+
it('returns a set of sourced files and parses ShellCheck directives', () => {
155+
jest.restoreAllMocks()
156+
157+
const fileContent = `
158+
. ./scripts/release-client.sh
159+
160+
source ./testing/fixtures/issue206.sh
161+
162+
# shellcheck source=/dev/null
163+
source ./IM_NOT_THERE.sh
164+
165+
# shellcheck source-path=testing/fixtures
166+
source missing-node.sh # source path by directive
167+
168+
# shellcheck source=./testing/fixtures/install.sh
169+
source "$X" # source by directive
170+
171+
# shellcheck source=./some-file-that-does-not-exist.sh
172+
source "$Y" # not source due to invalid directive
173+
174+
# shellcheck source-path=SCRIPTDIR # note that this is already the behaviour of bash language server
175+
source ./testing/fixtures/issue101.sh
176+
`
177+
178+
const sourceCommands = getSourceCommands({
179+
fileUri,
180+
rootPath: REPO_ROOT_FOLDER,
181+
tree: parser.parse(fileContent),
182+
})
183+
184+
const sourcedUris = new Set(
185+
sourceCommands
186+
.map((sourceCommand) => sourceCommand.uri)
187+
.filter((uri) => uri !== null),
188+
)
189+
190+
expect(sourcedUris).toEqual(
191+
new Set([
192+
`file://${REPO_ROOT_FOLDER}/scripts/release-client.sh`,
193+
`file://${REPO_ROOT_FOLDER}/testing/fixtures/issue206.sh`,
194+
`file://${REPO_ROOT_FOLDER}/testing/fixtures/missing-node.sh`,
195+
`file://${REPO_ROOT_FOLDER}/testing/fixtures/install.sh`,
196+
`file://${REPO_ROOT_FOLDER}/testing/fixtures/issue101.sh`,
197+
]),
198+
)
199+
200+
expect(
201+
sourceCommands
202+
.filter((command) => command.error)
203+
.map(({ error, range }) => ({
204+
error,
205+
line: range.start.line,
206+
})),
207+
).toMatchInlineSnapshot(`
208+
[
209+
{
210+
"error": "failed to resolve path",
211+
"line": 15,
212+
},
213+
]
214+
`)
215+
})
152216
})

server/src/util/discriminate.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function discriminate<K extends PropertyKey, V extends string | number | boolean>(
2+
discriminantKey: K,
3+
discriminantValue: V,
4+
) {
5+
return <T extends Record<K, any>>(
6+
obj: T & Record<K, V extends T[K] ? T[K] : V>,
7+
): obj is Extract<T, Record<K, V>> => obj[discriminantKey] === discriminantValue
8+
}

0 commit comments

Comments
 (0)