Skip to content

Commit 06f8773

Browse files
authored
[WIP] Add helpful messages for invalid expression diagnostics (#33)
* Add extended diagnostics for syntax errors (unclosed strings, brackets, comments) * Simplify diagnostics to use parser directly instead of duplicating logic * Fix tests to match actual parser error messages and handle generic errors
1 parent 361dec9 commit 06f8773

3 files changed

Lines changed: 272 additions & 6 deletions

File tree

src/language-service/diagnostics.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
22
* Diagnostics module for the language service.
3-
* Provides function argument count validation.
3+
* Provides function argument count validation and syntax error detection.
4+
*
5+
* This module leverages the existing parser infrastructure for error detection,
6+
* avoiding duplication of tokenization and parsing logic.
47
*/
58

69
import {
@@ -16,6 +19,13 @@ import { DiagnosticSeverity } from 'vscode-languageserver-types';
1619
import type { TextDocument } from 'vscode-languageserver-textdocument';
1720
import type { GetDiagnosticsParams, ArityInfo } from './language-service.types';
1821
import { FunctionDetails } from './language-service.models';
22+
import { ParseError } from '../types/errors';
23+
24+
/**
25+
* Length of the error highlight range when position is known but token length is not.
26+
* Used to visually indicate the location of an error in the source text.
27+
*/
28+
const ERROR_HIGHLIGHT_LENGTH = 10;
1929

2030
/**
2131
* Represents a token with its position in the source text.
@@ -332,3 +342,62 @@ export function getDiagnosticsForDocument(
332342

333343
return diagnostics;
334344
}
345+
346+
/**
347+
* Creates a diagnostic from a ParseError.
348+
* This function converts errors thrown by the parser/tokenizer into diagnostics
349+
* that can be displayed to the user.
350+
*/
351+
export function createDiagnosticFromParseError(
352+
textDocument: TextDocument,
353+
error: ParseError
354+
): Diagnostic {
355+
const position = error.context.position;
356+
let startOffset = 0;
357+
let endOffset = textDocument.getText().length;
358+
359+
if (position) {
360+
// Convert line/column to offset
361+
startOffset = textDocument.offsetAt({
362+
line: position.line - 1, // ParseError uses 1-based line numbers
363+
character: position.column - 1 // ParseError uses 1-based column numbers
364+
});
365+
// Highlight a fixed-length region from the error position
366+
endOffset = Math.min(startOffset + ERROR_HIGHLIGHT_LENGTH, textDocument.getText().length);
367+
}
368+
369+
const range: Range = {
370+
start: textDocument.positionAt(startOffset),
371+
end: textDocument.positionAt(endOffset)
372+
};
373+
374+
return {
375+
range,
376+
severity: DiagnosticSeverity.Error,
377+
message: error.message,
378+
source: 'expr-eval'
379+
};
380+
}
381+
382+
/**
383+
* Creates a diagnostic from a generic Error.
384+
* This function handles errors thrown by the parser that are not ParseError instances.
385+
* Since these errors don't have position information, the diagnostic highlights the whole text.
386+
*/
387+
export function createDiagnosticFromError(
388+
textDocument: TextDocument,
389+
error: Error
390+
): Diagnostic {
391+
const text = textDocument.getText();
392+
const range: Range = {
393+
start: textDocument.positionAt(0),
394+
end: textDocument.positionAt(text.length)
395+
};
396+
397+
return {
398+
range,
399+
severity: DiagnosticSeverity.Error,
400+
message: error.message,
401+
source: 'expr-eval'
402+
};
403+
}

src/language-service/language-service.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ import {
3737
iterateTokens
3838
} from './ls-utils';
3939
import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils';
40-
import { getDiagnosticsForDocument } from './diagnostics';
40+
import {
41+
getDiagnosticsForDocument,
42+
createDiagnosticFromParseError,
43+
createDiagnosticFromError,
44+
TokenSpan
45+
} from './diagnostics';
46+
import { ParseError } from '../types/errors';
4147

4248
export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi {
4349
// Build a parser instance to access keywords/operators/functions/consts
@@ -299,22 +305,54 @@ export function createLanguageService(options: LanguageServiceOptions | undefine
299305

300306
/**
301307
* Analyzes the document for function calls and checks if they have the correct number of arguments.
302-
* Returns diagnostics for function calls with incorrect argument counts.
308+
* Returns diagnostics for function calls with incorrect argument counts, as well as
309+
* syntax errors detected by the parser (unclosed strings, brackets, unknown characters, etc.).
303310
*/
304311
function getDiagnostics(params: GetDiagnosticsParams): Diagnostic[] {
305312
const { textDocument } = params;
306313
const text = textDocument.getText();
314+
const diagnostics: Diagnostic[] = [];
315+
316+
// Try to parse the expression to catch syntax errors
317+
// The parser will throw ParseError for issues like:
318+
// - Unknown characters
319+
// - Unclosed strings
320+
// - Illegal escape sequences
321+
// - Unexpected tokens
322+
// - Missing expected tokens (like closing brackets)
323+
try {
324+
parser.parse(text);
325+
} catch (error) {
326+
if (error instanceof ParseError) {
327+
diagnostics.push(createDiagnosticFromParseError(textDocument, error));
328+
} else if (error instanceof Error) {
329+
// Handle generic errors thrown by the parser (e.g., invalid object definition)
330+
diagnostics.push(createDiagnosticFromError(textDocument, error));
331+
}
332+
}
307333

308-
const ts = makeTokenStream(parser, text);
309-
const spans = iterateTokens(ts);
334+
// Try to tokenize for function argument checking
335+
let spans: TokenSpan[] = [];
336+
try {
337+
const ts = makeTokenStream(parser, text);
338+
spans = iterateTokens(ts);
339+
} catch (error) {
340+
// If tokenization fails, we already have a parse error diagnostic
341+
// Return early since we can't do function argument checking without tokens
342+
return diagnostics;
343+
}
310344

311345
// Build a map from function name to FunctionDetails for quick lookup
312346
const funcDetailsMap = new Map<string, FunctionDetails>();
313347
for (const func of allFunctions()) {
314348
funcDetailsMap.set(func.name, func);
315349
}
316350

317-
return getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap);
351+
// Get function argument count diagnostics
352+
const functionDiagnostics = getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap);
353+
diagnostics.push(...functionDiagnostics);
354+
355+
return diagnostics;
318356
}
319357

320358
return {

test/language-service/language-service.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,5 +829,164 @@ describe('Language Service', () => {
829829
const diagnostics = ls.getDiagnostics({ textDocument: doc });
830830
expect(diagnostics).toEqual([]);
831831
});
832+
833+
// Extended diagnostics tests for syntax errors
834+
// These tests verify that parser errors are properly converted to diagnostics
835+
describe('syntax error diagnostics', () => {
836+
it('should detect unclosed string literal with double quotes', () => {
837+
const text = '"hello world';
838+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
839+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
840+
841+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
842+
// Parser reports this as 'Unknown character' since unclosed string is not tokenized
843+
const stringDiag = diagnostics.find(d => d.message.includes('Unknown character'));
844+
expect(stringDiag).toBeDefined();
845+
expect(stringDiag?.severity).toBe(DiagnosticSeverity.Error);
846+
});
847+
848+
it('should detect unclosed string literal with single quotes', () => {
849+
const text = "'hello world";
850+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
851+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
852+
853+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
854+
// Parser reports this as 'Unknown character' since unclosed string is not tokenized
855+
const stringDiag = diagnostics.find(d => d.message.includes('Unknown character'));
856+
expect(stringDiag).toBeDefined();
857+
});
858+
859+
it('should detect unclosed parenthesis', () => {
860+
const text = '(1 + 2';
861+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
862+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
863+
864+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
865+
// Parser expects closing parenthesis
866+
const parenDiag = diagnostics.find(d => d.message.includes('Expected )'));
867+
expect(parenDiag).toBeDefined();
868+
});
869+
870+
it('should detect unclosed bracket', () => {
871+
const text = '[1, 2, 3';
872+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
873+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
874+
875+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
876+
// Parser reports unexpected end of input
877+
const bracketDiag = diagnostics.find(d => d.message.includes('Unexpected token'));
878+
expect(bracketDiag).toBeDefined();
879+
});
880+
881+
it('should detect unclosed brace', () => {
882+
const text = '{a: 1, b: 2';
883+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
884+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
885+
886+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
887+
// Parser reports invalid object definition
888+
const braceDiag = diagnostics.find(d => d.message.includes('invalid object definition'));
889+
expect(braceDiag).toBeDefined();
890+
});
891+
892+
it('should detect unexpected closing parenthesis', () => {
893+
const text = '1 + 2)';
894+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
895+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
896+
897+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
898+
// Parser expects EOF but found )
899+
const parenDiag = diagnostics.find(d => d.message.includes('Expected EOF'));
900+
expect(parenDiag).toBeDefined();
901+
});
902+
903+
it('should detect unexpected closing bracket', () => {
904+
const text = '1 + 2]';
905+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
906+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
907+
908+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
909+
// Parser expects EOF but found ]
910+
const bracketDiag = diagnostics.find(d => d.message.includes('Expected EOF'));
911+
expect(bracketDiag).toBeDefined();
912+
});
913+
914+
it('should detect unexpected closing brace', () => {
915+
const text = '1 + 2}';
916+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
917+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
918+
919+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
920+
// Parser expects EOF but found }
921+
const braceDiag = diagnostics.find(d => d.message.includes('Expected EOF'));
922+
expect(braceDiag).toBeDefined();
923+
});
924+
925+
it('should detect unclosed comment', () => {
926+
const text = '1 + /* this is a comment 2';
927+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
928+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
929+
930+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
931+
// Parser reports unexpected end of input
932+
const commentDiag = diagnostics.find(d => d.message.includes('Unexpected token'));
933+
expect(commentDiag).toBeDefined();
934+
});
935+
936+
it('should detect unknown character', () => {
937+
const text = '1 @ 2';
938+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
939+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
940+
941+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
942+
const unknownCharDiag = diagnostics.find(d => d.message.includes('Unknown character'));
943+
expect(unknownCharDiag).toBeDefined();
944+
});
945+
946+
it('should not report errors for valid closed strings', () => {
947+
const text = '"hello" + "world"';
948+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
949+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
950+
expect(diagnostics).toEqual([]);
951+
});
952+
953+
it('should not report errors for valid closed brackets', () => {
954+
const text = '(1 + 2) * [3, 4][0]';
955+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
956+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
957+
expect(diagnostics).toEqual([]);
958+
});
959+
960+
it('should not report errors for valid closed comments', () => {
961+
const text = '1 + /* comment */ 2';
962+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
963+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
964+
expect(diagnostics).toEqual([]);
965+
});
966+
967+
it('should handle nested brackets correctly', () => {
968+
const text = '((1 + 2) * (3 + 4))';
969+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
970+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
971+
expect(diagnostics).toEqual([]);
972+
});
973+
974+
it('should handle escaped quotes in strings', () => {
975+
const text = '"hello \\"world\\""';
976+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
977+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
978+
expect(diagnostics).toEqual([]);
979+
});
980+
981+
it('should detect syntax errors in complex expressions', () => {
982+
const text = '(1 + "unclosed';
983+
const doc = TextDocument.create('file://test', 'plaintext', 1, text);
984+
const diagnostics = ls.getDiagnostics({ textDocument: doc });
985+
986+
// Parser will report the first error it encounters (unknown character for unclosed string)
987+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
988+
expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error);
989+
});
990+
});
832991
});
833992
});

0 commit comments

Comments
 (0)