Skip to content

Commit a8e33fb

Browse files
authored
Merge pull request #569 from bash-lsp/performant-background-analysis
Performant globbing and background analysis
2 parents 938bf33 + 7cf098f commit a8e33fb

File tree

9 files changed

+290
-188
lines changed

9 files changed

+290
-188
lines changed

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
},
2020
"dependencies": {
2121
"@types/node-fetch": "2.6.2",
22+
"fast-glob": "3.2.12",
2223
"fuzzy-search": "^3.2.1",
23-
"glob": "^8.0.0",
2424
"node-fetch": "^2.6.7",
2525
"turndown": "^7.0.0",
2626
"urijs": "^1.19.11",

server/src/__tests__/analyzer.test.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import * as fsUtil from '../util/fs'
77
let analyzer: Analyzer
88

99
const CURRENT_URI = 'dummy-uri.sh'
10+
const mockConsole = getMockConnection().console
11+
12+
// if you add a .sh file to testing/fixtures, update this value
13+
const FIXTURE_FILES_MATCHING_GLOB = 13
1014

1115
beforeAll(async () => {
1216
const parser = await initializeParser()
13-
analyzer = new Analyzer(parser)
17+
analyzer = new Analyzer({ console: mockConsole, parser })
1418
})
1519

1620
describe('analyze', () => {
@@ -262,39 +266,28 @@ describe('commentsAbove', () => {
262266
})
263267
})
264268

265-
describe('fromRoot', () => {
266-
it('initializes an analyzer from a root', async () => {
269+
describe('initiateBackgroundAnalysis', () => {
270+
it('finds bash files', async () => {
267271
const parser = await initializeParser()
268272

269273
jest.spyOn(Date, 'now').mockImplementation(() => 0)
270274

271275
const connection = getMockConnection()
272276

273-
const newAnalyzer = await Analyzer.fromRoot({
274-
connection,
277+
const newAnalyzer = new Analyzer({ console: connection.console, parser })
278+
const { filesParsed } = await newAnalyzer.initiateBackgroundAnalysis({
275279
rootPath: FIXTURE_FOLDER,
276-
parser,
277280
})
278281

279-
expect(newAnalyzer).toBeDefined()
280-
281282
expect(connection.window.showWarningMessage).not.toHaveBeenCalled()
282-
283-
// if you add a .sh file to testing/fixtures, update this value
284-
const FIXTURE_FILES_MATCHING_GLOB = 14
283+
expect(connection.console.warn).not.toHaveBeenCalled()
285284

286285
// Intro, stats on glob, one file skipped due to shebang, and outro
287-
const LOG_LINES = FIXTURE_FILES_MATCHING_GLOB + 4
286+
expect(filesParsed).toEqual(FIXTURE_FILES_MATCHING_GLOB)
288287

289-
expect(connection.console.log).toHaveBeenCalledTimes(LOG_LINES)
290288
expect(connection.console.log).toHaveBeenNthCalledWith(
291289
1,
292-
expect.stringContaining('Analyzing files matching'),
293-
)
294-
295-
expect(connection.console.log).toHaveBeenNthCalledWith(
296-
LOG_LINES,
297-
'Analyzer finished after 0 seconds',
290+
expect.stringContaining('BackgroundAnalysis: resolving glob'),
298291
)
299292
})
300293

@@ -307,16 +300,13 @@ describe('fromRoot', () => {
307300

308301
const connection = getMockConnection()
309302

310-
const newAnalyzer = await Analyzer.fromRoot({
311-
connection,
303+
const newAnalyzer = new Analyzer({ console: connection.console, parser })
304+
const { filesParsed } = await newAnalyzer.initiateBackgroundAnalysis({
312305
rootPath: FIXTURE_FOLDER,
313-
parser,
314306
})
315307

316-
expect(newAnalyzer).toBeDefined()
317-
318-
expect(connection.window.showWarningMessage).toHaveBeenCalledWith(
319-
expect.stringContaining('BOOM'),
320-
)
308+
expect(connection.window.showWarningMessage).not.toHaveBeenCalled()
309+
expect(connection.console.warn).toHaveBeenCalledWith(expect.stringContaining('BOOM'))
310+
expect(filesParsed).toEqual(0)
321311
})
322312
})

server/src/__tests__/server.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ async function initializeServer() {
2020
workspaceFolders: null,
2121
})
2222

23+
const { backgroundAnalysisCompleted } = server
24+
25+
if (backgroundAnalysisCompleted) {
26+
await backgroundAnalysisCompleted
27+
}
28+
2329
return {
2430
connection,
2531
console,

server/src/analyser.ts

Lines changed: 77 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { promisify } from 'util'
77
import * as LSP from 'vscode-languageserver'
88
import * as Parser from 'web-tree-sitter'
99

10-
import { getGlobPattern } from './config'
10+
import * as config from './config'
1111
import { flattenArray, flattenObjectValues } from './util/flatten'
1212
import { getFilePaths } from './util/fs'
1313
import { getShebang, isBashShebang } from './util/shebang'
@@ -28,77 +28,8 @@ type Texts = { [uri: string]: string }
2828
* tree-sitter to find definitions, reference, etc.
2929
*/
3030
export default class Analyzer {
31-
/**
32-
* Initialize the Analyzer based on a connection to the client and an optional
33-
* root path.
34-
*
35-
* If the rootPath is provided it will initialize all shell files it can find
36-
* anywhere on that path. This non-exhaustive glob is used to preload the parser
37-
* to support features across files.
38-
*/
39-
public static async fromRoot({
40-
connection,
41-
rootPath,
42-
parser,
43-
}: {
44-
connection: LSP.Connection
45-
rootPath: LSP.InitializeParams['rootPath']
46-
parser: Parser
47-
}): Promise<Analyzer> {
48-
const analyzer = new Analyzer(parser)
49-
50-
if (rootPath) {
51-
const globPattern = getGlobPattern()
52-
connection.console.log(
53-
`Analyzing files matching glob "${globPattern}" inside ${rootPath}`,
54-
)
55-
56-
const lookupStartTime = Date.now()
57-
const getTimePassed = (): string =>
58-
`${(Date.now() - lookupStartTime) / 1000} seconds`
59-
60-
let filePaths: string[] = []
61-
try {
62-
filePaths = await getFilePaths({ globPattern, rootPath })
63-
} catch (error) {
64-
const errorMessage = error instanceof Error ? error.message : error
65-
connection.window.showWarningMessage(
66-
`Failed to analyze bash files using the glob "${globPattern}". The experience will be degraded. Error: ${errorMessage}`,
67-
)
68-
}
69-
70-
// TODO: we could load all files without extensions: globPattern: '**/[^.]'
71-
72-
connection.console.log(
73-
`Glob resolved with ${filePaths.length} files after ${getTimePassed()}`,
74-
)
75-
76-
for (const filePath of filePaths) {
77-
const uri = url.pathToFileURL(filePath).href
78-
connection.console.log(`Analyzing ${uri}`)
79-
80-
try {
81-
const fileContent = await readFileAsync(filePath, 'utf8')
82-
const shebang = getShebang(fileContent)
83-
if (shebang && !isBashShebang(shebang)) {
84-
connection.console.log(`Skipping file ${uri} with shebang "${shebang}"`)
85-
continue
86-
}
87-
88-
analyzer.analyze(uri, LSP.TextDocument.create(uri, 'shell', 1, fileContent))
89-
} catch (error) {
90-
const errorMessage = error instanceof Error ? error.message : error
91-
connection.console.warn(`Failed analyzing ${uri}. Error: ${errorMessage}`)
92-
}
93-
}
94-
95-
connection.console.log(`Analyzer finished after ${getTimePassed()}`)
96-
}
97-
98-
return analyzer
99-
}
100-
10131
private parser: Parser
32+
private console: LSP.RemoteConsole
10233

10334
private uriToTextDocument: { [uri: string]: LSP.TextDocument } = {}
10435

@@ -116,8 +47,82 @@ export default class Analyzer {
11647
variable_assignment: LSP.SymbolKind.Variable,
11748
}
11849

119-
public constructor(parser: Parser) {
50+
public constructor({
51+
console,
52+
parser,
53+
}: {
54+
console: LSP.RemoteConsole
55+
parser: Parser
56+
}) {
12057
this.parser = parser
58+
this.console = console
59+
}
60+
61+
/**
62+
* Initiates a background analysis of the files in the given rootPath to
63+
* enable features across files.
64+
*/
65+
public async initiateBackgroundAnalysis({
66+
rootPath,
67+
}: {
68+
rootPath: string
69+
}): Promise<{ filesParsed: number }> {
70+
const globPattern = config.getGlobPattern()
71+
const backgroundAnalysisMaxFiles = config.getBackgroundAnalysisMaxFiles()
72+
this.console.log(
73+
`BackgroundAnalysis: resolving glob "${globPattern}" inside "${rootPath}"...`,
74+
)
75+
76+
const lookupStartTime = Date.now()
77+
const getTimePassed = (): string => `${(Date.now() - lookupStartTime) / 1000} seconds`
78+
79+
let filePaths: string[] = []
80+
try {
81+
filePaths = await getFilePaths({
82+
globPattern,
83+
rootPath,
84+
maxItems: backgroundAnalysisMaxFiles,
85+
})
86+
} catch (error) {
87+
const errorMessage = error instanceof Error ? error.message : error
88+
this.console.warn(
89+
`BackgroundAnalysis: failed resolved glob "${globPattern}". The experience across files will be degraded. Error: ${errorMessage}`,
90+
)
91+
return { filesParsed: 0 }
92+
}
93+
94+
this.console.log(
95+
`BackgroundAnalysis: Glob resolved with ${
96+
filePaths.length
97+
} files after ${getTimePassed()}`,
98+
)
99+
100+
for (const filePath of filePaths) {
101+
const uri = url.pathToFileURL(filePath).href
102+
103+
try {
104+
const fileContent = await readFileAsync(filePath, 'utf8')
105+
const shebang = getShebang(fileContent)
106+
if (shebang && !isBashShebang(shebang)) {
107+
this.console.log(
108+
`BackgroundAnalysis: Skipping file ${uri} with shebang "${shebang}"`,
109+
)
110+
continue
111+
}
112+
113+
this.analyze(uri, LSP.TextDocument.create(uri, 'shell', 1, fileContent))
114+
} catch (error) {
115+
const errorMessage = error instanceof Error ? error.message : error
116+
this.console.warn(
117+
`BackgroundAnalysis: Failed analyzing ${uri}. Error: ${errorMessage}`,
118+
)
119+
}
120+
}
121+
122+
this.console.log(`BackgroundAnalysis: Completed after ${getTimePassed()}.`)
123+
return {
124+
filesParsed: filePaths.length,
125+
}
121126
}
122127

123128
/**

server/src/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const DEFAULT_GLOB_PATTERN = '**/*@(.sh|.inc|.bash|.command)'
2+
export const DEFAULT_BACKGROUND_ANALYSIS_MAX_FILES = 500
23

34
export function getShellcheckPath(): string | null {
45
const { SHELLCHECK_PATH } = process.env
@@ -13,6 +14,9 @@ export function getExplainshellEndpoint(): string | null {
1314
: null
1415
}
1516

17+
/**
18+
* Get the glob pattern for files to run background analysis on.
19+
*/
1620
export function getGlobPattern(): string {
1721
const { GLOB_PATTERN } = process.env
1822
return typeof GLOB_PATTERN === 'string' && GLOB_PATTERN.trim() !== ''
@@ -26,3 +30,12 @@ export function getHighlightParsingError(): boolean {
2630
? HIGHLIGHT_PARSING_ERRORS === 'true' || HIGHLIGHT_PARSING_ERRORS === '1'
2731
: false
2832
}
33+
34+
/**
35+
* Get the maximum number of files to run background analysis on.
36+
*/
37+
export function getBackgroundAnalysisMaxFiles(): number {
38+
const { BACKGROUND_ANALYSIS_MAX_FILES } = process.env
39+
const parsed = parseInt(BACKGROUND_ANALYSIS_MAX_FILES || '', 10)
40+
return !isNaN(parsed) ? parsed : DEFAULT_BACKGROUND_ANALYSIS_MAX_FILES
41+
}

server/src/server.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,27 @@ export default class BashServer {
3838
throw new Error('Expected PATH environment variable to be set')
3939
}
4040

41-
return Promise.all([
42-
Executables.fromPath(PATH),
43-
Analyzer.fromRoot({ connection, rootPath, parser }),
44-
getLinterExecutablePath(),
45-
]).then(([executables, analyzer, linterExecutablePath]) => {
46-
const linter = new Linter({ executablePath: linterExecutablePath })
47-
return new BashServer(connection, executables, analyzer, linter, capabilities)
48-
})
41+
return Promise.all([Executables.fromPath(PATH), getLinterExecutablePath()]).then(
42+
([executables, linterExecutablePath]) => {
43+
const analyzer = new Analyzer({ console: connection.console, parser })
44+
const linter = new Linter({ executablePath: linterExecutablePath })
45+
46+
let backgroundAnalysisCompleted
47+
if (rootPath) {
48+
// NOTE: we do not block the server initialization on this background analysis.
49+
backgroundAnalysisCompleted = analyzer.initiateBackgroundAnalysis({ rootPath })
50+
}
51+
52+
return new BashServer({
53+
analyzer,
54+
backgroundAnalysisCompleted,
55+
capabilities,
56+
connection,
57+
executables,
58+
linter,
59+
})
60+
},
61+
)
4962
}
5063

5164
private executables: Executables
@@ -55,19 +68,29 @@ export default class BashServer {
5568
private documents: LSP.TextDocuments<TextDocument> = new LSP.TextDocuments(TextDocument)
5669
private connection: LSP.Connection
5770
private clientCapabilities: LSP.ClientCapabilities
58-
59-
private constructor(
60-
connection: LSP.Connection,
61-
executables: Executables,
62-
analyzer: Analyzer,
63-
linter: Linter,
64-
capabilities: LSP.ClientCapabilities,
65-
) {
71+
public backgroundAnalysisCompleted?: Promise<any>
72+
73+
private constructor({
74+
connection,
75+
executables,
76+
analyzer,
77+
linter,
78+
capabilities,
79+
backgroundAnalysisCompleted,
80+
}: {
81+
connection: LSP.Connection
82+
executables: Executables
83+
analyzer: Analyzer
84+
linter: Linter
85+
capabilities: LSP.ClientCapabilities
86+
backgroundAnalysisCompleted?: Promise<any>
87+
}) {
6688
this.connection = connection
6789
this.executables = executables
6890
this.analyzer = analyzer
6991
this.linter = linter
7092
this.clientCapabilities = capabilities
93+
this.backgroundAnalysisCompleted = backgroundAnalysisCompleted
7194
}
7295

7396
/**

0 commit comments

Comments
 (0)