Skip to content

Completion with sourcing suggestions #692

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
23 changes: 23 additions & 0 deletions server/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,29 @@ describe('server', () => {
]
`)
})

it('responds to onCompletion with source suggestions', async () => {
const { connection } = await initializeServer({ rootPath: REPO_ROOT_FOLDER })

const onCompletion = connection.onCompletion.mock.calls[0][0]

const result = await onCompletion(
{
textDocument: {
uri: FIXTURE_URI.SOURCING,
},
position: {
// after source
line: 2,
character: 8,
},
},
{} as any,
{} as any,
)

expect(result).toMatchInlineSnapshot(`Array []`)
})
})

describe('onCompletionResolve', () => {
Expand Down
12 changes: 10 additions & 2 deletions server/src/analyser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export default class Analyzer {
* It's currently not scope-aware, see findOccurrences.
*/
public findReferences(word: string): LSP.Location[] {
const uris = Object.keys(this.uriToAnalyzedDocument)
const uris = this.getAllUris()
return flattenArray(uris.map((uri) => this.findOccurrences(uri, word)))
}

Expand Down Expand Up @@ -319,6 +319,10 @@ export default class Analyzer {
return locations
}

public getAllUris(): string[] {
return Object.keys(this.uriToAnalyzedDocument)
}

public getAllVariables({
position,
uri,
Expand Down Expand Up @@ -412,6 +416,10 @@ export default class Analyzer {
}
}

public getSourcedUris(uri: string): Set<string> {
return this.uriToAnalyzedDocument[uri]?.sourcedUris || new Set([])
}

/**
* Find the name of the command at the given point.
*/
Expand Down Expand Up @@ -519,7 +527,7 @@ export default class Analyzer {
// Private methods
private getReachableUris({ uri: fromUri }: { uri?: string } = {}): string[] {
if (!fromUri || this.includeAllWorkspaceSymbols) {
return Object.keys(this.uriToAnalyzedDocument)
return this.getAllUris()
}

const uris = [fromUri, ...Array.from(this.findAllSourcedUris({ uri: fromUri }))]
Expand Down
63 changes: 55 additions & 8 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { uniqueBasedOnHash } from './util/array'
import { logger, setLogConnection, setLogLevel } from './util/logger'
import { isPositionIncludedInRange } from './util/lsp'
import { getShellDocumentation } from './util/sh'
import { SOURCING_COMMANDS } from './util/sourcing'

const PARAMETER_EXPANSION_PREFIXES = new Set(['$', '${'])
const CONFIGURATION_SECTION = 'bashIde'
Expand Down Expand Up @@ -115,7 +116,8 @@ export default class BashServer {
textDocumentSync: LSP.TextDocumentSyncKind.Full,
completionProvider: {
resolveProvider: true,
triggerCharacters: ['$', '{'],
// ' ' is needed for completion after a command (currently only for source)
triggerCharacters: ['$', '{', ' ', '.'],
},
hoverProvider: true,
documentHighlightProvider: true,
Expand Down Expand Up @@ -423,12 +425,15 @@ export default class BashServer {
}

private onCompletion(params: LSP.TextDocumentPositionParams): BashCompletionItem[] {
const currentUri = params.textDocument.uri
const previousCharacterPosition = Math.max(params.position.character - 1, 0)

const word = this.analyzer.wordAtPointFromTextPosition({
...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),
character: previousCharacterPosition,
},
})

Expand All @@ -439,9 +444,53 @@ export default class BashServer {
return []
}

if (word === '{') {
// We should not complete when it is not prefixed by a $.
// This case needs to be here as "{" is a completionProvider triggerCharacter.
if (word && ['{', '.'].includes(word)) {
// When the current word is a "{"" or a "." we should not complete.
// A valid completion word would be "${" or a "." command followed by an empty word.
return []
}

const commandNameBefore = this.analyzer.commandNameAtPoint(
params.textDocument.uri,
params.position.line,
// there might be a better way using the AST:
Math.max(params.position.character - 2, 0),
)
console.log(
'>>> commandNameBefore',
commandNameBefore,
Math.max(params.position.character - 2, 0),
)
const { workspaceFolder } = this
if (
workspaceFolder &&
commandNameBefore &&
SOURCING_COMMANDS.includes(commandNameBefore)
) {
const uris = this.analyzer
.getAllUris()
.filter((uri) => currentUri !== uri)
.map((uri) => uri.replace(workspaceFolder, '.').replace('file://', ''))

if (uris) {
// TODO: remove qoutes if the user already typed them
// TODO: figure out the base path based on other source commands
return uris.map((uri) => {
return {
label: uri,
kind: LSP.CompletionItemKind.File,
data: {
type: CompletionItemDataType.Symbol,
},
}
})
}
}

// TODO: maybe abort if commandNameBefore is a known command
if (word === ' ') {
// TODO: test this
// No command was found, so don't complete on space
return []
}

Expand All @@ -463,16 +512,14 @@ export default class BashServer {
params.textDocument.uri,
params.position.line,
// Go one character back to get completion on the current word
Math.max(params.position.character - 1, 0),
previousCharacterPosition,
)

if (commandName) {
options = getCommandOptions(commandName, word)
}
}

const currentUri = params.textDocument.uri

// TODO: an improvement here would be to detect if the current word is
// not only a parameter expansion prefix, but also if the word is actually
// inside a parameter expansion (e.g. auto completing on a word $MY_VARIA).
Expand Down
2 changes: 1 addition & 1 deletion server/src/util/shebang.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const SHEBANG_REGEXP = /^#!(.*)/
const SHELL_REGEXP = /bin[/](?:env )?(\w+)/

const BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh'] as const
const BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh'] as const // why not try to parse zsh? And let treesitter determine if it is supported
type SupportedBashDialect = (typeof BASH_DIALECTS)[number]

export function getShebang(fileContent: string): string | null {
Expand Down
7 changes: 4 additions & 3 deletions server/src/util/sourcing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as Parser from 'web-tree-sitter'
import { untildify } from './fs'
import * as TreeSitterUtil from './tree-sitter'

const SOURCING_COMMANDS = ['source', '.']
export const SOURCING_COMMANDS = ['source', '.']

export type SourceCommand = {
range: LSP.Range
Expand Down Expand Up @@ -55,11 +55,12 @@ function getSourcedPathInfoFromNode({
}: {
node: Parser.SyntaxNode
}): null | { sourcedPath?: string; parseError?: string } {
if (node.type === 'command') {
if (node && node.type === 'command') {
const [commandNameNode, argumentNode] = node.namedChildren
if (
commandNameNode.type === 'command_name' &&
SOURCING_COMMANDS.includes(commandNameNode.text)
SOURCING_COMMANDS.includes(commandNameNode.text) &&
argumentNode
) {
if (argumentNode.type === 'word') {
return {
Expand Down