Skip to content

Commit c23778c

Browse files
authored
Merge pull request #237 from bash-lsp/word-at-point-improvements
Code completion improvements
2 parents e441064 + e6f759b commit c23778c

File tree

11 files changed

+125
-92
lines changed

11 files changed

+125
-92
lines changed

server/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Bash Language Server
22

3+
## 1.16.0
4+
5+
* Improved completion handler for parameter expansions (https://github.com/bash-lsp/bash-language-server/pull/237)
6+
37
## 1.15.0
48

59
* Use comments above symbols for documentation (https://github.com/bash-lsp/bash-language-server/pull/234, https://github.com/bash-lsp/bash-language-server/pull/235)
610

7-
811
## 1.14.0
912

1013
* onHover and onCompletion documentation improvements (https://github.com/bash-lsp/bash-language-server/pull/230)

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": "1.15.0",
6+
"version": "1.16.0",
77
"publisher": "mads-hartmann",
88
"main": "./out/server.js",
99
"typings": "./out/server.d.ts",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ exports[`server initializes and responds to capabilities 1`] = `
44
Object {
55
"completionProvider": Object {
66
"resolveProvider": true,
7+
"triggerCharacters": Array [
8+
"$",
9+
"{",
10+
],
711
},
812
"definitionProvider": true,
913
"documentHighlightProvider": true,

server/src/__tests__/analyzer.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,26 @@ describe('findSymbolsForFile', () => {
8787
describe('wordAtPoint', () => {
8888
it('returns current word at a given point', () => {
8989
analyzer.analyze(CURRENT_URI, FIXTURES.INSTALL)
90+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 0)).toEqual(null)
91+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 1)).toEqual(null)
92+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 2)).toEqual(null)
93+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 3)).toEqual(null)
94+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 4)).toEqual('rm')
9095
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 5)).toEqual('rm')
96+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 6)).toEqual(null)
97+
expect(analyzer.wordAtPoint(CURRENT_URI, 25, 7)).toEqual('npm-install-')
9198

92-
// FIXME: grammar issue: else is not found
93-
// expect(analyzer.wordAtPoint(CURRENT_URI, 24, 5)).toEqual('else')
99+
expect(analyzer.wordAtPoint(CURRENT_URI, 24, 2)).toEqual('else')
100+
expect(analyzer.wordAtPoint(CURRENT_URI, 24, 3)).toEqual('else')
101+
expect(analyzer.wordAtPoint(CURRENT_URI, 24, 5)).toEqual('else')
102+
expect(analyzer.wordAtPoint(CURRENT_URI, 24, 7)).toEqual(null)
94103

95104
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 1)).toEqual(null)
96105

106+
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 2)).toEqual('ret')
97107
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 3)).toEqual('ret')
98108
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 4)).toEqual('ret')
99-
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 5)).toEqual('ret')
109+
expect(analyzer.wordAtPoint(CURRENT_URI, 30, 5)).toEqual('=')
100110

101111
expect(analyzer.wordAtPoint(CURRENT_URI, 38, 5)).toEqual('configures')
102112
})

server/src/__tests__/server.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe('server', () => {
181181
{} as any,
182182
)
183183

184-
expect(result2).toMatchInlineSnapshot(`null`)
184+
expect(result2).toMatchInlineSnapshot(`Array []`)
185185
})
186186

187187
it('responds to onWorkspaceSymbol', async () => {
@@ -279,17 +279,17 @@ describe('server', () => {
279279
uri: FIXTURE_URI.INSTALL,
280280
},
281281
position: {
282-
// else
283-
line: 24,
284-
character: 5,
282+
// empty space
283+
line: 26,
284+
character: 0,
285285
},
286286
},
287287
{} as any,
288288
{} as any,
289289
)
290290

291291
// Entire list
292-
expect(result && 'length' in result && result.length > 50).toBe(true)
292+
expect(result && 'length' in result && result.length).toBeGreaterThanOrEqual(50)
293293
})
294294

295295
it('responds to onCompletion with empty list when word is a comment', async () => {
@@ -418,4 +418,31 @@ Helper function to add a user",
418418
]
419419
`)
420420
})
421+
422+
it('responds to onCompletion with all variables when starting to expand parameters', async () => {
423+
const { connection, server } = await initializeServer()
424+
server.register(connection)
425+
426+
const onCompletion = connection.onCompletion.mock.calls[0][0]
427+
428+
const result: any = await onCompletion(
429+
{
430+
textDocument: {
431+
uri: FIXTURE_URI.SOURCING,
432+
},
433+
position: {
434+
// $
435+
line: 14,
436+
character: 7,
437+
},
438+
},
439+
{} as any,
440+
{} as any,
441+
)
442+
443+
// they are all variables
444+
expect(Array.from(new Set(result.map((item: any) => item.kind)))).toEqual([
445+
lsp.CompletionItemKind.Variable,
446+
])
447+
})
421448
})

server/src/analyser.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -371,32 +371,19 @@ export default class Analyzer {
371371
*/
372372
public wordAtPoint(uri: string, line: number, column: number): string | null {
373373
const document = this.uriToTreeSitterTrees[uri]
374-
const contents = this.uriToFileContent[uri]
375374

376375
if (!document.rootNode) {
377376
// Check for lacking rootNode (due to failed parse?)
378377
return null
379378
}
380379

381-
const point = { row: line, column }
382-
383-
const node = TreeSitterUtil.namedLeafDescendantForPosition(point, document.rootNode)
380+
const node = document.rootNode.descendantForPosition({ row: line, column })
384381

385-
if (!node) {
382+
if (!node || node.childCount > 0 || node.text.trim() === '') {
386383
return null
387384
}
388385

389-
const start = node.startIndex
390-
const end = node.endIndex
391-
const name = contents.slice(start, end)
392-
393-
// Hack. Might be a problem with the grammar.
394-
// TODO: Document this with a test case
395-
if (name.endsWith('=')) {
396-
return name.slice(0, name.length - 1)
397-
}
398-
399-
return name
386+
return node.text.trim()
400387
}
401388

402389
/**
@@ -447,6 +434,10 @@ export default class Analyzer {
447434
return null
448435
}
449436

437+
public getAllVariableSymbols(): LSP.SymbolInformation[] {
438+
return this.getAllSymbols().filter(symbol => symbol.kind === LSP.SymbolKind.Variable)
439+
}
440+
450441
private getAllSymbols(): LSP.SymbolInformation[] {
451442
// NOTE: this could be cached, it takes < 1 ms to generate for a project with 250 bash files...
452443
const symbols: LSP.SymbolInformation[] = []

server/src/server.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { BashCompletionItem, CompletionItemDataType } from './types'
1313
import { uniqueBasedOnHash } from './util/array'
1414
import { getShellDocumentation } from './util/sh'
1515

16+
const PARAMETER_EXPANSION_PREFIXES = new Set(['$', '${'])
17+
1618
/**
1719
* The BashServer glues together the separate components to implement
1820
* the various parts of the Language Server Protocol.
@@ -100,6 +102,7 @@ export default class BashServer {
100102
textDocumentSync: LSP.TextDocumentSyncKind.Full,
101103
completionProvider: {
102104
resolveProvider: true,
105+
triggerCharacters: ['$', '{'],
103106
},
104107
hoverProvider: true,
105108
documentHighlightProvider: true,
@@ -273,9 +276,11 @@ export default class BashServer {
273276
): LSP.DocumentHighlight[] | null {
274277
const word = this.getWordAtPoint(params)
275278
this.logRequest({ request: 'onDocumentHighlight', params, word })
279+
276280
if (!word) {
277-
return null
281+
return []
278282
}
283+
279284
return this.analyzer
280285
.findOccurrences(params.textDocument.uri, word)
281286
.map(n => ({ range: n.range }))
@@ -291,22 +296,51 @@ export default class BashServer {
291296
}
292297

293298
private onCompletion(params: LSP.TextDocumentPositionParams): BashCompletionItem[] {
294-
const word = this.getWordAtPoint(params)
299+
const word = this.getWordAtPoint({
300+
...params,
301+
position: {
302+
line: params.position.line,
303+
// Go one character back to get completion on the current word
304+
character: Math.max(params.position.character - 1, 0),
305+
},
306+
})
295307
this.logRequest({ request: 'onCompletion', params, word })
296308

309+
if (word && word.startsWith('#')) {
310+
// Inside a comment block
311+
return []
312+
}
313+
297314
const currentUri = params.textDocument.uri
298315

316+
// TODO: an improvement here would be to detect if the current word is
317+
// not only a parameter expansion prefix, but also if the word is actually
318+
// inside a parameter expansion (e.g. auto completing on a word $MY_VARIA).
319+
const shouldCompleteOnVariables = word
320+
? PARAMETER_EXPANSION_PREFIXES.has(word)
321+
: false
322+
299323
const symbolCompletions =
300324
word === null
301325
? []
302326
: this.getCompletionItemsForSymbols({
303-
symbols: this.analyzer.findSymbolsMatchingWord({
304-
exactMatch: false,
305-
word,
306-
}),
327+
symbols: shouldCompleteOnVariables
328+
? this.analyzer.getAllVariableSymbols()
329+
: this.analyzer.findSymbolsMatchingWord({
330+
exactMatch: false,
331+
word,
332+
}),
307333
currentUri,
308334
})
309335

336+
if (shouldCompleteOnVariables) {
337+
// In case we auto complete on a word that starts a parameter expansion,
338+
// we do not return anything else than variable/parameter suggestions.
339+
// Note: that LSP clients should not call onCompletion in the middle
340+
// of a word, so the following should work for client.
341+
return symbolCompletions
342+
}
343+
310344
const reservedWordsCompletions = ReservedWords.LIST.map(reservedWord => ({
311345
label: reservedWord,
312346
kind: LSP.SymbolKind.Interface, // ??
@@ -347,11 +381,6 @@ export default class BashServer {
347381
]
348382

349383
if (word) {
350-
if (word.startsWith('#')) {
351-
// Inside a comment block
352-
return []
353-
}
354-
355384
// Filter to only return suffixes of the current word
356385
return allCompletions.filter(item => item.label.startsWith(word))
357386
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ describe('getDocumentation', () => {
2020
expect(lines[1]).toContain('list directory contents')
2121
})
2222

23+
it('skips documentation for some builtins', async () => {
24+
const result = await sh.getShellDocumentation({ word: 'else' })
25+
expect(result).toEqual(null)
26+
})
27+
2328
it('sanity checks the given word', async () => {
2429
await expect(sh.getShellDocumentation({ word: 'ls foo' })).rejects.toThrow()
2530
})

server/src/util/sh.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export function execShellScript(body: string): Promise<string> {
2424
})
2525
}
2626

27+
// Currently only reserved words where documentation doesn't make sense.
28+
// At least on OS X these just return the builtin man. On ubuntu there
29+
// are no documentaiton for them.
30+
const WORDS_WITHOUT_DOCUMENTATION = new Set([
31+
'else',
32+
'fi',
33+
'then',
34+
'esac',
35+
'elif',
36+
'done',
37+
])
38+
2739
/**
2840
* Get documentation for the given word by usingZZ help and man.
2941
*/
@@ -36,6 +48,10 @@ export async function getShellDocumentationWithoutCache({
3648
throw new Error(`lookupDocumentation should be given a word, received "${word}"`)
3749
}
3850

51+
if (WORDS_WITHOUT_DOCUMENTATION.has(word)) {
52+
return null
53+
}
54+
3955
const DOCUMENTATION_COMMANDS = [
4056
{ type: 'help', command: `help ${word} | col -bx` },
4157
// We have experimented with setting MANWIDTH to different values for reformatting.

server/src/util/tree-sitter.ts

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Range } from 'vscode-languageserver/lib/main'
2-
import { Point, SyntaxNode } from 'web-tree-sitter'
2+
import { SyntaxNode } from 'web-tree-sitter'
33

44
export function forEach(node: SyntaxNode, cb: (n: SyntaxNode) => void) {
55
cb(node)
@@ -52,57 +52,3 @@ export function findParent(
5252
}
5353
return null
5454
}
55-
56-
/**
57-
* Given a tree and a point, try to find the named leaf node that the point corresponds to.
58-
* This is a helper for wordAtPoint, useful in cases where the point occurs at the boundary of
59-
* a word so the normal behavior of "namedDescendantForPosition" does not find the desired leaf.
60-
* For example, if you do
61-
* > (new Parser()).setLanguage(bash).parse("echo 42").rootNode.descendantForIndex(4).text
62-
* then you get 'echo 42', not the leaf node for 'echo'.
63-
*
64-
* TODO: the need for this function might reveal a flaw in tree-sitter-bash.
65-
*/
66-
export function namedLeafDescendantForPosition(
67-
point: Point,
68-
rootNode: SyntaxNode,
69-
): SyntaxNode | null {
70-
const node = rootNode.namedDescendantForPosition(point)
71-
72-
if (node.childCount === 0) {
73-
return node
74-
} else {
75-
// The node wasn't a leaf. Try to figure out what word we should use.
76-
const nodeToUse = searchForLeafNode(point, node)
77-
if (nodeToUse) {
78-
return nodeToUse
79-
} else {
80-
return null
81-
}
82-
}
83-
}
84-
85-
function searchForLeafNode(point: Point, parent: SyntaxNode): SyntaxNode | null {
86-
let child = parent.firstNamedChild
87-
88-
while (child) {
89-
if (
90-
pointsEqual(child.startPosition, point) ||
91-
pointsEqual(child.endPosition, point)
92-
) {
93-
if (child.childCount === 0) {
94-
return child
95-
} else {
96-
return searchForLeafNode(point, child)
97-
}
98-
}
99-
100-
child = child.nextNamedSibling
101-
}
102-
103-
return null
104-
}
105-
106-
function pointsEqual(point1: Point, point2: Point) {
107-
return point1.row === point2.row && point1.column === point2.column
108-
}

testing/fixtures/sourcing.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ add_a_us
1111
BOLD=`tput bold` # redefined
1212

1313
echo $BOL
14+
15+
echo "$"

0 commit comments

Comments
 (0)