diff --git a/server/src/__tests__/__snapshots__/analyzer.test.ts.snap b/server/src/__tests__/__snapshots__/analyzer.test.ts.snap index 3b3671146..c838b1df1 100644 --- a/server/src/__tests__/__snapshots__/analyzer.test.ts.snap +++ b/server/src/__tests__/__snapshots__/analyzer.test.ts.snap @@ -22,7 +22,7 @@ Array [ exports[`analyze returns a list of errors for a file with parsing errors 1`] = ` Array [ Object { - "message": "Failed to parse expression", + "message": "Failed to parse", "range": Object { "end": Object { "character": 1, @@ -53,19 +53,6 @@ Array [ }, "uri": "dummy-uri.sh", }, - Object { - "range": Object { - "end": Object { - "character": 12, - "line": 148, - }, - "start": Object { - "character": 0, - "line": 148, - }, - }, - "uri": "dummy-uri.sh", - }, Object { "range": Object { "end": Object { @@ -108,7 +95,7 @@ Array [ ] `; -exports[`findSymbolsForFile issue 101 1`] = ` +exports[`getDeclarationsForUri issue 101 1`] = ` Array [ Object { "kind": 12, @@ -184,7 +171,7 @@ Array [ ] `; -exports[`findSymbolsForFile returns a list of SymbolInformation if uri is found 1`] = ` +exports[`getDeclarationsForUri returns a list of SymbolInformation if uri is found 1`] = ` Array [ Object { "kind": 13, @@ -225,68 +212,68 @@ Array [ "location": Object { "range": Object { "end": Object { - "character": 6, - "line": 54, + "character": 68, + "line": 38, }, "start": Object { "character": 0, - "line": 54, + "line": 38, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "configures", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 5, - "line": 83, + "character": 27, + "line": 40, }, "start": Object { "character": 0, - "line": 83, + "line": 40, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "npm_config_loglevel", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 8, - "line": 90, + "character": 31, + "line": 48, }, "start": Object { "character": 2, - "line": 90, + "line": 48, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "npm_config_loglevel", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 8, - "line": 97, + "character": 22, + "line": 53, }, "start": Object { - "character": 2, - "line": 97, + "character": 0, + "line": 53, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "node", }, Object { "kind": 13, @@ -294,11 +281,11 @@ Array [ "range": Object { "end": Object { "character": 6, - "line": 149, + "line": 54, }, "start": Object { "character": 0, - "line": 149, + "line": 54, }, }, "uri": "dummy-uri.sh", @@ -310,51 +297,51 @@ Array [ "location": Object { "range": Object { "end": Object { - "character": 6, - "line": 181, + "character": 15, + "line": 69, }, "start": Object { "character": 0, - "line": 181, + "line": 69, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "TMP", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 9, - "line": 183, + "character": 12, + "line": 71, }, "start": Object { "character": 2, - "line": 183, + "line": 71, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "TMP", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 8, - "line": 188, + "character": 19, + "line": 73, }, "start": Object { - "character": 2, - "line": 188, + "character": 0, + "line": 73, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "TMP", }, Object { "kind": 13, @@ -362,28 +349,28 @@ Array [ "range": Object { "end": Object { "character": 11, - "line": 190, + "line": 81, }, "start": Object { - "character": 4, - "line": 190, + "character": 0, + "line": 81, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "BACK", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 11, - "line": 220, + "character": 5, + "line": 83, }, "start": Object { - "character": 6, - "line": 220, + "character": 0, + "line": 83, }, }, "uri": "dummy-uri.sh", @@ -395,63 +382,63 @@ Array [ "location": Object { "range": Object { "end": Object { - "character": 15, - "line": 230, + "character": 12, + "line": 84, }, "start": Object { - "character": 10, - "line": 230, + "character": 0, + "line": 84, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "tar", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 16, - "line": 233, + "character": 25, + "line": 86, }, "start": Object { - "character": 10, - "line": 233, + "character": 2, + "line": 86, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "tar", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 16, - "line": 236, + "character": 22, + "line": 89, }, "start": Object { - "character": 10, - "line": 236, + "character": 2, + "line": 89, }, }, "uri": "dummy-uri.sh", }, - "name": "ret", + "name": "tar", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 6, - "line": 265, + "character": 8, + "line": 90, }, "start": Object { - "character": 0, - "line": 265, + "character": 2, + "line": 90, }, }, "uri": "dummy-uri.sh", @@ -463,204 +450,204 @@ Array [ "location": Object { "range": Object { "end": Object { - "character": 68, - "line": 38, + "character": 8, + "line": 97, }, "start": Object { - "character": 0, - "line": 38, + "character": 2, + "line": 97, }, }, "uri": "dummy-uri.sh", }, - "name": "configures", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 27, - "line": 40, + "character": 11, + "line": 116, }, "start": Object { "character": 0, - "line": 40, + "line": 116, }, }, "uri": "dummy-uri.sh", }, - "name": "npm_config_loglevel", + "name": "MAKE", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 31, - "line": 48, + "character": 25, + "line": 119, }, "start": Object { "character": 2, - "line": 48, + "line": 119, }, }, "uri": "dummy-uri.sh", }, - "name": "npm_config_loglevel", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 22, - "line": 53, + "character": 26, + "line": 123, }, "start": Object { - "character": 0, - "line": 53, + "character": 4, + "line": 123, }, }, "uri": "dummy-uri.sh", }, - "name": "node", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 15, - "line": 69, + "character": 17, + "line": 127, }, "start": Object { - "character": 0, - "line": 69, + "character": 6, + "line": 127, }, }, "uri": "dummy-uri.sh", }, - "name": "TMP", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 12, - "line": 71, + "character": 14, + "line": 131, }, "start": Object { "character": 2, - "line": 71, + "line": 131, }, }, "uri": "dummy-uri.sh", }, - "name": "TMP", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 19, - "line": 73, + "character": 13, + "line": 138, }, "start": Object { - "character": 0, - "line": 73, + "character": 2, + "line": 138, }, }, "uri": "dummy-uri.sh", }, - "name": "TMP", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 11, - "line": 81, + "character": 12, + "line": 145, }, "start": Object { - "character": 0, - "line": 81, + "character": 2, + "line": 145, }, }, "uri": "dummy-uri.sh", }, - "name": "BACK", + "name": "clean", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 12, - "line": 84, + "character": 37, + "line": 148, }, "start": Object { "character": 0, - "line": 84, + "line": 148, }, }, "uri": "dummy-uri.sh", }, - "name": "tar", + "name": "node_version", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 25, - "line": 86, + "character": 6, + "line": 149, }, "start": Object { - "character": 2, - "line": 86, + "character": 0, + "line": 149, }, }, "uri": "dummy-uri.sh", }, - "name": "tar", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 22, - "line": 89, + "character": 18, + "line": 158, }, "start": Object { - "character": 2, - "line": 89, + "character": 0, + "line": 158, }, }, "uri": "dummy-uri.sh", }, - "name": "tar", + "name": "t", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 11, - "line": 116, + "character": 16, + "line": 170, }, "start": Object { - "character": 0, - "line": 116, + "character": 6, + "line": 170, }, }, "uri": "dummy-uri.sh", }, - "name": "MAKE", + "name": "t", }, Object { "kind": 13, @@ -668,322 +655,322 @@ Array [ "range": Object { "end": Object { "character": 25, - "line": 119, + "line": 179, }, "start": Object { - "character": 2, - "line": 119, + "character": 0, + "line": 177, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "url", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 26, - "line": 123, + "character": 6, + "line": 181, }, "start": Object { - "character": 4, - "line": 123, + "character": 0, + "line": 181, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 17, - "line": 127, + "character": 9, + "line": 183, }, "start": Object { - "character": 6, - "line": 127, + "character": 2, + "line": 183, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 14, - "line": 131, + "character": 24, + "line": 187, }, "start": Object { "character": 2, - "line": 131, + "line": 185, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "url", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 13, - "line": 138, + "character": 8, + "line": 188, }, "start": Object { "character": 2, - "line": 138, + "line": 188, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 21, - "line": 255, + "character": 11, + "line": 190, }, "start": Object { - "character": 8, - "line": 255, + "character": 4, + "line": 190, }, }, "uri": "dummy-uri.sh", }, - "name": "make", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 12, - "line": 145, + "character": 65, + "line": 205, }, "start": Object { - "character": 2, - "line": 145, + "character": 6, + "line": 205, }, }, "uri": "dummy-uri.sh", }, - "name": "clean", + "name": "ver", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 18, - "line": 225, + "character": 15, + "line": 206, }, "start": Object { - "character": 10, - "line": 225, + "character": 6, + "line": 206, }, }, "uri": "dummy-uri.sh", }, - "name": "clean", + "name": "isnpm10", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 37, - "line": 148, + "character": 21, + "line": 211, }, "start": Object { - "character": 0, - "line": 148, + "character": 12, + "line": 211, }, }, "uri": "dummy-uri.sh", }, - "name": "node_version", + "name": "isnpm10", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 18, - "line": 158, + "character": 21, + "line": 215, }, "start": Object { - "character": 0, - "line": 158, + "character": 12, + "line": 215, }, }, "uri": "dummy-uri.sh", }, - "name": "t", + "name": "isnpm10", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 16, - "line": 170, + "character": 11, + "line": 220, }, "start": Object { "character": 6, - "line": 170, + "line": 220, }, }, "uri": "dummy-uri.sh", }, - "name": "t", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 25, - "line": 179, + "character": 18, + "line": 225, }, "start": Object { - "character": 0, - "line": 177, + "character": 10, + "line": 225, }, }, "uri": "dummy-uri.sh", }, - "name": "url", + "name": "clean", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 24, - "line": 187, + "character": 15, + "line": 230, }, "start": Object { - "character": 2, - "line": 185, + "character": 10, + "line": 230, }, }, "uri": "dummy-uri.sh", }, - "name": "url", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 65, - "line": 205, + "character": 22, + "line": 232, }, "start": Object { - "character": 6, - "line": 205, + "character": 10, + "line": 232, }, }, "uri": "dummy-uri.sh", }, - "name": "ver", + "name": "NODE", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 15, - "line": 206, + "character": 16, + "line": 233, }, "start": Object { - "character": 6, - "line": 206, + "character": 10, + "line": 233, }, }, "uri": "dummy-uri.sh", }, - "name": "isnpm10", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 21, - "line": 211, + "character": 22, + "line": 235, }, "start": Object { - "character": 12, - "line": 211, + "character": 10, + "line": 235, }, }, "uri": "dummy-uri.sh", }, - "name": "isnpm10", + "name": "NODE", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 21, - "line": 215, + "character": 16, + "line": 236, }, "start": Object { - "character": 12, - "line": 215, + "character": 10, + "line": 236, }, }, "uri": "dummy-uri.sh", }, - "name": "isnpm10", + "name": "ret", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 22, - "line": 232, + "character": 21, + "line": 255, }, "start": Object { - "character": 10, - "line": 232, + "character": 8, + "line": 255, }, }, "uri": "dummy-uri.sh", }, - "name": "NODE", + "name": "make", }, Object { "kind": 13, "location": Object { "range": Object { "end": Object { - "character": 22, - "line": 235, + "character": 6, + "line": 265, }, "start": Object { - "character": 10, - "line": 235, + "character": 0, + "line": 265, }, }, "uri": "dummy-uri.sh", }, - "name": "NODE", + "name": "ret", }, ] `; diff --git a/server/src/__tests__/analyzer.test.ts b/server/src/__tests__/analyzer.test.ts index 60b396887..c804c7302 100644 --- a/server/src/__tests__/analyzer.test.ts +++ b/server/src/__tests__/analyzer.test.ts @@ -18,7 +18,7 @@ const CURRENT_URI = 'dummy-uri.sh' const mockConsole = getMockConnection().console // if you add a .sh file to testing/fixtures, update this value -const FIXTURE_FILES_MATCHING_GLOB = 14 +const FIXTURE_FILES_MATCHING_GLOB = 15 const defaultConfig = getDefaultConfiguration() @@ -59,18 +59,12 @@ describe('analyze', () => { }) }) -describe('findDefinition', () => { - it('returns an empty list if word is not found', () => { - analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL }) - const result = analyzer.findDefinition({ uri: CURRENT_URI, word: 'foobar' }) - expect(result).toEqual([]) - }) - +describe('findDeclarationLocations', () => { it('returns a location to a file if word is the path in a sourcing statement', () => { const document = FIXTURE_DOCUMENT.SOURCING const { uri } = document analyzer.analyze({ uri, document }) - const result = analyzer.findDefinition({ + const result = analyzer.findDeclarationLocations({ uri, word: './extension.inc', position: { character: 10, line: 2 }, @@ -107,7 +101,7 @@ describe('findDefinition', () => { }) newAnalyzer.analyze({ uri, document }) - const result = newAnalyzer.findDefinition({ + const result = newAnalyzer.findDeclarationLocations({ uri, word: './scripts/tag-release.inc', position: { character: 10, line: 16 }, @@ -131,13 +125,13 @@ describe('findDefinition', () => { `) }) - it('returns a list of locations if parameter is found', () => { + it('returns a local reference if definition is found', () => { analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL }) - const result = analyzer.findDefinition({ + const result = analyzer.findDeclarationLocations({ + position: { character: 1, line: 148 }, uri: CURRENT_URI, word: 'node_version', }) - expect(result).not.toEqual([]) expect(result).toMatchInlineSnapshot(` Array [ Object { @@ -156,6 +150,32 @@ describe('findDefinition', () => { ] `) }) + + it('returns local declarations', () => { + analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL }) + const result = analyzer.findDeclarationLocations({ + position: { character: 12, line: 12 }, + uri: FIXTURE_URI.SCOPE, + word: 'X', + }) + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "range": Object { + "end": Object { + "character": 17, + "line": 12, + }, + "start": Object { + "character": 10, + "line": 12, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + ] + `) + }) }) describe('findReferences', () => { @@ -173,23 +193,23 @@ describe('findReferences', () => { }) }) -describe('findSymbolsForFile', () => { +describe('getDeclarationsForUri', () => { it('returns empty list if uri is not found', () => { analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL }) - const result = analyzer.findSymbolsForFile({ uri: 'foobar.sh' }) + const result = analyzer.getDeclarationsForUri({ uri: 'foobar.sh' }) expect(result).toEqual([]) }) it('returns a list of SymbolInformation if uri is found', () => { analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.INSTALL }) - const result = analyzer.findSymbolsForFile({ uri: CURRENT_URI }) + const result = analyzer.getDeclarationsForUri({ uri: CURRENT_URI }) expect(result).not.toEqual([]) expect(result).toMatchSnapshot() }) it('issue 101', () => { analyzer.analyze({ uri: CURRENT_URI, document: FIXTURE_DOCUMENT.ISSUE101 }) - const result = analyzer.findSymbolsForFile({ uri: CURRENT_URI }) + const result = analyzer.getDeclarationsForUri({ uri: CURRENT_URI }) expect(result).not.toEqual([]) expect(result).toMatchSnapshot() }) @@ -297,8 +317,8 @@ describe('commandNameAtPoint', () => { }) }) -describe('findSymbolsMatchingWord', () => { - it('return a list of symbols across the workspace when includeAllWorkspaceSymbols is true', async () => { +describe('findDeclarationsMatchingWord', () => { + it('returns a list of symbols across the workspace when includeAllWorkspaceSymbols is true', async () => { const parser = await initializeParser() const connection = getMockConnection() @@ -314,10 +334,11 @@ describe('findSymbolsMatchingWord', () => { }) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'npm_config_logl', uri: FIXTURE_URI.INSTALL, exactMatch: false, + position: { line: 1000, character: 0 }, }), ).toMatchInlineSnapshot(` Array [ @@ -338,39 +359,24 @@ describe('findSymbolsMatchingWord', () => { }, "name": "npm_config_loglevel", }, - Object { - "kind": 13, - "location": Object { - "range": Object { - "end": Object { - "character": 31, - "line": 48, - }, - "start": Object { - "character": 2, - "line": 48, - }, - }, - "uri": "file://${FIXTURE_FOLDER}install.sh", - }, - "name": "npm_config_loglevel", - }, ] `) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'xxxxxxxx', uri: FIXTURE_URI.INSTALL, exactMatch: false, + position: { line: 1000, character: 0 }, }), ).toMatchInlineSnapshot(`Array []`) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'BLU', uri: FIXTURE_URI.INSTALL, exactMatch: false, + position: { line: 6, character: 9 }, }), ).toMatchInlineSnapshot(` Array [ @@ -395,10 +401,11 @@ describe('findSymbolsMatchingWord', () => { `) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'BLU', uri: FIXTURE_URI.SOURCING, exactMatch: false, + position: { line: 6, character: 9 }, }), ).toMatchInlineSnapshot(` Array [ @@ -423,7 +430,7 @@ describe('findSymbolsMatchingWord', () => { `) }) - it('return a list of symbols accessible to the uri when includeAllWorkspaceSymbols is false', async () => { + it('returns a list of symbols accessible to the uri when includeAllWorkspaceSymbols is false', async () => { const parser = await initializeParser() const connection = getMockConnection() @@ -439,18 +446,20 @@ describe('findSymbolsMatchingWord', () => { }) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'BLU', uri: FIXTURE_URI.INSTALL, exactMatch: false, + position: { line: 1000, character: 0 }, }), ).toMatchInlineSnapshot(`Array []`) expect( - analyzer.findSymbolsMatchingWord({ + analyzer.findDeclarationsMatchingWord({ word: 'BLU', uri: FIXTURE_URI.SOURCING, exactMatch: false, + position: { line: 6, character: 9 }, }), ).toMatchInlineSnapshot(` Array [ @@ -474,6 +483,170 @@ describe('findSymbolsMatchingWord', () => { ] `) }) + + it('returns symbols depending on the scope', async () => { + const parser = await initializeParser() + const connection = getMockConnection() + + const analyzer = new Analyzer({ + console: connection.console, + parser, + includeAllWorkspaceSymbols: false, + workspaceFolder: FIXTURE_FOLDER, + }) + + const findWordFromLine = (word: string, line: number) => + analyzer.findDeclarationsMatchingWord({ + word, + uri: FIXTURE_URI.SCOPE, + exactMatch: true, + position: { line, character: 0 }, + }) + + // Variable or function defined yet + expect(findWordFromLine('X', 0)).toEqual([]) + expect(findWordFromLine('f', 0)).toEqual([]) + + // First definition + expect(findWordFromLine('X', 3)).toMatchInlineSnapshot(` + Array [ + Object { + "kind": 13, + "location": Object { + "range": Object { + "end": Object { + "character": 9, + "line": 2, + }, + "start": Object { + "character": 0, + "line": 2, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "X", + }, + ] + `) + + // Local variable definition + expect(findWordFromLine('X', 13)).toMatchInlineSnapshot(` + Array [ + Object { + "containerName": "g", + "kind": 13, + "location": Object { + "range": Object { + "end": Object { + "character": 17, + "line": 12, + }, + "start": Object { + "character": 10, + "line": 12, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "X", + }, + ] + `) + + // Local function definition + expect(findWordFromLine('f', 23)).toMatchInlineSnapshot(` + Array [ + Object { + "containerName": "g", + "kind": 12, + "location": Object { + "range": Object { + "end": Object { + "character": 5, + "line": 21, + }, + "start": Object { + "character": 4, + "line": 18, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "f", + }, + ] + `) + + // Last definition + expect(findWordFromLine('X', 1000)).toMatchInlineSnapshot(` + Array [ + Object { + "kind": 13, + "location": Object { + "range": Object { + "end": Object { + "character": 9, + "line": 4, + }, + "start": Object { + "character": 0, + "line": 4, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "X", + }, + ] + `) + + expect(findWordFromLine('f', 1000)).toMatchInlineSnapshot(` + Array [ + Object { + "kind": 12, + "location": Object { + "range": Object { + "end": Object { + "character": 1, + "line": 30, + }, + "start": Object { + "character": 0, + "line": 7, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "f", + }, + ] + `) + + // Global variable defined inside a function + expect(findWordFromLine('GLOBAL_1', 1000)).toMatchInlineSnapshot(` + Array [ + Object { + "containerName": "g", + "kind": 13, + "location": Object { + "range": Object { + "end": Object { + "character": 23, + "line": 13, + }, + "start": Object { + "character": 4, + "line": 13, + }, + }, + "uri": "file://${FIXTURE_FOLDER}scope.sh", + }, + "name": "GLOBAL_1", + }, + ] + `) + }) }) describe('commentsAbove', () => { @@ -596,7 +769,7 @@ describe('initiateBackgroundAnalysis', () => { }) }) -describe('getAllVariableSymbols', () => { +describe('getAllVariables', () => { it('returns all variable symbols', async () => { const document = FIXTURE_DOCUMENT.SOURCING const { uri } = document @@ -613,7 +786,8 @@ describe('getAllVariableSymbols', () => { newAnalyzer.analyze({ uri, document }) - expect(newAnalyzer.getAllVariableSymbols({ uri })).toMatchInlineSnapshot(` + expect(newAnalyzer.getAllVariables({ uri, position: { line: 20, character: 0 } })) + .toMatchInlineSnapshot(` Array [ Object { "kind": 13, @@ -717,24 +891,6 @@ describe('getAllVariableSymbols', () => { }, "name": "RESET", }, - Object { - "containerName": "tagRelease", - "kind": 13, - "location": Object { - "range": Object { - "end": Object { - "character": 8, - "line": 5, - }, - "start": Object { - "character": 2, - "line": 5, - }, - }, - "uri": "file://${REPO_ROOT_FOLDER}/scripts/tag-release.inc", - }, - "name": "tag", - }, ] `) }) diff --git a/server/src/__tests__/server.test.ts b/server/src/__tests__/server.test.ts index 4a0e93ff5..7a33503c1 100644 --- a/server/src/__tests__/server.test.ts +++ b/server/src/__tests__/server.test.ts @@ -221,7 +221,6 @@ describe('server', () => { {} as any, ) - // TODO: there is a superfluous range here on line 0: expect(result1).toMatchInlineSnapshot(` Array [ Object { @@ -236,18 +235,6 @@ describe('server', () => { }, }, }, - Object { - "range": Object { - "end": Object { - "character": 12, - "line": 0, - }, - "start": Object { - "character": 9, - "line": 0, - }, - }, - }, Object { "range": Object { "end": Object { @@ -279,6 +266,134 @@ describe('server', () => { ) expect(result2).toMatchInlineSnapshot(`Array []`) + + const result3 = await onDocumentHighlight( + { + textDocument: { + uri: FIXTURE_URI.SCOPE, + }, + position: { + // X + line: 32, + character: 8, + }, + }, + {} as any, + {} as any, + ) + + expect(result3).toMatchInlineSnapshot(` + Array [ + Object { + "range": Object { + "end": Object { + "character": 1, + "line": 2, + }, + "start": Object { + "character": 0, + "line": 2, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 1, + "line": 4, + }, + "start": Object { + "character": 0, + "line": 4, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 9, + "line": 8, + }, + "start": Object { + "character": 8, + "line": 8, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 11, + "line": 12, + }, + "start": Object { + "character": 10, + "line": 12, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 13, + "line": 15, + }, + "start": Object { + "character": 12, + "line": 15, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 13, + "line": 19, + }, + "start": Object { + "character": 12, + "line": 19, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 15, + "line": 20, + }, + "start": Object { + "character": 14, + "line": 20, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 11, + "line": 29, + }, + "start": Object { + "character": 10, + "line": 29, + }, + }, + }, + Object { + "range": Object { + "end": Object { + "character": 9, + "line": 32, + }, + "start": Object { + "character": 8, + "line": 32, + }, + }, + }, + ] + `) }) it('responds to onWorkspaceSymbol', async () => { @@ -307,17 +422,6 @@ describe('server', () => { }, name: 'npm_config_loglevel', }, - { - kind: expect.any(Number), - location: { - range: { - end: { character: 31, line: 48 }, - start: { character: 2, line: 48 }, - }, - uri: expect.stringContaining('/testing/fixtures/install.sh'), - }, - name: 'npm_config_loglevel', - }, ]) } @@ -669,58 +773,6 @@ describe('server', () => { "kind": 6, "label": "RESET", }, - Object { - "data": Object { - "name": "USER", - "type": 3, - }, - "documentation": Object { - "kind": "markdown", - "value": "Variable: **USER** - *defined in issue101.sh*", - }, - "kind": 6, - "label": "USER", - }, - Object { - "data": Object { - "name": "PASSWORD", - "type": 3, - }, - "documentation": Object { - "kind": "markdown", - "value": "Variable: **PASSWORD** - *defined in issue101.sh*", - }, - "kind": 6, - "label": "PASSWORD", - }, - Object { - "data": Object { - "name": "COMMENTS", - "type": 3, - }, - "documentation": Object { - "kind": "markdown", - "value": "Variable: **COMMENTS** - *defined in issue101.sh* - - \`\`\`txt - Having shifted twice, the rest is now comments ... - \`\`\`", - }, - "kind": 6, - "label": "COMMENTS", - }, - Object { - "data": Object { - "name": "tag", - "type": 3, - }, - "documentation": Object { - "kind": "markdown", - "value": "Variable: **tag** - *defined in ../../scripts/tag-release.inc*", - }, - "kind": 6, - "label": "tag", - }, ] `) }) @@ -849,30 +901,27 @@ describe('server', () => { `) }) - it.failing( - 'returns executable documentation if the function is not redefined', - async () => { - const { connection, server } = await initializeServer() - server.register(connection) + it('returns executable documentation if the function is not redefined', async () => { + const { connection, server } = await initializeServer() + server.register(connection) - const onHover = connection.onHover.mock.calls[0][0] + const onHover = connection.onHover.mock.calls[0][0] - const result = await onHover( - { - textDocument: { - uri: FIXTURE_URI.OVERRIDE_SYMBOL, - }, - position: { - line: 2, - character: 1, - }, + const result = await onHover( + { + textDocument: { + uri: FIXTURE_URI.OVERRIDE_SYMBOL, }, - {} as any, - {} as any, - ) + position: { + line: 2, + character: 1, + }, + }, + {} as any, + {} as any, + ) - expect(result).toBeDefined() - expect((result as any)?.contents.value).toContain('ls – list directory contents') - }, - ) + expect(result).toBeDefined() + expect((result as any)?.contents.value).toContain('list directory contents') + }) }) diff --git a/server/src/analyser.ts b/server/src/analyser.ts index 303cf60c0..7a2c35e86 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -3,12 +3,18 @@ import * as FuzzySearch from 'fuzzy-search' import fetch from 'node-fetch' import * as URI from 'urijs' import * as url from 'url' -import { promisify } from 'util' +import { isDeepStrictEqual, promisify } from 'util' import * as LSP from 'vscode-languageserver/node' import { TextDocument } from 'vscode-languageserver-textdocument' import * as Parser from 'web-tree-sitter' -import { flattenArray, flattenObjectValues } from './util/flatten' +import { + getAllDeclarationsInTree, + getGlobalDeclarations, + getLocalDeclarations, + GlobalDeclarations, +} from './util/declarations' +import { flattenArray } from './util/flatten' import { getFilePaths } from './util/fs' import { analyzeShebang } from './util/shebang' import * as sourcing from './util/sourcing' @@ -16,20 +22,11 @@ import * as TreeSitterUtil from './util/tree-sitter' const readFileAsync = promisify(fs.readFile) -const TREE_SITTER_TYPE_TO_LSP_KIND: { [type: string]: LSP.SymbolKind | undefined } = { - // These keys are using underscores as that's the naming convention in tree-sitter. - environment_variable_assignment: LSP.SymbolKind.Variable, - function_definition: LSP.SymbolKind.Function, - variable_assignment: LSP.SymbolKind.Variable, -} - -type Declarations = { [word: string]: LSP.SymbolInformation[] } - type AnalyzedDocument = { document: TextDocument - tree: Parser.Tree - declarations: Declarations + globalDeclarations: GlobalDeclarations sourcedUris: Set + tree: Parser.Tree } /** @@ -60,6 +57,57 @@ export default class Analyzer { this.workspaceFolder = workspaceFolder } + /** + * Analyze the given document, cache the tree-sitter AST, and iterate over the + * tree to find declarations. + * + * Returns all, if any, syntax errors that occurred while parsing the file. + * + */ + public analyze({ + document, + uri, // NOTE: we don't use document.uri to make testing easier + }: { + document: TextDocument + uri: string + }): LSP.Diagnostic[] { + const fileContent = document.getText() + + const tree = this.parser.parse(fileContent) + + const { diagnostics, globalDeclarations } = getGlobalDeclarations({ tree, uri }) + + this.uriToAnalyzedDocument[uri] = { + document, + globalDeclarations, + sourcedUris: sourcing.getSourcedUris({ + fileContent, + fileUri: uri, + rootPath: this.workspaceFolder, + tree, + }), + tree, + } + + function findMissingNodes(node: Parser.SyntaxNode) { + if (node.isMissing()) { + diagnostics.push( + LSP.Diagnostic.create( + TreeSitterUtil.range(node), + `Syntax error: expected "${node.type}" somewhere in the file`, + LSP.DiagnosticSeverity.Warning, + ), + ) + } else if (node.hasError()) { + node.children.forEach(findMissingNodes) + } + } + + findMissingNodes(tree.rootNode) + + return diagnostics + } + /** * Initiates a background analysis of the files in the workspaceFolder to * enable features across files. @@ -146,14 +194,14 @@ export default class Analyzer { } /** - * Find all the locations where something has been defined. + * Find all the locations where the word was declared. */ - public findDefinition({ + public findDeclarationLocations({ position, uri, word, }: { - position?: { line: number; character: number } + position: LSP.Position uri: string word: string }): LSP.Location[] { @@ -173,329 +221,185 @@ export default class Analyzer { } } - return this.getReachableUris({ uri }) - .reduce((symbols, uri) => { - const analyzedDocument = this.uriToAnalyzedDocument[uri] - if (analyzedDocument) { - const declarationNames = analyzedDocument.declarations[word] || [] - declarationNames.forEach((d) => symbols.push(d)) - } - return symbols - }, [] as LSP.SymbolInformation[]) - .map((symbol) => symbol.location) + return this.findDeclarationsMatchingWord({ + exactMatch: true, + position, + uri, + word, + }).map((symbol) => symbol.location) } /** - * Find all the symbols matching the query using fuzzy search. + * Find all the declaration symbols in the workspace matching the query using fuzzy search. */ - public search(query: string): LSP.SymbolInformation[] { - const searcher = new FuzzySearch(this.getAllSymbols(), ['name'], { + public findDeclarationsWithFuzzySearch(query: string): LSP.SymbolInformation[] { + const searcher = new FuzzySearch(this.getAllDeclarations(), ['name'], { caseSensitive: true, }) return searcher.search(query) } - public async getExplainshellDocumentation({ - params, - endpoint, + /** + * Find declarations for the given word and position. + */ + public findDeclarationsMatchingWord({ + exactMatch, + position, + uri, + word, }: { - params: LSP.TextDocumentPositionParams - endpoint: string - }): Promise<{ helpHTML?: string }> { - const analyzedDocument = this.uriToAnalyzedDocument[params.textDocument.uri] - - const leafNode = analyzedDocument?.tree.rootNode.descendantForPosition({ - row: params.position.line, - column: params.position.character, + exactMatch: boolean + position: LSP.Position + uri: string + word: string + }): LSP.SymbolInformation[] { + return this.getAllDeclarations({ uri, position }).filter((symbol) => { + if (exactMatch) { + return symbol.name === word + } else { + return symbol.name.startsWith(word) + } }) - - if (!leafNode || !analyzedDocument) { - return {} - } - - // explainshell needs the whole command, not just the "word" (tree-sitter - // parlance) that the user hovered over. A relatively successful heuristic - // is to simply go up one level in the AST. If you go up too far, you'll - // start to include newlines, and explainshell completely balks when it - // encounters newlines. - const interestingNode = leafNode.type === 'word' ? leafNode.parent : leafNode - - if (!interestingNode) { - return {} - } - - const cmd = analyzedDocument.document - .getText() - .slice(interestingNode.startIndex, interestingNode.endIndex) - type ExplainshellResponse = { - matches?: Array<{ helpHTML: string; start: number; end: number }> - } - - const url = URI(endpoint).path('/api/explain').addQuery('cmd', cmd).toString() - const explainshellRawResponse = await fetch(url) - const explainshellResponse = - (await explainshellRawResponse.json()) as ExplainshellResponse - - if (!explainshellRawResponse.ok) { - throw new Error(`HTTP request failed: ${url}`) - } else if (!explainshellResponse.matches) { - return {} - } else { - const offsetOfMousePointerInCommand = - analyzedDocument.document.offsetAt(params.position) - interestingNode.startIndex - - const match = explainshellResponse.matches.find( - (helpItem) => - helpItem.start <= offsetOfMousePointerInCommand && - offsetOfMousePointerInCommand < helpItem.end, - ) - - return { helpHTML: match && match.helpHTML } - } } /** - * Find all the locations where something named name has been defined. + * Find all the locations where the given word was defined or referenced. + * + * FIXME: take position into account + * FIXME: take file into account */ - public findReferences(name: string): LSP.Location[] { + public findReferences(word: string): LSP.Location[] { const uris = Object.keys(this.uriToAnalyzedDocument) - return flattenArray(uris.map((uri) => this.findOccurrences(uri, name))) + return flattenArray(uris.map((uri) => this.findOccurrences(uri, word))) } /** - * Find all occurrences of name in the given file. + * Find all occurrences (references or definitions) of a word in the given file. * It's currently not scope-aware. + * + * FIXME: should this take the scope into account? I guess it should + * as this is used for highlighting. */ - public findOccurrences(uri: string, query: string): LSP.Location[] { + public findOccurrences(uri: string, word: string): LSP.Location[] { const analyzedDocument = this.uriToAnalyzedDocument[uri] if (!analyzedDocument) { return [] } const { tree } = analyzedDocument - const contents = analyzedDocument.document.getText() const locations: LSP.Location[] = [] TreeSitterUtil.forEach(tree.rootNode, (n) => { - let name: null | string = null - let range: null | LSP.Range = null + let namedNode: Parser.SyntaxNode | null = null if (TreeSitterUtil.isReference(n)) { - const node = n.firstNamedChild || n - name = contents.slice(node.startIndex, node.endIndex) - range = TreeSitterUtil.range(node) + namedNode = n.firstNamedChild || n } else if (TreeSitterUtil.isDefinition(n)) { - const namedNode = n.firstNamedChild - if (namedNode) { - name = contents.slice(namedNode.startIndex, namedNode.endIndex) - range = TreeSitterUtil.range(namedNode) - } + namedNode = n.firstNamedChild } - if (name === query && range !== null) { - locations.push(LSP.Location.create(uri, range)) + if (namedNode && namedNode.text === word) { + const range = TreeSitterUtil.range(namedNode) + + const alreadyInLocations = locations.some((loc) => { + return isDeepStrictEqual(loc.range, range) + }) + + if (!alreadyInLocations) { + locations.push(LSP.Location.create(uri, range)) + } } }) return locations } - /** - * Find all symbol definitions in the given file. - */ - public findSymbolsForFile({ uri }: { uri: string }): LSP.SymbolInformation[] { - const declarationsInFile = this.uriToAnalyzedDocument[uri]?.declarations || {} - return flattenObjectValues(declarationsInFile) - } - - /** - * Find symbol completions for the given word. - */ - public findSymbolsMatchingWord({ - exactMatch, + public getAllVariables({ + position, uri, - word, }: { - exactMatch: boolean + position: LSP.Position uri: string - word: string }): LSP.SymbolInformation[] { - return this.getReachableUris({ uri }).reduce((symbols, uri) => { - const analyzedDocument = this.uriToAnalyzedDocument[uri] - - if (analyzedDocument) { - const { declarations } = analyzedDocument - Object.keys(declarations).map((name) => { - const match = exactMatch ? name === word : name.startsWith(word) - if (match) { - declarations[name].forEach((symbol) => symbols.push(symbol)) - } - }) - } - return symbols - }, [] as LSP.SymbolInformation[]) + return this.getAllDeclarations({ uri, position }).filter( + (symbol) => symbol.kind === LSP.SymbolKind.Variable, + ) } /** - * Analyze the given document, cache the tree-sitter AST, and iterate over the - * tree to find declarations. - * - * Returns all, if any, syntax errors that occurred while parsing the file. + * Get all symbol declarations in the given file. This is used for generating an outline. * + * TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found in a given text document. */ - public analyze({ - document, - uri, // NOTE: we don't use document.uri to make testing easier - }: { - document: TextDocument - uri: string - }): LSP.Diagnostic[] { - const contents = document.getText() - - const tree = this.parser.parse(contents) - - const problems: LSP.Diagnostic[] = [] - - const declarations: Declarations = {} - - // TODO: move this somewhere - TreeSitterUtil.forEach(tree.rootNode, (n: Parser.SyntaxNode) => { - if (n.type === 'ERROR') { - problems.push( - LSP.Diagnostic.create( - TreeSitterUtil.range(n), - 'Failed to parse expression', - LSP.DiagnosticSeverity.Error, - ), - ) - return - } else if (TreeSitterUtil.isDefinition(n)) { - const named = n.firstNamedChild - - if (named === null) { - return - } - - const word = contents.slice(named.startIndex, named.endIndex) - const namedDeclarations = declarations[word] || [] - - const parent = TreeSitterUtil.findParent( - n, - (p) => p.type === 'function_definition', - ) - const parentName = - parent && parent.firstNamedChild - ? contents.slice( - parent.firstNamedChild.startIndex, - parent.firstNamedChild.endIndex, - ) - : '' // TODO: unsure what we should do here? - - const kind = TREE_SITTER_TYPE_TO_LSP_KIND[n.type] - - if (!kind) { - this.console.warn( - `Unmapped tree sitter type: ${n.type}, defaulting to variable`, - ) - } - - namedDeclarations.push( - LSP.SymbolInformation.create( - word, - kind || LSP.SymbolKind.Variable, - TreeSitterUtil.range(n), - uri, - parentName, - ), - ) - declarations[word] = namedDeclarations - } - }) - - this.uriToAnalyzedDocument[uri] = { - tree, - document, - declarations, - sourcedUris: sourcing.getSourcedUris({ - fileContent: contents, - fileUri: uri, - rootPath: this.workspaceFolder, - tree, - }), - } + public getDeclarationsForUri({ uri }: { uri: string }): LSP.SymbolInformation[] { + const tree = this.uriToAnalyzedDocument[uri]?.tree - function findMissingNodes(node: Parser.SyntaxNode) { - if (node.isMissing()) { - problems.push( - LSP.Diagnostic.create( - TreeSitterUtil.range(node), - `Syntax error: expected "${node.type}" somewhere in the file`, - LSP.DiagnosticSeverity.Warning, - ), - ) - } else if (node.hasError()) { - node.children.forEach(findMissingNodes) - } + if (!tree?.rootNode) { + return [] } - findMissingNodes(tree.rootNode) - - return problems + return getAllDeclarationsInTree({ uri, tree }) } - public findAllSourcedUris({ uri }: { uri: string }): Set { - const allSourcedUris = new Set([]) - - const addSourcedFilesFromUri = (fromUri: string) => { - const sourcedUris = this.uriToAnalyzedDocument[fromUri]?.sourcedUris + // TODO: move somewhere else than the analyzer... + public async getExplainshellDocumentation({ + params, + endpoint, + }: { + params: LSP.TextDocumentPositionParams + endpoint: string + }): Promise<{ helpHTML?: string }> { + const analyzedDocument = this.uriToAnalyzedDocument[params.textDocument.uri] - if (!sourcedUris) { - return - } + const leafNode = analyzedDocument?.tree.rootNode.descendantForPosition({ + row: params.position.line, + column: params.position.character, + }) - sourcedUris.forEach((sourcedUri) => { - if (!allSourcedUris.has(sourcedUri)) { - allSourcedUris.add(sourcedUri) - addSourcedFilesFromUri(sourcedUri) - } - }) + if (!leafNode || !analyzedDocument) { + return {} } - addSourcedFilesFromUri(uri) - - return allSourcedUris - } + // explainshell needs the whole command, not just the "word" (tree-sitter + // parlance) that the user hovered over. A relatively successful heuristic + // is to simply go up one level in the AST. If you go up too far, you'll + // start to include newlines, and explainshell completely balks when it + // encounters newlines. + const interestingNode = leafNode.type === 'word' ? leafNode.parent : leafNode - /** - * Find the node at the given point. - */ - private nodeAtPoint( - uri: string, - line: number, - column: number, - ): Parser.SyntaxNode | null { - const tree = this.uriToAnalyzedDocument[uri]?.tree + if (!interestingNode) { + return {} + } - if (!tree?.rootNode) { - // Check for lacking rootNode (due to failed parse?) - return null + const cmd = analyzedDocument.document + .getText() + .slice(interestingNode.startIndex, interestingNode.endIndex) + type ExplainshellResponse = { + matches?: Array<{ helpHTML: string; start: number; end: number }> } - return tree.rootNode.descendantForPosition({ row: line, column }) - } + const url = URI(endpoint).path('/api/explain').addQuery('cmd', cmd).toString() + const explainshellRawResponse = await fetch(url) + const explainshellResponse = + (await explainshellRawResponse.json()) as ExplainshellResponse - /** - * 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 (!explainshellRawResponse.ok) { + throw new Error(`HTTP request failed: ${url}`) + } else if (!explainshellResponse.matches) { + return {} + } else { + const offsetOfMousePointerInCommand = + analyzedDocument.document.offsetAt(params.position) - interestingNode.startIndex - if (!node || node.childCount > 0 || node.text.trim() === '') { - return null - } + const match = explainshellResponse.matches.find( + (helpItem) => + helpItem.start <= offsetOfMousePointerInCommand && + offsetOfMousePointerInCommand < helpItem.end, + ) - return node.text.trim() + return { helpHTML: match && match.helpHTML } + } } /** @@ -575,16 +479,24 @@ export default class Analyzer { return null } - public getAllVariableSymbols({ uri }: { uri: string }): LSP.SymbolInformation[] { - return this.getAllSymbols({ uri }).filter( - (symbol) => symbol.kind === LSP.SymbolKind.Variable, - ) + /** + * 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 + } + + return node.text.trim() } public setIncludeAllWorkspaceSymbols(includeAllWorkspaceSymbols: boolean): void { this.includeAllWorkspaceSymbols = includeAllWorkspaceSymbols } + // Private methods private getReachableUris({ uri: fromUri }: { uri?: string } = {}): string[] { if (!fromUri || this.includeAllWorkspaceSymbols) { return Object.keys(this.uriToAnalyzedDocument) @@ -612,21 +524,107 @@ export default class Analyzer { }) } - private getAllSymbols({ uri }: { uri?: string } = {}): LSP.SymbolInformation[] { - const reachableUris = this.getReachableUris({ uri }) - - const symbols: LSP.SymbolInformation[] = [] - reachableUris.forEach((uri) => { + /** + * Get all declaration symbols (function or variables) from the given file/position + * or from all files in the workspace. It will take into account the given position + * to filter out irrelevant symbols. + * + * Note that this can return duplicates across the workspace. + */ + private getAllDeclarations({ + uri: fromUri, + position, + }: { uri?: string; position?: LSP.Position } = {}): LSP.SymbolInformation[] { + return this.getReachableUris({ uri: fromUri }).reduce((symbols, uri) => { const analyzedDocument = this.uriToAnalyzedDocument[uri] - if (!analyzedDocument) { + + if (analyzedDocument) { + if (uri !== fromUri || !position) { + // We use the global declarations for external files or if we do not have a position + const { globalDeclarations } = analyzedDocument + Object.values(globalDeclarations).forEach((symbol) => symbols.push(symbol)) + } + + // For the current file we find declarations based on the current scope + if (uri === fromUri && position) { + const node = analyzedDocument.tree.rootNode?.descendantForPosition({ + row: position.line, + column: position.character, + }) + + const localDeclarations = getLocalDeclarations({ + node, + uri, + }) + + Object.keys(localDeclarations).map((name) => { + const symbolsMatchingWord = localDeclarations[name] + + // Find the latest definition + let closestSymbol: LSP.SymbolInformation | null = null + symbolsMatchingWord.forEach((symbol) => { + // Skip if the symbol is defined in the current file after the requested position + if (symbol.location.range.start.line > position.line) { + return + } + + if ( + closestSymbol === null || + symbol.location.range.start.line > closestSymbol.location.range.start.line + ) { + closestSymbol = symbol + } + }) + + if (closestSymbol) { + symbols.push(closestSymbol) + } + }) + } + } + + return symbols + }, [] as LSP.SymbolInformation[]) + } + + public findAllSourcedUris({ uri }: { uri: string }): Set { + const allSourcedUris = new Set([]) + + const addSourcedFilesFromUri = (fromUri: string) => { + const sourcedUris = this.uriToAnalyzedDocument[fromUri]?.sourcedUris + + if (!sourcedUris) { return } - Object.values(analyzedDocument.declarations).forEach((declarationNames) => { - declarationNames.forEach((d) => symbols.push(d)) + sourcedUris.forEach((sourcedUri) => { + if (!allSourcedUris.has(sourcedUri)) { + allSourcedUris.add(sourcedUri) + addSourcedFilesFromUri(sourcedUri) + } }) - }) + } + + addSourcedFilesFromUri(uri) + + return allSourcedUris + } + + /** + * Find the node at the given point. + */ + private nodeAtPoint( + uri: string, + line: number, + column: number, + ): Parser.SyntaxNode | null { + const tree = this.uriToAnalyzedDocument[uri]?.tree - return symbols + if (!tree?.rootNode) { + // Check for lacking rootNode (due to failed parse?) + return null + } + + return tree.rootNode.descendantForPosition({ row: line, column }) } } diff --git a/server/src/server.ts b/server/src/server.ts index 21e39dd5c..5f7d78c60 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -426,10 +426,11 @@ export default class BashServer { } } - const symbolsMatchingWord = this.analyzer.findSymbolsMatchingWord({ + const symbolsMatchingWord = this.analyzer.findDeclarationsMatchingWord({ exactMatch: true, uri: currentUri, word, + position: params.position, }) if ( ReservedWords.isReservedWord(word) || @@ -465,7 +466,7 @@ export default class BashServer { if (!word) { return null } - return this.analyzer.findDefinition({ + return this.analyzer.findDeclarationLocations({ position: params.position, uri: params.textDocument.uri, word, @@ -473,13 +474,16 @@ export default class BashServer { } private onDocumentSymbol(params: LSP.DocumentSymbolParams): LSP.SymbolInformation[] { + // TODO: ideally this should return LSP.DocumentSymbol[] instead of LSP.SymbolInformation[] + // which is a hierarchy of symbols. + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol this.connection.console.log(`onDocumentSymbol`) - return this.analyzer.findSymbolsForFile({ uri: params.textDocument.uri }) + return this.analyzer.getDeclarationsForUri({ uri: params.textDocument.uri }) } private onWorkspaceSymbol(params: LSP.WorkspaceSymbolParams): LSP.SymbolInformation[] { this.connection.console.log('onWorkspaceSymbol') - return this.analyzer.search(params.query) + return this.analyzer.findDeclarationsWithFuzzySearch(params.query) } private onDocumentHighlight( @@ -558,11 +562,15 @@ export default class BashServer { ? [] : this.getCompletionItemsForSymbols({ symbols: shouldCompleteOnVariables - ? this.analyzer.getAllVariableSymbols({ uri: currentUri }) - : this.analyzer.findSymbolsMatchingWord({ + ? this.analyzer.getAllVariables({ + uri: currentUri, + position: params.position, + }) + : this.analyzer.findDeclarationsMatchingWord({ exactMatch: false, uri: currentUri, word, + position: params.position, }), currentUri, }) @@ -732,6 +740,7 @@ function deduplicateSymbols({ ), ) + // NOTE: it might be that uniqueBasedOnHash is not needed anymore return uniqueBasedOnHash([...symbolsCurrentFile, ...symbolsOtherFiles], getSymbolId) } diff --git a/server/src/util/__tests__/flatten.test.ts b/server/src/util/__tests__/flatten.test.ts index f77577710..b73de257b 100644 --- a/server/src/util/__tests__/flatten.test.ts +++ b/server/src/util/__tests__/flatten.test.ts @@ -1,4 +1,4 @@ -import { flattenArray, flattenObjectValues } from '../flatten' +import { flattenArray } from '../flatten' describe('flattenArray', () => { it('works on array with one element', () => { @@ -9,14 +9,3 @@ describe('flattenArray', () => { expect(flattenArray([[1], [2, 3], [4]])).toEqual([1, 2, 3, 4]) }) }) - -describe('flattenObjectValues', () => { - it('flatten object values', () => { - expect( - flattenObjectValues({ - 'foo.sh': [1], - 'baz.sh': [2], - }), - ).toEqual([1, 2]) - }) -}) diff --git a/server/src/util/declarations.ts b/server/src/util/declarations.ts new file mode 100644 index 000000000..4ada75fea --- /dev/null +++ b/server/src/util/declarations.ts @@ -0,0 +1,229 @@ +import * as LSP from 'vscode-languageserver/node' +import * as Parser from 'web-tree-sitter' + +import * as TreeSitterUtil from './tree-sitter' + +const TREE_SITTER_TYPE_TO_LSP_KIND: { [type: string]: LSP.SymbolKind | undefined } = { + // These keys are using underscores as that's the naming convention in tree-sitter. + environment_variable_assignment: LSP.SymbolKind.Variable, + function_definition: LSP.SymbolKind.Function, + variable_assignment: LSP.SymbolKind.Variable, +} + +export type GlobalDeclarations = { [word: string]: LSP.SymbolInformation } +export type Declarations = { [word: string]: LSP.SymbolInformation[] } + +/** + * Returns declarations (functions or variables) from a given root node + * that would be available after sourcing the file. This currently does + * not include global variables defined inside function as we do not do + * any flow tracing. + * + * Will only return one declaration per symbol name – the latest definition. + * This behavior is consistent with how Bash behaves, but differs between + * LSP servers. + * + * Used when finding declarations for sourced files and to get declarations + * for the entire workspace. + */ +export function getGlobalDeclarations({ + tree, + uri, +}: { + tree: Parser.Tree + uri: string +}): { + diagnostics: LSP.Diagnostic[] + globalDeclarations: GlobalDeclarations +} { + const diagnostics: LSP.Diagnostic[] = [] + const globalDeclarations: GlobalDeclarations = {} + + tree.rootNode.children.forEach((node) => { + if (node.type === 'ERROR') { + diagnostics.push( + LSP.Diagnostic.create( + TreeSitterUtil.range(node), + 'Failed to parse', + LSP.DiagnosticSeverity.Error, + ), + ) + return + } + + if (TreeSitterUtil.isDefinition(node)) { + const symbol = nodeToSymbolInformation({ node, uri }) + + if (symbol) { + const word = symbol.name + globalDeclarations[word] = symbol + } + } + }) + + return { diagnostics, globalDeclarations } +} + +/** + * Returns all declarations (functions or variables) from a given tree. + * This includes local variables. + */ +export function getAllDeclarationsInTree({ + tree, + uri, +}: { + tree: Parser.Tree + uri: string +}): LSP.SymbolInformation[] { + const symbols: LSP.SymbolInformation[] = [] + + TreeSitterUtil.forEach(tree.rootNode, (node: Parser.SyntaxNode) => { + if (TreeSitterUtil.isDefinition(node)) { + const symbol = nodeToSymbolInformation({ node, uri }) + + if (symbol) { + symbols.push(symbol) + } + } + + return + }) + + return symbols +} + +/** + * Returns declarations available for the given file and location. + * The heuristics used is a simplification compared to bash behaviour, + * but deemed good enough, compared to the complexity of flow tracing. + * + * Used when getting declarations for the current scope. + */ +export function getLocalDeclarations({ + node, + uri, +}: { + node: Parser.SyntaxNode | null + uri: string +}): Declarations { + const declarations: Declarations = {} + + // Bottom up traversal to capture all local and scoped declarations + const walk = (node: Parser.SyntaxNode | null) => { + // NOTE: there is also node.walk + if (node) { + for (const childNode of node.children) { + let symbol: LSP.SymbolInformation | null = null + + // local variables + if (childNode.type === 'declaration_command') { + const variableAssignmentNode = childNode.children.filter( + (child) => child.type === 'variable_assignment', + )[0] + + if (variableAssignmentNode) { + symbol = nodeToSymbolInformation({ + node: variableAssignmentNode, + uri, + }) + } + } else if (TreeSitterUtil.isDefinition(childNode)) { + symbol = nodeToSymbolInformation({ node: childNode, uri }) + } + + if (symbol) { + if (!declarations[symbol.name]) { + declarations[symbol.name] = [] + } + declarations[symbol.name].push(symbol) + } + } + + walk(node.parent) + } + } + + walk(node) + + // Top down traversal to add missing global variables from within functions + if (node) { + const rootNode = + node.type === 'program' + ? node + : TreeSitterUtil.findParent(node, (p) => p.type === 'program') + if (!rootNode) { + throw new Error('did not find root node') + } + + Object.entries( + getAllGlobalVariableDeclarations({ + rootNode, + uri, + }), + ).map(([name, symbols]) => { + if (!declarations[name]) { + declarations[name] = symbols + } + }) + } + + return declarations +} + +function getAllGlobalVariableDeclarations({ + uri, + rootNode, +}: { + uri: string + rootNode: Parser.SyntaxNode +}) { + const declarations: Declarations = {} + + TreeSitterUtil.forEach(rootNode, (node: Parser.SyntaxNode) => { + if ( + node.type === 'variable_assignment' && + // exclude local variables + node.parent?.type !== 'declaration_command' + ) { + const symbol = nodeToSymbolInformation({ node, uri }) + if (symbol) { + if (!declarations[symbol.name]) { + declarations[symbol.name] = [] + } + declarations[symbol.name].push(symbol) + } + } + + return + }) + + return declarations +} + +function nodeToSymbolInformation({ + node, + uri, +}: { + node: Parser.SyntaxNode + uri: string +}): LSP.SymbolInformation | null { + const named = node.firstNamedChild + + if (named === null) { + return null + } + + const containerName = + TreeSitterUtil.findParent(node, (p) => p.type === 'function_definition') + ?.firstNamedChild?.text || '' + + const kind = TREE_SITTER_TYPE_TO_LSP_KIND[node.type] + + return LSP.SymbolInformation.create( + named.text, + kind || LSP.SymbolKind.Variable, + TreeSitterUtil.range(node), + uri, + containerName, + ) +} diff --git a/server/src/util/flatten.ts b/server/src/util/flatten.ts index 7c4193af3..28fde991c 100644 --- a/server/src/util/flatten.ts +++ b/server/src/util/flatten.ts @@ -1,7 +1,3 @@ export function flattenArray(nestedArray: T[][]): T[] { return nestedArray.reduce((acc, array) => [...acc, ...array], []) } - -export function flattenObjectValues(object: { [key: string]: T[] }): T[] { - return flattenArray(Object.keys(object).map((objectKey) => object[objectKey])) -} diff --git a/testing/fixtures.ts b/testing/fixtures.ts index 7d5b19c1f..250645fb6 100644 --- a/testing/fixtures.ts +++ b/testing/fixtures.ts @@ -16,17 +16,18 @@ function getDocument(uri: string) { type FIXTURE_KEY = keyof typeof FIXTURE_URI export const FIXTURE_URI = { + COMMENT_DOC: `file://${path.join(FIXTURE_FOLDER, 'comment-doc-on-hover.sh')}`, INSTALL: `file://${path.join(FIXTURE_FOLDER, 'install.sh')}`, ISSUE101: `file://${path.join(FIXTURE_FOLDER, 'issue101.sh')}`, ISSUE206: `file://${path.join(FIXTURE_FOLDER, 'issue206.sh')}`, MISSING_EXTENSION: `file://${path.join(FIXTURE_FOLDER, 'extension')}`, MISSING_NODE: `file://${path.join(FIXTURE_FOLDER, 'missing-node.sh')}`, - 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')}`, - SHELLCHECK_SOURCE: `file://${path.join(FIXTURE_FOLDER, 'shellcheck', 'source.sh')}`, OVERRIDE_SYMBOL: `file://${path.join(FIXTURE_FOLDER, 'override-executable-symbol.sh')}`, + PARSE_PROBLEMS: `file://${path.join(FIXTURE_FOLDER, 'parse-problems.sh')}`, + SCOPE: `file://${path.join(FIXTURE_FOLDER, 'scope.sh')}`, + SHELLCHECK_SOURCE: `file://${path.join(FIXTURE_FOLDER, 'shellcheck', 'source.sh')}`, + SOURCING: `file://${path.join(FIXTURE_FOLDER, 'sourcing.sh')}`, } export const FIXTURE_DOCUMENT: Record = ( diff --git a/testing/fixtures/extension.inc b/testing/fixtures/extension.inc index 73b151c1c..b7869e5a9 100644 --- a/testing/fixtures/extension.inc +++ b/testing/fixtures/extension.inc @@ -7,3 +7,11 @@ GREEN=`tput setaf 2` BLUE=`tput setaf 4` BOLD=`tput bold` RESET=`tput sgr0` + +extensionFunc() { + LOCAL_VARIABLE='local' + + innerExtensionFunc() { + echo $LOCAL_VARIABLE + } +} \ No newline at end of file diff --git a/testing/fixtures/scope.sh b/testing/fixtures/scope.sh new file mode 100644 index 000000000..a13da0c0c --- /dev/null +++ b/testing/fixtures/scope.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +X="Horse" + +X="Mouse" + +# some function +f() ( + local X="Dog" + GLOBAL_1="Global 1" + + g() { + local X="Cat" + GLOBAL_1="Global 1" + GLOBAL_2="Global 1" + echo "${X}" + + # another function function + f() { + local X="Bird" + echo "${X}" + } + + f + } + + g + + echo "${GLOBAL_1}" + echo "${X}" +) + +echo "${X}" +f + +echo "${GLOBAL_2}"