diff --git a/server/src/__tests__/analyzer.test.ts b/server/src/__tests__/analyzer.test.ts index 7520acc2b..d1f41a7da 100644 --- a/server/src/__tests__/analyzer.test.ts +++ b/server/src/__tests__/analyzer.test.ts @@ -112,6 +112,24 @@ describe('wordAtPoint', () => { }) }) +describe('commandNameAtPoint', () => { + it('returns current command name at a given point', () => { + analyzer.analyze(CURRENT_URI, FIXTURES.INSTALL) + expect(analyzer.commandNameAtPoint(CURRENT_URI, 15, 0)).toEqual(null) + + expect(analyzer.commandNameAtPoint(CURRENT_URI, 20, 2)).toEqual('curl') + expect(analyzer.commandNameAtPoint(CURRENT_URI, 20, 15)).toEqual('curl') + expect(analyzer.commandNameAtPoint(CURRENT_URI, 20, 19)).toEqual('curl') + + expect(analyzer.commandNameAtPoint(CURRENT_URI, 26, 4)).toEqual('echo') + expect(analyzer.commandNameAtPoint(CURRENT_URI, 26, 9)).toEqual('echo') + + expect(analyzer.commandNameAtPoint(CURRENT_URI, 38, 13)).toEqual('env') + expect(analyzer.commandNameAtPoint(CURRENT_URI, 38, 24)).toEqual('grep') + expect(analyzer.commandNameAtPoint(CURRENT_URI, 38, 44)).toEqual('sed') + }) +}) + describe('findSymbolCompletions', () => { it('return a list of symbols across the workspace', () => { analyzer.analyze('install.sh', FIXTURES.INSTALL) @@ -250,7 +268,7 @@ describe('fromRoot', () => { expect(connection.window.showWarningMessage).not.toHaveBeenCalled() // if you add a .sh file to testing/fixtures, update this value - const FIXTURE_FILES_MATCHING_GLOB = 11 + const FIXTURE_FILES_MATCHING_GLOB = 12 // Intro, stats on glob, one file skipped due to shebang, and outro const LOG_LINES = FIXTURE_FILES_MATCHING_GLOB + 4 diff --git a/server/src/__tests__/server.test.ts b/server/src/__tests__/server.test.ts index 6dd4cd0ba..50eda7f2b 100644 --- a/server/src/__tests__/server.test.ts +++ b/server/src/__tests__/server.test.ts @@ -267,6 +267,41 @@ describe('server', () => { ) }) + it('responds to onCompletion with options list when command name is found', async () => { + const { connection, server } = await initializeServer() + server.register(connection) + + const onCompletion = connection.onCompletion.mock.calls[0][0] + + const result = await onCompletion( + { + textDocument: { + uri: FIXTURE_URI.OPTIONS, + }, + position: { + // grep --line- + line: 2, + character: 12, + }, + }, + {} as any, + {} as any, + ) + + expect(result).toEqual( + expect.arrayContaining([ + { + data: { + name: expect.stringMatching(RegExp('--line-.*')), + type: CompletionItemDataType.Symbol, + }, + kind: expect.any(Number), + label: expect.stringMatching(RegExp('--line-.*')), + }, + ]), + ) + }) + it('responds to onCompletion with entire list when no word is found', async () => { const { connection, server } = await initializeServer() server.register(connection) diff --git a/server/src/analyser.ts b/server/src/analyser.ts index 12986a677..7e4440247 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -367,9 +367,13 @@ export default class Analyzer { } /** - * Find the full word at the given point. + * Find the node at the given point. */ - public wordAtPoint(uri: string, line: number, column: number): string | null { + private nodeAtPoint( + uri: string, + line: number, + column: number, + ): Parser.SyntaxNode | null { const document = this.uriToTreeSitterTrees[uri] if (!document.rootNode) { @@ -377,7 +381,14 @@ export default class Analyzer { return null } - const node = document.rootNode.descendantForPosition({ row: line, column }) + return document.rootNode.descendantForPosition({ row: line, column }) + } + + /** + * Find the full word at the given point. + */ + public wordAtPoint(uri: string, line: number, column: number): string | null { + const node = this.nodeAtPoint(uri, line, column) if (!node || node.childCount > 0 || node.text.trim() === '') { return null @@ -386,6 +397,29 @@ export default class Analyzer { return node.text.trim() } + /** + * Find the name of the command at the given point. + */ + public commandNameAtPoint(uri: string, line: number, column: number): string | null { + let node = this.nodeAtPoint(uri, line, column) + + while (node && node.type !== 'command') { + node = node.parent + } + + if (!node) { + return null + } + + const firstChild = node.firstNamedChild + + if (!firstChild || firstChild.type !== 'command_name') { + return null + } + + return firstChild.text.trim() + } + /** * Find a block of comments above a line position */ diff --git a/server/src/get-options.sh b/server/src/get-options.sh new file mode 100755 index 000000000..46e26a28a --- /dev/null +++ b/server/src/get-options.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +DATADIR="$(pkg-config --variable=datadir bash-completion)" + +# Exit if bash-completion isn't installed. +if (( $? != 0 )) +then + exit 1 +fi + +source "$DATADIR/bash-completion/bash_completion" + +COMP_LINE="$*" +COMP_WORDS=("$@") +COMP_CWORD="${#COMP_WORDS[@]}" +((COMP_CWORD--)) +COMP_POINT="${#COMP_LINE}" +COMP_WORDBREAKS='"'"'><=;|&(:" + +_command_offset 0 2> /dev/null + +if (( ${#COMPREPLY[@]} == 0 )) +then + # Disabled by default because _longopt executes the program + # to get its options. + if (( ${BASH_LSP_COMPLETE_LONGOPTS} == 1 )) + then + _longopt "${COMP_WORDS[0]}" + fi +fi + +printf "%s\t" "${COMPREPLY[@]}" diff --git a/server/src/server.ts b/server/src/server.ts index b597a4153..05dac3ef5 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,4 +1,6 @@ +import * as Process from 'child_process' import * as path from 'path' +import * as Path from 'path' import * as TurndownService from 'turndown' import * as LSP from 'vscode-languageserver' import { TextDocument } from 'vscode-languageserver-textdocument' @@ -123,6 +125,16 @@ export default class BashServer { ) } + private getCommandNameAtPoint( + params: LSP.ReferenceParams | LSP.TextDocumentPositionParams, + ): string | null { + return this.analyzer.commandNameAtPoint( + params.textDocument.uri, + params.position.line, + params.position.character, + ) + } + private logRequest({ request, params, @@ -323,6 +335,22 @@ export default class BashServer { return [] } + let options: string[] = [] + if (word && word.startsWith('-')) { + const commandName = this.getCommandNameAtPoint({ + ...params, + position: { + line: params.position.line, + // Go one character back to get completion on the current word + character: Math.max(params.position.character - 1, 0), + }, + }) + + if (commandName) { + options = getCommandOptions(commandName, word) + } + } + const currentUri = params.textDocument.uri // TODO: an improvement here would be to detect if the current word is @@ -385,11 +413,21 @@ export default class BashServer { }, })) + const optionsCompletions = options.map(option => ({ + label: option, + kind: LSP.SymbolKind.Interface, + data: { + name: option, + type: CompletionItemDataType.Symbol, + }, + })) + const allCompletions = [ ...reservedWordsCompletions, ...symbolCompletions, ...programCompletions, ...builtinsCompletions, + ...optionsCompletions, ] if (word) { @@ -531,3 +569,21 @@ const getMarkdownContent = (documentation: string): LSP.MarkupContent => ({ // Passed as markdown for syntax highlighting kind: 'markdown' as const, }) + +function getCommandOptions(name: string, word: string): string[] { + // TODO: The options could be cached. + const options = Process.spawnSync(Path.join(__dirname, '../src/get-options.sh'), [ + name, + word, + ]) + + if (options.status !== 0) { + return [] + } + + return options.stdout + .toString() + .split('\t') + .map(l => l.trim()) + .filter(l => l.length > 0) +} diff --git a/testing/fixtures.ts b/testing/fixtures.ts index 7f71c04c7..09be8e85e 100644 --- a/testing/fixtures.ts +++ b/testing/fixtures.ts @@ -21,6 +21,7 @@ export const FIXTURE_URI = { PARSE_PROBLEMS: `file://${path.join(FIXTURE_FOLDER, 'parse-problems.sh')}`, SOURCING: `file://${path.join(FIXTURE_FOLDER, 'sourcing.sh')}`, COMMENT_DOC: `file://${path.join(FIXTURE_FOLDER, 'comment-doc-on-hover.sh')}`, + OPTIONS: `file://${path.join(FIXTURE_FOLDER, 'options.sh')}`, } export const FIXTURE_DOCUMENT = { @@ -30,6 +31,7 @@ export const FIXTURE_DOCUMENT = { PARSE_PROBLEMS: getDocument(FIXTURE_URI.PARSE_PROBLEMS), SOURCING: getDocument(FIXTURE_URI.SOURCING), COMMENT_DOC: getDocument(FIXTURE_URI.COMMENT_DOC), + OPTIONS: getDocument(FIXTURE_URI.OPTIONS), } export default FIXTURE_DOCUMENT diff --git a/testing/fixtures/options.sh b/testing/fixtures/options.sh new file mode 100644 index 000000000..39da180a6 --- /dev/null +++ b/testing/fixtures/options.sh @@ -0,0 +1,3 @@ +grep - +grep -- +grep --line-