From 838dbcb60b745b565d8e68992634bb4e86f744e2 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 14:13:37 +0100 Subject: [PATCH 1/7] Add failing test case with nested source command --- server/src/util/__tests__/sourcing.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/util/__tests__/sourcing.test.ts b/server/src/util/__tests__/sourcing.test.ts index 4d2e62fa8..68ac5d26a 100644 --- a/server/src/util/__tests__/sourcing.test.ts +++ b/server/src/util/__tests__/sourcing.test.ts @@ -22,7 +22,6 @@ describe('getSourcedUris', () => { it('returns an empty set if no files were sourced', () => { const fileContent = '' const result = getSourcedUris({ - fileContent, fileUri, rootPath: null, tree: parser.parse(fileContent), @@ -44,18 +43,20 @@ describe('getSourcedUris', () => { # source ... - source "./issue206.sh" # quoted file in fixtures folder - source "$LIBPATH" # dynamic imports not supported # conditional is currently not supported if [[ -z $__COMPLETION_LIB_LOADED ]]; then source "$LIBPATH" ; fi + . "$BASH_IT/themes/$BASH_IT_THEME/$BASH_IT_THEME.theme.bash" + show () { about 'Shows SVN proxy settings' group 'proxy' + source "./issue206.sh" # quoted file in fixtures folder + echo "SVN Proxy Settings" echo "==================" python2 - < { ` const result = getSourcedUris({ - fileContent, fileUri, rootPath: null, tree: parser.parse(fileContent), From d1e8a769db00c7a0635c479f8192e9d63e1d03f6 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 14:14:00 +0100 Subject: [PATCH 2/7] Use AST instead of nasty regular expressions --- server/src/analyser.ts | 1 - server/src/util/declarations.ts | 4 +- server/src/util/sourcing.ts | 86 ++++++++++++++++----------------- 3 files changed, 44 insertions(+), 47 deletions(-) diff --git a/server/src/analyser.ts b/server/src/analyser.ts index 9e0948904..ba8cd314a 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -78,7 +78,6 @@ export default class Analyzer { document, globalDeclarations, sourcedUris: sourcing.getSourcedUris({ - fileContent, fileUri: uri, rootPath: this.workspaceFolder, tree, diff --git a/server/src/util/declarations.ts b/server/src/util/declarations.ts index b288aabd3..0a1388b5a 100644 --- a/server/src/util/declarations.ts +++ b/server/src/util/declarations.ts @@ -77,7 +77,7 @@ export function getAllDeclarationsInTree({ }): LSP.SymbolInformation[] { const symbols: LSP.SymbolInformation[] = [] - TreeSitterUtil.forEach(tree.rootNode, (node: Parser.SyntaxNode) => { + TreeSitterUtil.forEach(tree.rootNode, (node) => { if (TreeSitterUtil.isDefinition(node)) { const symbol = nodeToSymbolInformation({ node, uri }) @@ -189,7 +189,7 @@ function getAllGlobalVariableDeclarations({ }) { const declarations: Declarations = {} - TreeSitterUtil.forEach(rootNode, (node: Parser.SyntaxNode) => { + TreeSitterUtil.forEach(rootNode, (node) => { if ( node.type === 'variable_assignment' && // exclude local variables diff --git a/server/src/util/sourcing.ts b/server/src/util/sourcing.ts index c342e4849..7fac56286 100644 --- a/server/src/util/sourcing.ts +++ b/server/src/util/sourcing.ts @@ -4,9 +4,9 @@ import * as LSP from 'vscode-languageserver' import * as Parser from 'web-tree-sitter' import { untildify } from './fs' +import { logger } from './logger' +import * as TreeSitterUtil from './tree-sitter' -// Until the grammar supports sourcing, we use this little regular expression -const SOURCING_STATEMENTS = /^(?:\t|[ ])*(?:source|[.])\s*(\S*)/ const SOURCING_COMMANDS = ['source', '.'] /** @@ -14,12 +14,10 @@ const SOURCING_COMMANDS = ['source', '.'] * sourced. Note that the URIs are resolved. */ export function getSourcedUris({ - fileContent, fileUri, rootPath, tree, }: { - fileContent: string fileUri: string rootPath: string | null tree: Parser.Tree @@ -27,26 +25,44 @@ export function getSourcedUris({ const uris: Set = new Set([]) const rootPaths = [path.dirname(fileUri), rootPath].filter(Boolean) as string[] - fileContent.split(/\r?\n/).forEach((line, lineIndex) => { - const match = line.match(SOURCING_STATEMENTS) - if (match) { - const [statement, word] = match - - if (tree.rootNode) { - const node = tree.rootNode.descendantForPosition({ - row: lineIndex, - column: statement.length - 2, - }) - if (['heredoc_body', 'raw_string'].includes(node?.type)) { - return + // find all source commands in the tree + TreeSitterUtil.forEach(tree.rootNode, (node) => { + if (node.type === 'command') { + const [commandNameNode, argumentNode] = node.namedChildren + if ( + commandNameNode.type === 'command_name' && + SOURCING_COMMANDS.includes(commandNameNode?.text) + ) { + let word = null + if (argumentNode.type === 'word') { + word = argumentNode.text + } else if (argumentNode.type === 'string') { + if (argumentNode.namedChildren.length === 0) { + word = argumentNode.text.slice(1, -1) + } else if ( + argumentNode.namedChildren.every((n) => n.type === 'simple_expansion') + ) { + // not supported + } else { + logger.warn( + 'Sourcing: unhandled argumentNode=string case', + argumentNode.namedChildren.map((c) => ({ type: c.type, text: c.text })), + ) + } + } else { + logger.warn('Sourcing: unhandled argumentNode case', argumentNode.type) } - } - const sourcedUri = getSourcedUri({ rootPaths, word }) - if (sourcedUri) { - uris.add(sourcedUri) + if (word) { + const sourcedUri = getSourcedUri({ rootPaths, word }) + if (sourcedUri) { + uris.add(sourcedUri) + } + } } } + + return true }) return uris @@ -102,17 +118,6 @@ export function getSourcedLocation({ return null } -const stripQuotes = (path: string): string => { - const first = path[0] - const last = path[path.length - 1] - - if (first === last && [`"`, `'`].includes(first)) { - return path.slice(1, -1) - } - - return path -} - /** * Tries to parse the given path and returns a URI if possible. * - Filters out dynamic sources @@ -131,27 +136,20 @@ function getSourcedUri({ rootPaths: string[] word: string }): string | null { - let unquotedPath = stripQuotes(word) - - if (unquotedPath.includes('$')) { - // NOTE: we don't support dynamic sourcing - return null - } - - if (unquotedPath.startsWith('~')) { - unquotedPath = untildify(unquotedPath) + if (word.startsWith('~')) { + word = untildify(word) } - if (unquotedPath.startsWith('/')) { - if (fs.existsSync(unquotedPath)) { - return `file://${unquotedPath}` + if (word.startsWith('/')) { + if (fs.existsSync(word)) { + return `file://${word}` } return null } // resolve relative path for (const rootPath of rootPaths) { - const potentialPath = path.join(rootPath.replace('file://', ''), unquotedPath) + const potentialPath = path.join(rootPath.replace('file://', ''), word) // check if path is a file if (fs.existsSync(potentialPath)) { From d7edf73b09e4a7627e0280074281439f3a468fd1 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 21:22:39 +0100 Subject: [PATCH 3/7] Improve sourcing diagnostics --- server/src/analyser.ts | 52 ++++-- .../__snapshots__/sourcing.test.ts.snap | 146 ++++++++++++++++ server/src/util/__tests__/sourcing.test.ts | 23 ++- server/src/util/lsp.ts | 13 ++ server/src/util/sourcing.ts | 161 ++++++++---------- 5 files changed, 281 insertions(+), 114 deletions(-) create mode 100644 server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap create mode 100644 server/src/util/lsp.ts diff --git a/server/src/analyser.ts b/server/src/analyser.ts index ba8cd314a..24f73fc78 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -17,6 +17,7 @@ import { } from './util/declarations' import { getFilePaths } from './util/fs' import { logger } from './util/logger' +import { isPositionIncludedInRange } from './util/lsp' import { analyzeShebang } from './util/shebang' import * as sourcing from './util/sourcing' import * as TreeSitterUtil from './util/tree-sitter' @@ -27,6 +28,7 @@ type AnalyzedDocument = { document: TextDocument globalDeclarations: GlobalDeclarations sourcedUris: Set + sourceCommands: sourcing.SourceCommand[] tree: Parser.Tree } @@ -74,17 +76,38 @@ export default class Analyzer { const { diagnostics, globalDeclarations } = getGlobalDeclarations({ tree, uri }) + const sourceCommands = sourcing.getSourceCommands({ + fileUri: uri, + rootPath: this.workspaceFolder, + tree, + }) + + const sourcedUris = new Set( + sourceCommands + .map((sourceCommand) => sourceCommand.uri) + .filter((uri): uri is string => uri !== null), + ) + this.uriToAnalyzedDocument[uri] = { document, globalDeclarations, - sourcedUris: sourcing.getSourcedUris({ - fileUri: uri, - rootPath: this.workspaceFolder, - tree, - }), + sourcedUris, + sourceCommands: sourceCommands.filter((sourceCommand) => !sourceCommand.error), tree, } + sourceCommands + .filter((sourceCommand) => sourceCommand.error) + .forEach((sourceCommand) => { + diagnostics.push( + LSP.Diagnostic.create( + sourceCommand.range, + `Source command could not be analyzed: ${sourceCommand.error}.`, + LSP.DiagnosticSeverity.Information, + ), + ) + }) + function findMissingNodes(node: Parser.SyntaxNode) { if (node.isMissing()) { diagnostics.push( @@ -197,20 +220,13 @@ export default class Analyzer { uri: string word: string }): LSP.Location[] { - const tree = this.uriToAnalyzedDocument[uri]?.tree + // If the word is sourced, return the location of the source file + const sourcedUri = this.uriToAnalyzedDocument[uri]?.sourceCommands + .filter((sourceCommand) => isPositionIncludedInRange(position, sourceCommand.range)) + .map((sourceCommand) => sourceCommand.uri)[0] - if (position && tree) { - // NOTE: when a word is a file path to a sourced file, we return a location to it. - const sourcedLocation = sourcing.getSourcedLocation({ - position, - rootPath: this.workspaceFolder, - tree, - uri, - word, - }) - if (sourcedLocation) { - return [sourcedLocation] - } + if (sourcedUri) { + return [LSP.Location.create(sourcedUri, LSP.Range.create(0, 0, 0, 0))] } return this.findDeclarationsMatchingWord({ diff --git a/server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap b/server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap new file mode 100644 index 000000000..b6f343d1e --- /dev/null +++ b/server/src/util/__tests__/__snapshots__/sourcing.test.ts.snap @@ -0,0 +1,146 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getSourcedUris returns a set of sourced files 2`] = ` +Array [ + Object { + "error": null, + "range": Object { + "end": Object { + "character": 28, + "line": 1, + }, + "start": Object { + "character": 6, + "line": 1, + }, + }, + "uri": "file:///Users/bash/file-in-path.sh", + }, + Object { + "error": null, + "range": Object { + "end": Object { + "character": 31, + "line": 3, + }, + "start": Object { + "character": 6, + "line": 3, + }, + }, + "uri": "file:///bin/extension.inc", + }, + Object { + "error": null, + "range": Object { + "end": Object { + "character": 22, + "line": 5, + }, + "start": Object { + "character": 6, + "line": 5, + }, + }, + "uri": "file:///Users/bash/x", + }, + Object { + "error": null, + "range": Object { + "end": Object { + "character": 36, + "line": 7, + }, + "start": Object { + "character": 6, + "line": 7, + }, + }, + "uri": "file:///Users/scripts/release-client.sh", + }, + Object { + "error": null, + "range": Object { + "end": Object { + "character": 23, + "line": 9, + }, + "start": Object { + "character": 6, + "line": 9, + }, + }, + "uri": "file:///Users/bash-user/myscript", + }, + Object { + "error": "expansion not supported", + "range": Object { + "end": Object { + "character": 23, + "line": 13, + }, + "start": Object { + "character": 6, + "line": 13, + }, + }, + "uri": null, + }, + Object { + "error": "expansion not supported", + "range": Object { + "end": Object { + "character": 66, + "line": 16, + }, + "start": Object { + "character": 49, + "line": 16, + }, + }, + "uri": null, + }, + Object { + "error": "expansion not supported", + "range": Object { + "end": Object { + "character": 66, + "line": 18, + }, + "start": Object { + "character": 6, + "line": 18, + }, + }, + "uri": null, + }, + Object { + "error": null, + "range": Object { + "end": Object { + "character": 30, + "line": 25, + }, + "start": Object { + "character": 8, + "line": 25, + }, + }, + "uri": "file:///Users/bash/issue206.sh", + }, + Object { + "error": "expansion not supported", + "range": Object { + "end": Object { + "character": 22, + "line": 43, + }, + "start": Object { + "character": 8, + "line": 43, + }, + }, + "uri": null, + }, +] +`; diff --git a/server/src/util/__tests__/sourcing.test.ts b/server/src/util/__tests__/sourcing.test.ts index 68ac5d26a..4c9b7d5ab 100644 --- a/server/src/util/__tests__/sourcing.test.ts +++ b/server/src/util/__tests__/sourcing.test.ts @@ -3,7 +3,7 @@ import * as os from 'os' import * as Parser from 'web-tree-sitter' import { initializeParser } from '../../parser' -import { getSourcedUris } from '../sourcing' +import { getSourceCommands } from '../sourcing' const fileDirectory = '/Users/bash' const fileUri = `${fileDirectory}/file.sh` @@ -21,12 +21,12 @@ jest.spyOn(os, 'homedir').mockImplementation(() => '/Users/bash-user') describe('getSourcedUris', () => { it('returns an empty set if no files were sourced', () => { const fileContent = '' - const result = getSourcedUris({ + const sourceCommands = getSourceCommands({ fileUri, rootPath: null, tree: parser.parse(fileContent), }) - expect(result).toEqual(new Set([])) + expect(sourceCommands).toEqual([]) }) it('returns a set of sourced files', () => { @@ -72,15 +72,26 @@ describe('getSourcedUris', () => { fi done + loadlib () { + source "$1.sh" + } + + loadlib "issue101" ` - const result = getSourcedUris({ + const sourceCommands = getSourceCommands({ fileUri, rootPath: null, tree: parser.parse(fileContent), }) - expect(result).toMatchInlineSnapshot(` + const sourcedUris = new Set( + sourceCommands + .map((sourceCommand) => sourceCommand.uri) + .filter((uri) => uri !== null), + ) + + expect(sourcedUris).toMatchInlineSnapshot(` Set { "file:///Users/bash/file-in-path.sh", "file:///bin/extension.inc", @@ -90,5 +101,7 @@ describe('getSourcedUris', () => { "file:///Users/bash/issue206.sh", } `) + + expect(sourceCommands).toMatchSnapshot() }) }) diff --git a/server/src/util/lsp.ts b/server/src/util/lsp.ts new file mode 100644 index 000000000..a335c206c --- /dev/null +++ b/server/src/util/lsp.ts @@ -0,0 +1,13 @@ +import * as LSP from 'vscode-languageserver/node' + +/** + * Determine if a position is included in a range. + */ +export function isPositionIncludedInRange(position: LSP.Position, range: LSP.Range) { + return ( + range.start.line <= position.line && + range.end.line >= position.line && + range.start.character <= position.character && + range.end.character >= position.character + ) +} diff --git a/server/src/util/sourcing.ts b/server/src/util/sourcing.ts index 7fac56286..7a9ec9416 100644 --- a/server/src/util/sourcing.ts +++ b/server/src/util/sourcing.ts @@ -9,11 +9,16 @@ import * as TreeSitterUtil from './tree-sitter' const SOURCING_COMMANDS = ['source', '.'] +export type SourceCommand = { + range: LSP.Range + uri: string | null // resolved URIs + error: string | null +} + /** - * Analysis the given file content and returns a set of URIs that are - * sourced. Note that the URIs are resolved. + * Analysis the given tree for source commands. */ -export function getSourcedUris({ +export function getSourceCommands({ fileUri, rootPath, tree, @@ -21,97 +26,72 @@ export function getSourcedUris({ fileUri: string rootPath: string | null tree: Parser.Tree -}): Set { - const uris: Set = new Set([]) +}): SourceCommand[] { + const sourceCommands: SourceCommand[] = [] + const rootPaths = [path.dirname(fileUri), rootPath].filter(Boolean) as string[] - // find all source commands in the tree TreeSitterUtil.forEach(tree.rootNode, (node) => { - if (node.type === 'command') { - const [commandNameNode, argumentNode] = node.namedChildren - if ( - commandNameNode.type === 'command_name' && - SOURCING_COMMANDS.includes(commandNameNode?.text) - ) { - let word = null - if (argumentNode.type === 'word') { - word = argumentNode.text - } else if (argumentNode.type === 'string') { - if (argumentNode.namedChildren.length === 0) { - word = argumentNode.text.slice(1, -1) - } else if ( - argumentNode.namedChildren.every((n) => n.type === 'simple_expansion') - ) { - // not supported - } else { - logger.warn( - 'Sourcing: unhandled argumentNode=string case', - argumentNode.namedChildren.map((c) => ({ type: c.type, text: c.text })), - ) - } - } else { - logger.warn('Sourcing: unhandled argumentNode case', argumentNode.type) - } + const sourcedPathInfo = getSourcedPathInfoFromNode({ node }) - if (word) { - const sourcedUri = getSourcedUri({ rootPaths, word }) - if (sourcedUri) { - uris.add(sourcedUri) - } - } - } + if (sourcedPathInfo) { + const { sourcedPath, parseError } = sourcedPathInfo + const uri = sourcedPath ? resolveSourcedUri({ rootPaths, sourcedPath }) : null + + sourceCommands.push({ + range: TreeSitterUtil.range(node), + uri, + error: uri ? null : parseError || 'failed to resolve path', + }) } return true }) - return uris + return sourceCommands } -/** - * Investigates if the given position is a path to a sourced file and maps it - * to a location. Useful for jump to definition. - * - * TODO: we could likely store the position as part of the getSourcedUris and - * get rid of this function. - * - * @returns an optional location - */ -export function getSourcedLocation({ - position, - rootPath, - tree, - uri, - word, +function getSourcedPathInfoFromNode({ + node, }: { - position: { line: number; character: number } - rootPath: string | null - tree: Parser.Tree - uri: string - word: string -}): LSP.Location | null { - // NOTE: when a word is a file path to a sourced file, we return a location to - // that file. - if (tree.rootNode) { - const node = tree.rootNode.descendantForPosition({ - row: position.line, - column: position.character, - }) - - if (!node || node.text.trim() !== word) { - throw new Error('Implementation error: word was not found at the given position') - } - - const isSourced = node.previousNamedSibling - ? SOURCING_COMMANDS.includes(node.previousNamedSibling.text.trim()) - : false - - const rootPaths = [path.dirname(uri), rootPath].filter(Boolean) as string[] + node: Parser.SyntaxNode +}): null | { sourcedPath?: string; parseError?: string } { + if (node.type === 'command') { + const [commandNameNode, argumentNode] = node.namedChildren + if ( + commandNameNode.type === 'command_name' && + SOURCING_COMMANDS.includes(commandNameNode.text) + ) { + if (argumentNode.type === 'word') { + return { + sourcedPath: argumentNode.text, + } + } - const sourcedUri = isSourced ? getSourcedUri({ word, rootPaths }) : null + if (argumentNode.type === 'string') { + if (argumentNode.namedChildren.length === 0) { + return { + sourcedPath: argumentNode.text.slice(1, -1), + } + } else if ( + argumentNode.namedChildren.every((n) => n.type === 'simple_expansion') + ) { + return { + parseError: 'expansion not supported', + } + } else { + logger.warn( + 'Sourcing: unhandled argumentNode=string case', + argumentNode.namedChildren.map((c) => ({ type: c.type, text: c.text })), + ) + } + } else { + logger.warn(`Sourcing: unhandled argumentNode=${argumentNode.type} case`) + } - if (sourcedUri) { - return LSP.Location.create(sourcedUri, LSP.Range.create(0, 0, 0, 0)) + return { + parseError: `unhandled case node type "${argumentNode.type}"`, + } } } @@ -119,8 +99,7 @@ export function getSourcedLocation({ } /** - * Tries to parse the given path and returns a URI if possible. - * - Filters out dynamic sources + * Tries to resolve the given sourced path and returns a URI if possible. * - Converts a relative paths to absolute paths * - Converts a tilde path to an absolute path * - Resolves the path @@ -129,27 +108,27 @@ export function getSourcedLocation({ * "If filename does not contain a slash, file names in PATH are used to find * the directory containing filename." (see https://ss64.com/osx/source.html) */ -function getSourcedUri({ +function resolveSourcedUri({ rootPaths, - word, + sourcedPath, }: { rootPaths: string[] - word: string + sourcedPath: string }): string | null { - if (word.startsWith('~')) { - word = untildify(word) + if (sourcedPath.startsWith('~')) { + sourcedPath = untildify(sourcedPath) } - if (word.startsWith('/')) { - if (fs.existsSync(word)) { - return `file://${word}` + if (sourcedPath.startsWith('/')) { + if (fs.existsSync(sourcedPath)) { + return `file://${sourcedPath}` } return null } // resolve relative path for (const rootPath of rootPaths) { - const potentialPath = path.join(rootPath.replace('file://', ''), word) + const potentialPath = path.join(rootPath.replace('file://', ''), sourcedPath) // check if path is a file if (fs.existsSync(potentialPath)) { From 7ad67fd0eff34f23d9c136d79c1d60bc5e49c3d9 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 21:23:00 +0100 Subject: [PATCH 4/7] Filter out logLevel env warning --- server/src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/config.ts b/server/src/config.ts index 692d429c9..3eac34e92 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -61,7 +61,8 @@ export function getConfigFromEnvironmentVariables(): { const environmentVariablesUsed = Object.entries(rawConfig) .map(([key, value]) => (typeof value !== 'undefined' ? key : null)) - .filter((key) => key !== null) as string[] + .filter((key): key is string => key !== null) + .filter((key) => key !== 'logLevel') // logLevel is a special case that we ignore const config = ConfigSchema.parse(rawConfig) From 784424f31a6960849fa7e72f311de0258b939949 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 21:23:26 +0100 Subject: [PATCH 5/7] Fix edge case where onHover same line sourced variables failed --- server/src/server.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/server.ts b/server/src/server.ts index 832ec6984..22a4a1726 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -364,6 +364,9 @@ export default class BashServer { symbol: LSP.SymbolInformation currentUri: string }): LSP.MarkupContent { + logger.debug( + `getDocumentationForSymbol: symbol=${symbol.name} uri=${symbol.location.uri}`, + ) const symbolUri = symbol.location.uri const symbolStartLine = symbol.location.range.start.line @@ -665,6 +668,9 @@ export default class BashServer { Builtins.isBuiltin(word) || (this.executables.isExecutableOnPATH(word) && symbolsMatchingWord.length == 0) ) { + logger.debug( + `onHover: getting shell documentation for reserved word or builtin or executable`, + ) const shellDocumentation = await getShellDocumentation({ word }) if (shellDocumentation) { return { contents: getMarkdownContent(shellDocumentation, 'man') } @@ -675,7 +681,11 @@ export default class BashServer { currentUri, }) // do not return hover referencing for the current line - .filter((symbol) => symbol.location.range.start.line !== params.position.line) + .filter( + (symbol) => + symbol.location.uri !== currentUri || + symbol.location.range.start.line !== params.position.line, + ) .map((symbol: LSP.SymbolInformation) => this.getDocumentationForSymbol({ currentUri, symbol }), ) From 61621d2af5ffbd91445fb04021415e7ee4594580 Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 21:51:07 +0100 Subject: [PATCH 6/7] Add additional context in diagnostics --- .../__snapshots__/analyzer.test.ts.snap | 38 ------- server/src/__tests__/analyzer.test.ts | 102 ++++++++++++++++-- server/src/__tests__/server.test.ts | 17 +++ server/src/analyser.ts | 33 ++++-- server/src/util/declarations.ts | 2 + server/src/util/sourcing.ts | 6 ++ testing/fixtures/sourcing.sh | 6 ++ 7 files changed, 144 insertions(+), 60 deletions(-) diff --git a/server/src/__tests__/__snapshots__/analyzer.test.ts.snap b/server/src/__tests__/__snapshots__/analyzer.test.ts.snap index c838b1df1..a4c7e0702 100644 --- a/server/src/__tests__/__snapshots__/analyzer.test.ts.snap +++ b/server/src/__tests__/__snapshots__/analyzer.test.ts.snap @@ -1,43 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`analyze returns a list of errors for a file with a missing node 1`] = ` -Array [ - Object { - "message": "Syntax error: expected \\"fi\\" somewhere in the file", - "range": Object { - "end": Object { - "character": 0, - "line": 12, - }, - "start": Object { - "character": 0, - "line": 12, - }, - }, - "severity": 2, - }, -] -`; - -exports[`analyze returns a list of errors for a file with parsing errors 1`] = ` -Array [ - Object { - "message": "Failed to parse", - "range": Object { - "end": Object { - "character": 1, - "line": 9, - }, - "start": Object { - "character": 0, - "line": 2, - }, - }, - "severity": 1, - }, -] -`; - exports[`findReferences returns a list of locations if parameter is found 1`] = ` Array [ Object { diff --git a/server/src/__tests__/analyzer.test.ts b/server/src/__tests__/analyzer.test.ts index 11de3e8a8..089d24010 100644 --- a/server/src/__tests__/analyzer.test.ts +++ b/server/src/__tests__/analyzer.test.ts @@ -36,30 +36,110 @@ beforeAll(async () => { }) describe('analyze', () => { - it('returns an empty list of errors for a file with no parsing errors', () => { - const result = analyzer.analyze({ + it('returns an empty list of diagnostics for a file with no parsing errors', () => { + const diagnostics = analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL, }) - expect(result).toEqual([]) + expect(diagnostics).toEqual([]) }) - it('returns a list of errors for a file with a missing node', () => { - const result = analyzer.analyze({ + it('returns a list of diagnostics for a file with a missing node', () => { + const diagnostics = analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.MISSING_NODE, }) - expect(result).not.toEqual([]) - expect(result).toMatchSnapshot() + expect(diagnostics).not.toEqual([]) + expect(diagnostics).toMatchInlineSnapshot(` + Array [ + Object { + "message": "Syntax error: expected \\"fi\\" somewhere in the file", + "range": Object { + "end": Object { + "character": 0, + "line": 12, + }, + "start": Object { + "character": 0, + "line": 12, + }, + }, + "severity": 2, + "source": "bash-language-server", + }, + ] + `) }) - it('returns a list of errors for a file with parsing errors', () => { - const result = analyzer.analyze({ + it('returns a list of diagnostics for a file with parsing errors', () => { + const diagnostics = analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.PARSE_PROBLEMS, }) - expect(result).not.toEqual([]) - expect(result).toMatchSnapshot() + expect(diagnostics).not.toEqual([]) + expect(diagnostics).toMatchInlineSnapshot(` + Array [ + Object { + "message": "Failed to parse", + "range": Object { + "end": Object { + "character": 1, + "line": 9, + }, + "start": Object { + "character": 0, + "line": 2, + }, + }, + "severity": 1, + "source": "bash-language-server", + }, + ] + `) + }) + + it('returns a list of diagnostics for a file with sourcing issues', async () => { + const parser = await initializeParser() + const newAnalyzer = new Analyzer({ + parser, + workspaceFolder: FIXTURE_FOLDER, + }) + const diagnostics = newAnalyzer.analyze({ + uri: CURRENT_URI, + document: FIXTURE_DOCUMENT.SOURCING, + }) + expect(diagnostics).not.toEqual([]) + expect(diagnostics).toMatchInlineSnapshot(` + Array [ + Object { + "message": "Source command could not be analyzed: expansion not supported. + + Note that enabling the configuration flag \\"includeAllWorkspaceSymbols\\" + would include all symbols in the workspace regardless of source commands. + But be aware that this will lead to false positive suggestions.", + "range": Object { + "end": Object { + "character": 16, + "line": 21, + }, + "start": Object { + "character": 2, + "line": 21, + }, + }, + "severity": 3, + "source": "bash-language-server", + }, + ] + `) + + // unless setIncludeAllWorkspaceSymbols set + newAnalyzer.setIncludeAllWorkspaceSymbols(true) + const diagnostics2 = newAnalyzer.analyze({ + uri: CURRENT_URI, + document: FIXTURE_DOCUMENT.SOURCING, + }) + expect(diagnostics2).toEqual([]) }) }) diff --git a/server/src/__tests__/server.test.ts b/server/src/__tests__/server.test.ts index 51c5e6075..dcac9dc70 100644 --- a/server/src/__tests__/server.test.ts +++ b/server/src/__tests__/server.test.ts @@ -208,6 +208,23 @@ describe('server', () => { }, "name": "BOLD", }, + Object { + "kind": 12, + "location": Object { + "range": Object { + "end": Object { + "character": 1, + "line": 22, + }, + "start": Object { + "character": 0, + "line": 20, + }, + }, + "uri": "file://${FIXTURE_FOLDER}sourcing.sh", + }, + "name": "loadlib", + }, ] `) }) diff --git a/server/src/analyser.ts b/server/src/analyser.ts index 24f73fc78..7f4b8d925 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -96,17 +96,26 @@ export default class Analyzer { tree, } - sourceCommands - .filter((sourceCommand) => sourceCommand.error) - .forEach((sourceCommand) => { - diagnostics.push( - LSP.Diagnostic.create( - sourceCommand.range, - `Source command could not be analyzed: ${sourceCommand.error}.`, - LSP.DiagnosticSeverity.Information, - ), - ) - }) + if (!this.includeAllWorkspaceSymbols) { + sourceCommands + .filter((sourceCommand) => sourceCommand.error) + .forEach((sourceCommand) => { + diagnostics.push( + LSP.Diagnostic.create( + sourceCommand.range, + [ + `Source command could not be analyzed: ${sourceCommand.error}.\n`, + 'Note that enabling the configuration flag "includeAllWorkspaceSymbols"', + 'would include all symbols in the workspace regardless of source commands.', + 'But be aware that this will lead to false positive suggestions.', + ].join('\n'), + LSP.DiagnosticSeverity.Information, + undefined, + 'bash-language-server', + ), + ) + }) + } function findMissingNodes(node: Parser.SyntaxNode) { if (node.isMissing()) { @@ -115,6 +124,8 @@ export default class Analyzer { TreeSitterUtil.range(node), `Syntax error: expected "${node.type}" somewhere in the file`, LSP.DiagnosticSeverity.Warning, + undefined, + 'bash-language-server', ), ) } else if (node.hasError()) { diff --git a/server/src/util/declarations.ts b/server/src/util/declarations.ts index 0a1388b5a..09c04e0a2 100644 --- a/server/src/util/declarations.ts +++ b/server/src/util/declarations.ts @@ -46,6 +46,8 @@ export function getGlobalDeclarations({ TreeSitterUtil.range(node), 'Failed to parse', LSP.DiagnosticSeverity.Error, + undefined, + 'bash-language-server', ), ) return diff --git a/server/src/util/sourcing.ts b/server/src/util/sourcing.ts index 7a9ec9416..d5e9e2038 100644 --- a/server/src/util/sourcing.ts +++ b/server/src/util/sourcing.ts @@ -68,6 +68,12 @@ function getSourcedPathInfoFromNode({ } } + if (argumentNode.type === 'simple_expansion') { + return { + parseError: 'expansion not supported', + } + } + if (argumentNode.type === 'string') { if (argumentNode.namedChildren.length === 0) { return { diff --git a/testing/fixtures/sourcing.sh b/testing/fixtures/sourcing.sh index 04c58ca3a..35227490f 100644 --- a/testing/fixtures/sourcing.sh +++ b/testing/fixtures/sourcing.sh @@ -17,3 +17,9 @@ echo "$" source ./scripts/tag-release.inc tagRelease '1.0.0' + +loadlib () { + source "$1.sh" +} + +loadlib "issue206" From eee60f0306ff5af54d5751dc82dda985fe8cb01a Mon Sep 17 00:00:00 2001 From: skovhus Date: Tue, 10 Jan 2023 22:09:27 +0100 Subject: [PATCH 7/7] Bump server version to 4.4.0 --- server/CHANGELOG.md | 9 +++++++++ server/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 079880c66..3ce580d40 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,5 +1,14 @@ # Bash Language Server +## 4.4.0 + +- Improve source command parser and include diagnostics when parser fails https://github.com/bash-lsp/bash-language-server/pull/673 +- Fix `onHover` bug where sourced symbols on the same line as a reference would hide documentation https://github.com/bash-lsp/bash-language-server/pull/673 + +## 4.3.2 + +- Improved CLI output https://github.com/bash-lsp/bash-language-server/pull/672 + ## 4.3.0 - Add centralized and configurable logger that can be controlled using the `BASH_IDE_LOG_LEVEL` environment variable and workspace configuration. https://github.com/bash-lsp/bash-language-server/pull/669 diff --git a/server/package.json b/server/package.json index 2e8a43184..1c1e2212f 100644 --- a/server/package.json +++ b/server/package.json @@ -3,7 +3,7 @@ "description": "A language server for Bash", "author": "Mads Hartmann", "license": "MIT", - "version": "4.3.2", + "version": "4.4.0", "main": "./out/server.js", "typings": "./out/server.d.ts", "bin": {