diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 40fe7b6..d14ee86 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -36,7 +36,7 @@ jobs: npm run antlr4ngPre npm run antlr4ngFmt mv ./server/src/antlr/out/server/src/antlr/* ./server/src/antlr/out - - run: npm run compile + - run: npm run build - run: xvfb-run -a npm test if: runner.os == 'Linux' - run: npm test diff --git a/.vscode/launch.json b/.vscode/launch.json index 963a2f0..46d6033 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,6 @@ "--extensionDevelopmentPath=${workspaceRoot}", "${workspaceFolder}/sample" ], - "outFiles": ["${workspaceRoot}/client/out/**/*.js"], "preLaunchTask": { "type": "npm", "script": "compile" @@ -23,7 +22,6 @@ "name": "Attach to Server", "port": 6009, "restart": true, - "outFiles": ["${workspaceRoot}/server/out/**/*.js"] }, { "name": "Language Server E2E Test", @@ -32,10 +30,10 @@ "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceRoot}", - "--extensionTestsPath=${workspaceRoot}/client/out/test/index", - "${workspaceRoot}/client/testFixture" + "--extensionTestsPath=${workspaceRoot}/dist/client/out/test/index", + "${workspaceRoot}/test/fixtures" ], - "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"] + "outFiles": ["${workspaceRoot}/dist/client/out/test/**/*.js"] }, { "name": "Debug ANTLR4 grammar", diff --git a/client/src/test/diagnostics.test.ts b/client/src/test/diagnostics.test.ts index cf860d0..4ae8970 100644 --- a/client/src/test/diagnostics.test.ts +++ b/client/src/test/diagnostics.test.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import * as assert from 'assert'; -import { getDocUri, activate } from './helper'; +import { getDocUri, activate, runOnActivate } from './helper'; import { toRange } from './util'; suite('Should get diagnostics', () => { @@ -147,8 +147,11 @@ suite('Should get diagnostics', () => { async function testDiagnostics(docUri: vscode.Uri, expectedDiagnostics: vscode.Diagnostic[]) { await activate(docUri); - const actualDiagnostics = vscode.languages.getDiagnostics(docUri); - + // Use this method first to ensure the extension is activated. + const actualDiagnostics = await runOnActivate( + () => vscode.languages.getDiagnostics(docUri), + (result) => result.length > 0 + ); assert.equal(actualDiagnostics.length, expectedDiagnostics.length, "Count"); expectedDiagnostics.forEach((expectedDiagnostic, i) => { diff --git a/client/src/test/foldingRanges.test.ts b/client/src/test/foldingRanges.test.ts index fb5b5e0..114977f 100644 --- a/client/src/test/foldingRanges.test.ts +++ b/client/src/test/foldingRanges.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as assert from 'assert'; -import { getDocUri, activate } from './helper'; +import { getDocUri, activate, runOnActivate } from './helper'; suite('Should get folding ranges', () => { test('formatting.class.template', async () => { @@ -24,10 +24,16 @@ suite('Should get folding ranges', () => { async function testFoldingRanges(docUri: vscode.Uri, expectedFoldingRanges: vscode.FoldingRange[]) { await activate(docUri); - const actualFoldingRanges = await vscode.commands.executeCommand( + const action = () => vscode.commands.executeCommand( 'vscode.executeFoldingRangeProvider', docUri ) + + // Use this method first to ensure the extension is activated. + const actualFoldingRanges = await runOnActivate( + action, + (result) => Array.isArray(result) && result.length > 0 + ); assert.equal(actualFoldingRanges.length ?? 0, expectedFoldingRanges.length, "Count"); diff --git a/client/src/test/formatting.test.ts b/client/src/test/formatting.test.ts index 828beec..ce539d1 100644 --- a/client/src/test/formatting.test.ts +++ b/client/src/test/formatting.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as assert from 'assert'; -import { getDocUri, activate } from './helper'; +import { getDocUri, activate, runOnActivate } from './helper'; import { toRange } from './util'; suite('Should get text edits', () => { @@ -42,11 +42,18 @@ suite('Should get text edits', () => { async function testTextEdits(docUri: vscode.Uri, expectedTextEdits: vscode.TextEdit[]) { await activate(docUri); - const actualEdits = await vscode.commands.executeCommand( + await vscode.window.showTextDocument(docUri); + const action = () => vscode.commands.executeCommand( 'vscode.executeFormatDocumentProvider', docUri, { tabSize: 4, insertSpaces: true } - ) + ); + + // Use this method first to ensure the extension is activated. + const actualEdits = await runOnActivate( + action, + (result) => Array.isArray(result) && result.length > 0 + ); assert.equal(actualEdits.length ?? 0, expectedTextEdits.length, "Count"); diff --git a/client/src/test/helper.ts b/client/src/test/helper.ts index b7bbf3f..8168979 100644 --- a/client/src/test/helper.ts +++ b/client/src/test/helper.ts @@ -10,6 +10,7 @@ export let doc: vscode.TextDocument; export let editor: vscode.TextEditor; export let documentEol: string; export let platformEol: string; +const TIMEOUTMS = 5000; /** * Activates the vscode.lsp-sample extension @@ -21,16 +22,31 @@ export async function activate(docUri: vscode.Uri) { try { doc = await vscode.workspace.openTextDocument(docUri); editor = await vscode.window.showTextDocument(doc); - await sleep(500); // Wait for server activation } catch (e) { console.error(e); } } +export function getTimeout() { + return Date.now() + TIMEOUTMS; +} + async function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } +export async function runOnActivate(action: () => T|Thenable, test: (result: T) => boolean): Promise { + const timeout = getTimeout(); + while (Date.now() < timeout) { + const result = await action(); + if (test(result)) { + return result; + } + await sleep(100); + } + throw new Error(`Timed out after ${TIMEOUTMS}`); +} + export const getDocPath = (p: string) => { return path.resolve(__dirname, '../../../../test/fixtures', p); }; diff --git a/esbuild.js b/esbuild.js index c307c08..99c7c04 100644 --- a/esbuild.js +++ b/esbuild.js @@ -3,6 +3,7 @@ const esbuild = require('esbuild'); const production = process.argv.includes('--production'); const watch = process.argv.includes('--watch'); +const test = process.argv.includes('--test'); /** * @type {import('esbuild').Plugin} @@ -77,11 +78,11 @@ async function buildTests() { } async function main() { - const buildTasks = [ - buildClient(), - buildServer(), - buildTests() - ] + const buildTasks = test ? [] : [ + buildClient(), + buildServer(), + ]; + buildTasks.push(buildTests()); const buildContexts = await Promise.all(buildTasks); if (watch) { diff --git a/package-lock.json b/package-lock.json index a48b09b..010c12e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vba-lsp", - "version": "1.5.6", + "version": "1.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vba-lsp", - "version": "1.5.6", + "version": "1.5.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 048de99..9a12247 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "icon": "images/vba-lsp-icon.png", "author": "SSlinky", "license": "MIT", - "version": "1.5.6", + "version": "1.5.7", "repository": { "type": "git", "url": "https://github.com/SSlinky/VBA-LanguageServer" @@ -179,7 +179,8 @@ }, "scripts": { "vscode:prepublish": "npm run package", - "compile": "npm run check-types && node esbuild.js", + "build": "npm run check-types && node esbuild.js", + "build-test": "node esbuild.js --test", "check-types": "tsc --noEmit", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.js --watch", @@ -188,7 +189,7 @@ "lint": "eslint ./client/src ./server/src --ext .ts,.tsx", "postinstall": "cd client && npm install && cd ../server && npm install && cd ..", "textMate": "npx js-yaml client/syntaxes/vba.tmLanguage.yaml > client/syntaxes/vba.tmLanguage.json && npm run tmSnapTest", - "antlr": "npm run antlr4ngPre && npm run antlr4ng && npm run antlr4ngFmt && npm run compile", + "antlr": "npm run antlr4ngPre && npm run antlr4ng && npm run antlr4ngFmt && npm run build", "antlr4ng": "antlr4ng -Dlanguage=TypeScript -visitor -Xlog ./server/src/antlr/vba.g4 -o ./server/src/antlr/out/", "antlr4ngPre": "antlr4ng -Dlanguage=TypeScript -visitor ./server/src/antlr/vbapre.g4 -o ./server/src/antlr/out/", "antlr4ngFmt": "antlr4ng -Dlanguage=TypeScript -visitor ./server/src/antlr/vbafmt.g4 -o ./server/src/antlr/out/", @@ -198,7 +199,7 @@ "tmUnitTest": "vscode-tmgrammar-test ./test/textmate/**/*.vba", "tmSnapTest": "vscode-tmgrammar-snap ./test/textmate/snapshot/*.??s", "tmSnapUpdate": "vscode-tmgrammar-snap --updateSnapshot ./test/textmate/snapshot/*.??s", - "vsctest": "vscode-test" + "vsctest": "npm run build-test && vscode-test" }, "dependencies": { "antlr4ng": "^3.0.16", diff --git a/scripts/e2e.ps1 b/scripts/e2e.ps1 index 8b94b9d..f100506 100644 --- a/scripts/e2e.ps1 +++ b/scripts/e2e.ps1 @@ -1,3 +1,3 @@ -$ENV:CODE_TESTS_PATH="$(Get-Location)\client\out\test" -$ENV:CODE_TESTS_WORKSPACE="$(Get-Location)\client\testFixture" -Invoke-Expression "node $(Get-Location)\client\out\test\runTest.js" +$ENV:CODE_TESTS_PATH="$(Get-Location)\dist\client\out\test" +$ENV:CODE_TESTS_WORKSPACE="$(Get-Location)\test\fixtures" +Invoke-Expression "node $(Get-Location)\dist\client\out\test\runTest.js" diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 860c62e..e3c324f 100644 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -export CODE_TESTS_PATH="$(pwd)/client/out/test" -export CODE_TESTS_WORKSPACE="$(pwd)/client/testFixture" +export CODE_TESTS_PATH="$(pwd)/dist/client/out/test" +export CODE_TESTS_WORKSPACE="$(pwd)/test/fixtures" -node "$(pwd)/client/out/test/runTest" \ No newline at end of file +node "$(pwd)/dist/client/out/test/runTest" \ No newline at end of file diff --git a/server/src/capabilities/diagnostics.ts b/server/src/capabilities/diagnostics.ts index 0917a87..47e04ae 100644 --- a/server/src/capabilities/diagnostics.ts +++ b/server/src/capabilities/diagnostics.ts @@ -68,7 +68,7 @@ export class DuplicateDeclarationDiagnostic extends BaseDiagnostic { // test export class ShadowDeclarationDiagnostic extends BaseDiagnostic { message = "Declaration is shadowed in the local scope."; - severity = DiagnosticSeverity.Error; + severity = DiagnosticSeverity.Warning; constructor(range: Range) { super(range); } @@ -124,4 +124,11 @@ export class LegacyFunctionalityDiagnostic extends BaseDiagnostic { constructor(range: Range, functionalityType: string) { super(range, `${functionalityType} are legacy functionality and should be avoided.`); } +} + +export class ParserErrorDiagnostic extends BaseDiagnostic { + severity = DiagnosticSeverity.Error; + constructor(range: Range, msg: string) { + super(range, msg); + } } \ No newline at end of file diff --git a/server/src/project/elements/generic.ts b/server/src/project/elements/generic.ts new file mode 100644 index 0000000..f67ae35 --- /dev/null +++ b/server/src/project/elements/generic.ts @@ -0,0 +1,21 @@ +// Core +import { TextDocument } from 'vscode-languageserver-textdocument'; + +// Antlr +import { ErrorNode, ParserRuleContext } from 'antlr4ng'; + +// Project +import { BaseContextSyntaxElement, BaseSyntaxElement } from './base'; +import { DiagnosticCapability } from '../../capabilities/capabilities'; +import { ParserErrorDiagnostic } from '../../capabilities/diagnostics'; + + +export class ErrorRuleElement extends BaseContextSyntaxElement { + constructor(node: ErrorNode, doc: TextDocument) { + super(node.parent as ParserRuleContext, doc); + this.diagnosticCapability = new DiagnosticCapability(this); + this.diagnosticCapability.diagnostics.push( + new ParserErrorDiagnostic(this.context.range, node.getText()) + ); + } +} \ No newline at end of file diff --git a/server/src/project/elements/procedure.ts b/server/src/project/elements/procedure.ts index 64a6b12..c6b8d58 100644 --- a/server/src/project/elements/procedure.ts +++ b/server/src/project/elements/procedure.ts @@ -40,10 +40,12 @@ export class SubDeclarationElement extends BaseProcedureElement ctx.subroutineName()?.ambiguousIdentifier(), + // For some reason the IdentifierCapability throws if no default is given + // despite it not actually ever needing it. Most unusual. + defaultRange: () => this.context.range }); this.foldingRangeCapability.openWord = `Sub ${this.identifierCapability.name}`; this.foldingRangeCapability.closeWord = 'End Sub'; - } } diff --git a/server/src/project/parser/vbaAntlr.ts b/server/src/project/parser/vbaAntlr.ts index 4ba20a3..4ed7b88 100644 --- a/server/src/project/parser/vbaAntlr.ts +++ b/server/src/project/parser/vbaAntlr.ts @@ -1,5 +1,5 @@ // Antlr -import { CharStream, CommonTokenStream, TokenStream } from 'antlr4ng'; +import { CharStream, CommonTokenStream, InputMismatchException, IntervalSet, Token, TokenStream } from 'antlr4ng'; import { DefaultErrorStrategy, Parser, RecognitionException } from 'antlr4ng'; import { vbaLexer } from '../../antlr/out/vbaLexer'; import { vbaParser } from '../../antlr/out/vbaParser'; @@ -23,7 +23,6 @@ export class VbaLexer extends vbaLexer { export class VbaParser extends vbaParser { constructor(input: TokenStream) { super(input); - } static create(document: string): VbaParser { @@ -90,11 +89,80 @@ export class VbaFmtParser extends vbafmtParser { export class VbaErrorHandler extends DefaultErrorStrategy { - recover(recognizer: Parser, e: RecognitionException): void { + override recover(recognizer: Parser, e: RecognitionException): void { + // Consume the error token if look-ahead is not EOF. const inputStream = recognizer.inputStream; - // if (!recognizer.isMatchedEOF) { + if (inputStream.LA(1) !== Token.EOF) { inputStream.consume(); - // } + } this.endErrorCondition(recognizer); } + + override recoverInline(recognizer: Parser): Token { + const stream = recognizer.inputStream; + const thisToken = recognizer.getCurrentToken(); + + // Recover using deletion strategy. + const nextToken = stream.LT(2); + const expectedTokens = recognizer.getExpectedTokens(); + if (nextToken && expectedTokens.contains(nextToken.type)) { + recognizer.consume(); + this.reportMatch(recognizer); + return thisToken; + } + + // Failsafe to prevent circular insertions. + const MAXRECURSION = -20 + for (let i = -1; i >= MAXRECURSION; i--) { + if (i <= -20) { + throw new InputMismatchException(recognizer); + } + const wasInsertedToken = this.isTokenPositionMatch(thisToken, recognizer.inputStream.LT(i)); + if (!wasInsertedToken) { + break; + } + } + + // Recover using insertion strategy. + const missingToken = this.createErrorToken(recognizer, expectedTokens); + this.reportMatch(recognizer); + return missingToken; + } + + private createErrorToken(recognizer: Parser, expectedTokens: IntervalSet): Token { + // Set up the token attributes. + const type = expectedTokens.length === 0 + ? Token.INVALID_TYPE + : expectedTokens.minElement; + + const expectedIdentifiers = expectedTokens.toArray().map( + t => recognizer.vocabulary.getLiteralName(t) + ?? recognizer.vocabulary.getDisplayName(t) + ); + const plural = expectedIdentifiers.length > 1 ? 's' : ''; + const expectedText = expectedIdentifiers.join(', '); + const text = ``; + const currentToken = recognizer.getCurrentToken(); + + // Create the token. + return recognizer.getTokenFactory().create( + [ + recognizer.tokenStream.tokenSource, + recognizer.tokenStream.tokenSource.inputStream + ], + type, + text, + Token.DEFAULT_CHANNEL, + currentToken.start, + currentToken.stop, + currentToken.line, + currentToken.column + ) + } + + private isTokenPositionMatch(a: Token | null, b: Token | null): boolean { + return !!a && !!b + && a.line === b.line + && a.column === b.column; + } } \ No newline at end of file diff --git a/server/src/project/parser/vbaListener.ts b/server/src/project/parser/vbaListener.ts index 217e490..92431c2 100644 --- a/server/src/project/parser/vbaListener.ts +++ b/server/src/project/parser/vbaListener.ts @@ -43,7 +43,6 @@ import { DocumentElementContext, IndentAfterElementContext, LabelStatementContext, - LineEndingContext, MethodParametersContext, OutdentBeforeElementContext, OutdentOnIndentAfterElementContext, @@ -63,6 +62,7 @@ import { DeclarationStatementElement, EnumDeclarationElement, TypeDeclarationEle import { FunctionDeclarationElement, PropertyGetDeclarationElement, PropertyLetDeclarationElement, PropertySetDeclarationElement, SubDeclarationElement } from '../elements/procedure'; import { ExtensionConfiguration } from '../workspace'; import { Services } from '../../injection/services'; +import { ErrorRuleElement } from '../elements/generic'; export class CommonParserCapability { document: VbaClassDocument | VbaModuleDocument; @@ -213,7 +213,7 @@ export class VbaListener extends vbaListener { }; visitErrorNode(node: ErrorNode) { - Services.logger.error(`Listener error @ ${node.getPayload()?.line ?? '--'}: ${node.getPayload()?.text}`); + this.document.registerElement(new ErrorRuleElement(node, this.document.textDocument)); } } diff --git a/server/src/project/parser/vbaParser.ts b/server/src/project/parser/vbaParser.ts index 262f862..beed4fb 100644 --- a/server/src/project/parser/vbaParser.ts +++ b/server/src/project/parser/vbaParser.ts @@ -1,6 +1,6 @@ // Antlr import { ParseCancellationException, ParseTreeWalker } from 'antlr4ng'; -import { VbaFmtParser, VbaParser, VbaPreParser } from './vbaAntlr'; +import { VbaErrorHandler, VbaFmtParser, VbaParser, VbaPreParser } from './vbaAntlr'; import { VbaFmtListener, VbaListener, VbaPreListener } from './vbaListener'; // Project diff --git a/server/src/project/scope.ts b/server/src/project/scope.ts index 6a3b797..b7bad67 100644 --- a/server/src/project/scope.ts +++ b/server/src/project/scope.ts @@ -30,7 +30,7 @@ export class NamespaceManager { // Check higher scopes for shadowed declarations checkItem = this.names.get(item.identifierCapability.name) if (!!checkItem && !checkItem.equals(item)) { - pushDiagnostic(new ShadowDeclarationDiagnostic(item.context.range)); + pushDiagnostic(new ShadowDeclarationDiagnostic(item.identifierCapability.range)); return; } this.names.set(item.identifierCapability.name, item); diff --git a/server/src/project/workspace.ts b/server/src/project/workspace.ts index 9502010..14d4865 100644 --- a/server/src/project/workspace.ts +++ b/server/src/project/workspace.ts @@ -118,11 +118,11 @@ export class Workspace implements IWorkspace { await this.activeDocument.parseAsync(this.parseCancellationTokenSource.token); this.logger.info(`Parsed ${this.activeDocument.name}`); this.connection.sendDiagnostics(this.activeDocument.languageServerDiagnostics()); - } catch (e) { + } catch (e) { // Swallow cancellation exceptions. They're good. We like these. if (e instanceof ParseCancellationException) { } else if (e instanceof Error) { this.logger.stack(e); } - else { this.logger.error('Something went wrong.')} + else { this.logger.error('Something went wrong.') } } this.parseCancellationTokenSource = undefined; @@ -259,10 +259,10 @@ class WorkspaceEvents { private initialiseConnectionEvents(connection: _Connection) { const cancellableOnDocSymbol = returnDefaultOnCancelClientRequest( - (p: DocumentSymbolParams, t) => this.onDocumentSymbolAsync(p, t), [], Services.logger, 'Document Symbols'); + (p: DocumentSymbolParams, t) => this.onDocumentSymbolAsync(p, t), [], 'Document Symbols'); const cancellableOnFoldingRanges = returnDefaultOnCancelClientRequest( - (p: FoldingRangeParams, t) => this.onFoldingRangesAsync(p, t), [], Services.logger, 'Folding Range'); + (p: FoldingRangeParams, t) => this.onFoldingRangesAsync(p, t), [], 'Folding Range'); connection.onInitialized(() => this.onInitialized()); connection.onCompletion(params => this.onCompletion(params)); @@ -328,6 +328,7 @@ class WorkspaceEvents { } private async onDocumentSymbolAsync(params: DocumentSymbolParams, token: CancellationToken): Promise { + Services.logger.debug('[event] onDocumentSymbol'); const document = await this.getParsedProjectDocument(params.textDocument.uri, 0, token); return document?.languageServerSymbolInformation() ?? []; } diff --git a/server/src/utils/wrappers.ts b/server/src/utils/wrappers.ts index 1b97785..e242203 100644 --- a/server/src/utils/wrappers.ts +++ b/server/src/utils/wrappers.ts @@ -1,6 +1,7 @@ import { CancellationToken } from 'vscode-languageserver'; import { LspLogger } from './logger'; import { Logger } from '../injection/interface'; +import { Services } from '../injection/services'; type signature = (token: CancellationToken, ...args: A) => Promise; type paramsSignature = (params: P, token: CancellationToken) => Promise; @@ -35,25 +36,24 @@ function returnDefaultOnCancel(fn: signature, logger?: * @param fn An async function that requires cancellation token handling. * @param defaultValue The value to return when cancelled. */ -export function returnDefaultOnCancelClientRequest(fn: paramsSignature, defaultValue: T, logger: Logger | undefined, name: string): paramsSignature { +export function returnDefaultOnCancelClientRequest(fn: paramsSignature, defaultValue: T, name: string): paramsSignature { return async (params: P, token: CancellationToken): Promise => { if (token.isCancellationRequested) { - // The logger has started dereferencing since I added the parseTokenSource.dispose() to parseDocumentAsync. const msg = `Cancellation requested before start for ${name}. Returning default.`; - if (logger) logger.debug(msg); - else console.debug(msg); + Services.logger.debug(msg); return defaultValue; } return new Promise((resolve) => { const onCancel = () =>{ const msg = `Cancellation requested during processing for ${name}. Returning default.`; - if (logger) logger.debug(msg); - else console.debug(msg); + Services.logger.debug(msg); resolve(defaultValue); } token.onCancellationRequested(onCancel); fn(params, token).then(resolve).catch(() => resolve(defaultValue)); + token.onCancellationRequested(() => undefined); + Services.logger.debug(`Finished processing ${name}`); }); }; } \ No newline at end of file