From 2301c952c325069577f4f7caa09eb69941621ecd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:33:57 +0000 Subject: [PATCH 1/4] Initial plan From 47652a0683b8eeb9fbf3876d3cfc2456eb435605 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:43:35 +0000 Subject: [PATCH 2/4] Add extended diagnostics for syntax errors (unclosed strings, brackets, comments) Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/diagnostics.ts | 321 +++++++++++++++++++++- src/language-service/language-service.ts | 49 +++- test/language-service/language-service.ts | 152 ++++++++++ 3 files changed, 515 insertions(+), 7 deletions(-) diff --git a/src/language-service/diagnostics.ts b/src/language-service/diagnostics.ts index 51895b5..551648b 100644 --- a/src/language-service/diagnostics.ts +++ b/src/language-service/diagnostics.ts @@ -1,6 +1,6 @@ /** * Diagnostics module for the language service. - * Provides function argument count validation. + * Provides function argument count validation and syntax error detection. */ import { @@ -16,6 +16,7 @@ import { DiagnosticSeverity } from 'vscode-languageserver-types'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import type { GetDiagnosticsParams, ArityInfo } from './language-service.types'; import { FunctionDetails } from './language-service.models'; +import { ParseError } from '../types/errors'; /** * Represents a token with its position in the source text. @@ -332,3 +333,321 @@ export function getDiagnosticsForDocument( return diagnostics; } + +/** + * Creates a diagnostic from a ParseError. + */ +export function createDiagnosticFromParseError( + textDocument: TextDocument, + error: ParseError +): Diagnostic { + const position = error.context.position; + let startOffset = 0; + let endOffset = textDocument.getText().length; + + if (position) { + // Convert line/column to offset + startOffset = textDocument.offsetAt({ + line: position.line - 1, // ParseError uses 1-based line numbers + character: position.column - 1 // ParseError uses 1-based column numbers + }); + // End at the end of the line or a few characters ahead + endOffset = Math.min(startOffset + 10, textDocument.getText().length); + } + + const range: Range = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset) + }; + + return { + range, + severity: DiagnosticSeverity.Error, + message: error.message, + source: 'expr-eval' + }; +} + +/** + * Result of checking brackets in an expression. + */ +interface BracketCheckResult { + unclosedOpening: Array<{ char: string; offset: number }>; + unmatchedClosing: Array<{ char: string; offset: number }>; +} + +/** + * Checks for unclosed or mismatched brackets, parentheses, and braces. + */ +function checkBrackets(text: string): BracketCheckResult { + const stack: Array<{ char: string; offset: number }> = []; + const unclosedOpening: Array<{ char: string; offset: number }> = []; + const unmatchedClosing: Array<{ char: string; offset: number }> = []; + const matchingBrackets: Record = { ')': '(', ']': '[', '}': '{' }; + const openBrackets = new Set(['(', '[', '{']); + const closeBrackets = new Set([')', ']', '}']); + + let inString = false; + let stringChar = ''; + let i = 0; + + while (i < text.length) { + const char = text[i]; + + // Handle string literals + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + i++; + continue; + } + if (inString) { + if (char === '\\' && i + 1 < text.length) { + i += 2; // Skip escaped character + continue; + } + if (char === stringChar) { + inString = false; + } + i++; + continue; + } + + // Handle comments + if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { + const endComment = text.indexOf('*/', i + 2); + if (endComment >= 0) { + i = endComment + 2; + } else { + i = text.length; + } + continue; + } + + if (openBrackets.has(char)) { + stack.push({ char, offset: i }); + } else if (closeBrackets.has(char)) { + const expected = matchingBrackets[char]; + if (stack.length > 0 && stack[stack.length - 1].char === expected) { + stack.pop(); + } else { + unmatchedClosing.push({ char, offset: i }); + } + } + i++; + } + + // Any remaining items in the stack are unclosed + unclosedOpening.push(...stack); + + return { unclosedOpening, unmatchedClosing }; +} + +/** + * Creates diagnostics for bracket matching issues. + */ +export function getDiagnosticsForBrackets( + textDocument: TextDocument +): Diagnostic[] { + const text = textDocument.getText(); + const { unclosedOpening, unmatchedClosing } = checkBrackets(text); + const diagnostics: Diagnostic[] = []; + + const bracketNames: Record = { + '(': 'parenthesis', + ')': 'parenthesis', + '[': 'bracket', + ']': 'bracket', + '{': 'brace', + '}': 'brace' + }; + + for (const { char, offset } of unclosedOpening) { + const range: Range = { + start: textDocument.positionAt(offset), + end: textDocument.positionAt(offset + 1) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Unclosed ${bracketNames[char]} '${char}'.`, + source: 'expr-eval' + }); + } + + for (const { char, offset } of unmatchedClosing) { + const range: Range = { + start: textDocument.positionAt(offset), + end: textDocument.positionAt(offset + 1) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Unexpected closing ${bracketNames[char]} '${char}'.`, + source: 'expr-eval' + }); + } + + return diagnostics; +} + +/** + * Result of checking for unclosed strings. + */ +interface UnclosedStringResult { + unclosedStrings: Array<{ quote: string; offset: number }>; +} + +/** + * Checks for unclosed string literals. + */ +function checkUnclosedStrings(text: string): UnclosedStringResult { + const unclosedStrings: Array<{ quote: string; offset: number }> = []; + let i = 0; + + while (i < text.length) { + const char = text[i]; + + // Handle comments (skip them) + if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { + const endComment = text.indexOf('*/', i + 2); + if (endComment >= 0) { + i = endComment + 2; + } else { + i = text.length; + } + continue; + } + + // Check for string start + if (char === '"' || char === "'") { + const quote = char; + const startOffset = i; + i++; // Move past the opening quote + + let closed = false; + while (i < text.length) { + if (text[i] === '\\' && i + 1 < text.length) { + i += 2; // Skip escaped character + continue; + } + if (text[i] === quote) { + closed = true; + i++; // Move past the closing quote + break; + } + i++; + } + + if (!closed) { + unclosedStrings.push({ quote, offset: startOffset }); + } + continue; + } + + i++; + } + + return { unclosedStrings }; +} + +/** + * Creates diagnostics for unclosed string literals. + */ +export function getDiagnosticsForUnclosedStrings( + textDocument: TextDocument +): Diagnostic[] { + const text = textDocument.getText(); + const { unclosedStrings } = checkUnclosedStrings(text); + const diagnostics: Diagnostic[] = []; + + for (const { quote, offset } of unclosedStrings) { + const range: Range = { + start: textDocument.positionAt(offset), + end: textDocument.positionAt(text.length) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Unclosed string literal. Missing closing ${quote === '"' ? 'double quote' : 'single quote'}.`, + source: 'expr-eval' + }); + } + + return diagnostics; +} + +/** + * Checks for unclosed comments. + */ +function checkUnclosedComments(text: string): Array<{ offset: number }> { + const unclosedComments: Array<{ offset: number }> = []; + let i = 0; + let inString = false; + let stringChar = ''; + + while (i < text.length) { + const char = text[i]; + + // Handle string literals (skip them to avoid false positives) + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + i++; + continue; + } + if (inString) { + if (char === '\\' && i + 1 < text.length) { + i += 2; + continue; + } + if (char === stringChar) { + inString = false; + } + i++; + continue; + } + + // Check for comment start + if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { + const commentStart = i; + const endComment = text.indexOf('*/', i + 2); + if (endComment < 0) { + unclosedComments.push({ offset: commentStart }); + break; + } + i = endComment + 2; + continue; + } + + i++; + } + + return unclosedComments; +} + +/** + * Creates diagnostics for unclosed comments. + */ +export function getDiagnosticsForUnclosedComments( + textDocument: TextDocument +): Diagnostic[] { + const text = textDocument.getText(); + const unclosedComments = checkUnclosedComments(text); + const diagnostics: Diagnostic[] = []; + + for (const { offset } of unclosedComments) { + const range: Range = { + start: textDocument.positionAt(offset), + end: textDocument.positionAt(text.length) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Unclosed comment. Missing closing '*/' for block comment.`, + source: 'expr-eval' + }); + } + + return diagnostics; +} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 01d74b3..061c0e4 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -37,7 +37,15 @@ import { iterateTokens } from './ls-utils'; import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils'; -import { getDiagnosticsForDocument } from './diagnostics'; +import { + getDiagnosticsForDocument, + getDiagnosticsForBrackets, + getDiagnosticsForUnclosedStrings, + getDiagnosticsForUnclosedComments, + createDiagnosticFromParseError, + TokenSpan +} from './diagnostics'; +import { ParseError } from '../types/errors'; export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { // Build a parser instance to access keywords/operators/functions/consts @@ -299,14 +307,39 @@ export function createLanguageService(options: LanguageServiceOptions | undefine /** * Analyzes the document for function calls and checks if they have the correct number of arguments. - * Returns diagnostics for function calls with incorrect argument counts. + * Returns diagnostics for function calls with incorrect argument counts, as well as + * syntax errors like unclosed strings, brackets, and comments. */ function getDiagnostics(params: GetDiagnosticsParams): Diagnostic[] { const { textDocument } = params; const text = textDocument.getText(); - - const ts = makeTokenStream(parser, text); - const spans = iterateTokens(ts); + const diagnostics: Diagnostic[] = []; + + // Check for unclosed strings first (these prevent proper tokenization) + const stringDiagnostics = getDiagnosticsForUnclosedStrings(textDocument); + diagnostics.push(...stringDiagnostics); + + // Check for unclosed comments + const commentDiagnostics = getDiagnosticsForUnclosedComments(textDocument); + diagnostics.push(...commentDiagnostics); + + // Check for mismatched brackets + const bracketDiagnostics = getDiagnosticsForBrackets(textDocument); + diagnostics.push(...bracketDiagnostics); + + // Try to tokenize and get additional diagnostics + let spans: TokenSpan[] = []; + try { + const ts = makeTokenStream(parser, text); + spans = iterateTokens(ts); + } catch (error) { + // If tokenization fails, add a diagnostic for the parse error + if (error instanceof ParseError) { + diagnostics.push(createDiagnosticFromParseError(textDocument, error)); + } + // Return early since we can't do function argument checking without tokens + return diagnostics; + } // Build a map from function name to FunctionDetails for quick lookup const funcDetailsMap = new Map(); @@ -314,7 +347,11 @@ export function createLanguageService(options: LanguageServiceOptions | undefine funcDetailsMap.set(func.name, func); } - return getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap); + // Get function argument count diagnostics + const functionDiagnostics = getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap); + diagnostics.push(...functionDiagnostics); + + return diagnostics; } return { diff --git a/test/language-service/language-service.ts b/test/language-service/language-service.ts index d920d97..e7c1278 100644 --- a/test/language-service/language-service.ts +++ b/test/language-service/language-service.ts @@ -829,5 +829,157 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics).toEqual([]); }); + + // Extended diagnostics tests for syntax errors + describe('syntax error diagnostics', () => { + it('should detect unclosed string literal with double quotes', () => { + const text = '"hello world'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const stringDiag = diagnostics.find(d => d.message.includes('Unclosed string')); + expect(stringDiag).toBeDefined(); + expect(stringDiag?.message).toContain('double quote'); + expect(stringDiag?.severity).toBe(DiagnosticSeverity.Error); + }); + + it('should detect unclosed string literal with single quotes', () => { + const text = "'hello world"; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const stringDiag = diagnostics.find(d => d.message.includes('Unclosed string')); + expect(stringDiag).toBeDefined(); + expect(stringDiag?.message).toContain('single quote'); + }); + + it('should detect unclosed parenthesis', () => { + const text = '(1 + 2'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const parenDiag = diagnostics.find(d => d.message.includes('Unclosed parenthesis')); + expect(parenDiag).toBeDefined(); + }); + + it('should detect unclosed bracket', () => { + const text = '[1, 2, 3'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const bracketDiag = diagnostics.find(d => d.message.includes('Unclosed bracket')); + expect(bracketDiag).toBeDefined(); + }); + + it('should detect unclosed brace', () => { + const text = '{a: 1, b: 2'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const braceDiag = diagnostics.find(d => d.message.includes('Unclosed brace')); + expect(braceDiag).toBeDefined(); + }); + + it('should detect unexpected closing parenthesis', () => { + const text = '1 + 2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const parenDiag = diagnostics.find(d => d.message.includes('Unexpected closing parenthesis')); + expect(parenDiag).toBeDefined(); + }); + + it('should detect unexpected closing bracket', () => { + const text = '1 + 2]'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const bracketDiag = diagnostics.find(d => d.message.includes('Unexpected closing bracket')); + expect(bracketDiag).toBeDefined(); + }); + + it('should detect unexpected closing brace', () => { + const text = '1 + 2}'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const braceDiag = diagnostics.find(d => d.message.includes('Unexpected closing brace')); + expect(braceDiag).toBeDefined(); + }); + + it('should detect unclosed comment', () => { + const text = '1 + /* this is a comment 2'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const commentDiag = diagnostics.find(d => d.message.includes('Unclosed comment')); + expect(commentDiag).toBeDefined(); + }); + + it('should detect unknown character', () => { + const text = '1 @ 2'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + const unknownCharDiag = diagnostics.find(d => d.message.includes('Unknown character')); + expect(unknownCharDiag).toBeDefined(); + }); + + it('should not report errors for valid closed strings', () => { + const text = '"hello" + "world"'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should not report errors for valid closed brackets', () => { + const text = '(1 + 2) * [3, 4][0]'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should not report errors for valid closed comments', () => { + const text = '1 + /* comment */ 2'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should handle nested brackets correctly', () => { + const text = '((1 + 2) * (3 + 4))'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should handle escaped quotes in strings', () => { + const text = '"hello \\"world\\""'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should detect multiple syntax errors', () => { + const text = '(1 + "unclosed'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + // Should detect both unclosed paren and unclosed string + expect(diagnostics.length).toBeGreaterThanOrEqual(2); + expect(diagnostics.some(d => d.message.includes('Unclosed parenthesis'))).toBe(true); + expect(diagnostics.some(d => d.message.includes('Unclosed string'))).toBe(true); + }); + }); }); }); From 7d1efbf8e7691ec54facb817675991b7596726a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:28:09 +0000 Subject: [PATCH 3/4] Simplify diagnostics to use parser directly instead of duplicating logic Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/diagnostics.ts | 299 +---------------------- src/language-service/language-service.ts | 37 ++- 2 files changed, 30 insertions(+), 306 deletions(-) diff --git a/src/language-service/diagnostics.ts b/src/language-service/diagnostics.ts index 551648b..b25a55a 100644 --- a/src/language-service/diagnostics.ts +++ b/src/language-service/diagnostics.ts @@ -1,6 +1,9 @@ /** * Diagnostics module for the language service. * Provides function argument count validation and syntax error detection. + * + * This module leverages the existing parser infrastructure for error detection, + * avoiding duplication of tokenization and parsing logic. */ import { @@ -18,6 +21,12 @@ import type { GetDiagnosticsParams, ArityInfo } from './language-service.types'; import { FunctionDetails } from './language-service.models'; import { ParseError } from '../types/errors'; +/** + * Length of the error highlight range when position is known but token length is not. + * Used to visually indicate the location of an error in the source text. + */ +const ERROR_HIGHLIGHT_LENGTH = 10; + /** * Represents a token with its position in the source text. */ @@ -336,6 +345,8 @@ export function getDiagnosticsForDocument( /** * Creates a diagnostic from a ParseError. + * This function converts errors thrown by the parser/tokenizer into diagnostics + * that can be displayed to the user. */ export function createDiagnosticFromParseError( textDocument: TextDocument, @@ -351,8 +362,8 @@ export function createDiagnosticFromParseError( line: position.line - 1, // ParseError uses 1-based line numbers character: position.column - 1 // ParseError uses 1-based column numbers }); - // End at the end of the line or a few characters ahead - endOffset = Math.min(startOffset + 10, textDocument.getText().length); + // Highlight a fixed-length region from the error position + endOffset = Math.min(startOffset + ERROR_HIGHLIGHT_LENGTH, textDocument.getText().length); } const range: Range = { @@ -367,287 +378,3 @@ export function createDiagnosticFromParseError( source: 'expr-eval' }; } - -/** - * Result of checking brackets in an expression. - */ -interface BracketCheckResult { - unclosedOpening: Array<{ char: string; offset: number }>; - unmatchedClosing: Array<{ char: string; offset: number }>; -} - -/** - * Checks for unclosed or mismatched brackets, parentheses, and braces. - */ -function checkBrackets(text: string): BracketCheckResult { - const stack: Array<{ char: string; offset: number }> = []; - const unclosedOpening: Array<{ char: string; offset: number }> = []; - const unmatchedClosing: Array<{ char: string; offset: number }> = []; - const matchingBrackets: Record = { ')': '(', ']': '[', '}': '{' }; - const openBrackets = new Set(['(', '[', '{']); - const closeBrackets = new Set([')', ']', '}']); - - let inString = false; - let stringChar = ''; - let i = 0; - - while (i < text.length) { - const char = text[i]; - - // Handle string literals - if (!inString && (char === '"' || char === "'")) { - inString = true; - stringChar = char; - i++; - continue; - } - if (inString) { - if (char === '\\' && i + 1 < text.length) { - i += 2; // Skip escaped character - continue; - } - if (char === stringChar) { - inString = false; - } - i++; - continue; - } - - // Handle comments - if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { - const endComment = text.indexOf('*/', i + 2); - if (endComment >= 0) { - i = endComment + 2; - } else { - i = text.length; - } - continue; - } - - if (openBrackets.has(char)) { - stack.push({ char, offset: i }); - } else if (closeBrackets.has(char)) { - const expected = matchingBrackets[char]; - if (stack.length > 0 && stack[stack.length - 1].char === expected) { - stack.pop(); - } else { - unmatchedClosing.push({ char, offset: i }); - } - } - i++; - } - - // Any remaining items in the stack are unclosed - unclosedOpening.push(...stack); - - return { unclosedOpening, unmatchedClosing }; -} - -/** - * Creates diagnostics for bracket matching issues. - */ -export function getDiagnosticsForBrackets( - textDocument: TextDocument -): Diagnostic[] { - const text = textDocument.getText(); - const { unclosedOpening, unmatchedClosing } = checkBrackets(text); - const diagnostics: Diagnostic[] = []; - - const bracketNames: Record = { - '(': 'parenthesis', - ')': 'parenthesis', - '[': 'bracket', - ']': 'bracket', - '{': 'brace', - '}': 'brace' - }; - - for (const { char, offset } of unclosedOpening) { - const range: Range = { - start: textDocument.positionAt(offset), - end: textDocument.positionAt(offset + 1) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Unclosed ${bracketNames[char]} '${char}'.`, - source: 'expr-eval' - }); - } - - for (const { char, offset } of unmatchedClosing) { - const range: Range = { - start: textDocument.positionAt(offset), - end: textDocument.positionAt(offset + 1) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Unexpected closing ${bracketNames[char]} '${char}'.`, - source: 'expr-eval' - }); - } - - return diagnostics; -} - -/** - * Result of checking for unclosed strings. - */ -interface UnclosedStringResult { - unclosedStrings: Array<{ quote: string; offset: number }>; -} - -/** - * Checks for unclosed string literals. - */ -function checkUnclosedStrings(text: string): UnclosedStringResult { - const unclosedStrings: Array<{ quote: string; offset: number }> = []; - let i = 0; - - while (i < text.length) { - const char = text[i]; - - // Handle comments (skip them) - if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { - const endComment = text.indexOf('*/', i + 2); - if (endComment >= 0) { - i = endComment + 2; - } else { - i = text.length; - } - continue; - } - - // Check for string start - if (char === '"' || char === "'") { - const quote = char; - const startOffset = i; - i++; // Move past the opening quote - - let closed = false; - while (i < text.length) { - if (text[i] === '\\' && i + 1 < text.length) { - i += 2; // Skip escaped character - continue; - } - if (text[i] === quote) { - closed = true; - i++; // Move past the closing quote - break; - } - i++; - } - - if (!closed) { - unclosedStrings.push({ quote, offset: startOffset }); - } - continue; - } - - i++; - } - - return { unclosedStrings }; -} - -/** - * Creates diagnostics for unclosed string literals. - */ -export function getDiagnosticsForUnclosedStrings( - textDocument: TextDocument -): Diagnostic[] { - const text = textDocument.getText(); - const { unclosedStrings } = checkUnclosedStrings(text); - const diagnostics: Diagnostic[] = []; - - for (const { quote, offset } of unclosedStrings) { - const range: Range = { - start: textDocument.positionAt(offset), - end: textDocument.positionAt(text.length) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Unclosed string literal. Missing closing ${quote === '"' ? 'double quote' : 'single quote'}.`, - source: 'expr-eval' - }); - } - - return diagnostics; -} - -/** - * Checks for unclosed comments. - */ -function checkUnclosedComments(text: string): Array<{ offset: number }> { - const unclosedComments: Array<{ offset: number }> = []; - let i = 0; - let inString = false; - let stringChar = ''; - - while (i < text.length) { - const char = text[i]; - - // Handle string literals (skip them to avoid false positives) - if (!inString && (char === '"' || char === "'")) { - inString = true; - stringChar = char; - i++; - continue; - } - if (inString) { - if (char === '\\' && i + 1 < text.length) { - i += 2; - continue; - } - if (char === stringChar) { - inString = false; - } - i++; - continue; - } - - // Check for comment start - if (char === '/' && i + 1 < text.length && text[i + 1] === '*') { - const commentStart = i; - const endComment = text.indexOf('*/', i + 2); - if (endComment < 0) { - unclosedComments.push({ offset: commentStart }); - break; - } - i = endComment + 2; - continue; - } - - i++; - } - - return unclosedComments; -} - -/** - * Creates diagnostics for unclosed comments. - */ -export function getDiagnosticsForUnclosedComments( - textDocument: TextDocument -): Diagnostic[] { - const text = textDocument.getText(); - const unclosedComments = checkUnclosedComments(text); - const diagnostics: Diagnostic[] = []; - - for (const { offset } of unclosedComments) { - const range: Range = { - start: textDocument.positionAt(offset), - end: textDocument.positionAt(text.length) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Unclosed comment. Missing closing '*/' for block comment.`, - source: 'expr-eval' - }); - } - - return diagnostics; -} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 061c0e4..eead8ad 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -39,9 +39,6 @@ import { import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils'; import { getDiagnosticsForDocument, - getDiagnosticsForBrackets, - getDiagnosticsForUnclosedStrings, - getDiagnosticsForUnclosedComments, createDiagnosticFromParseError, TokenSpan } from './diagnostics'; @@ -308,35 +305,35 @@ export function createLanguageService(options: LanguageServiceOptions | undefine /** * Analyzes the document for function calls and checks if they have the correct number of arguments. * Returns diagnostics for function calls with incorrect argument counts, as well as - * syntax errors like unclosed strings, brackets, and comments. + * syntax errors detected by the parser (unclosed strings, brackets, unknown characters, etc.). */ function getDiagnostics(params: GetDiagnosticsParams): Diagnostic[] { const { textDocument } = params; const text = textDocument.getText(); const diagnostics: Diagnostic[] = []; - // Check for unclosed strings first (these prevent proper tokenization) - const stringDiagnostics = getDiagnosticsForUnclosedStrings(textDocument); - diagnostics.push(...stringDiagnostics); - - // Check for unclosed comments - const commentDiagnostics = getDiagnosticsForUnclosedComments(textDocument); - diagnostics.push(...commentDiagnostics); - - // Check for mismatched brackets - const bracketDiagnostics = getDiagnosticsForBrackets(textDocument); - diagnostics.push(...bracketDiagnostics); + // Try to parse the expression to catch syntax errors + // The parser will throw ParseError for issues like: + // - Unknown characters + // - Unclosed strings + // - Illegal escape sequences + // - Unexpected tokens + // - Missing expected tokens (like closing brackets) + try { + parser.parse(text); + } catch (error) { + if (error instanceof ParseError) { + diagnostics.push(createDiagnosticFromParseError(textDocument, error)); + } + } - // Try to tokenize and get additional diagnostics + // Try to tokenize for function argument checking let spans: TokenSpan[] = []; try { const ts = makeTokenStream(parser, text); spans = iterateTokens(ts); } catch (error) { - // If tokenization fails, add a diagnostic for the parse error - if (error instanceof ParseError) { - diagnostics.push(createDiagnosticFromParseError(textDocument, error)); - } + // If tokenization fails, we already have a parse error diagnostic // Return early since we can't do function argument checking without tokens return diagnostics; } From 873b950b6265cdd7bdcd198ba49039790bff5b94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:49:27 +0000 Subject: [PATCH 4/4] Fix tests to match actual parser error messages and handle generic errors Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/diagnostics.ts | 23 +++++++++++++ src/language-service/language-service.ts | 4 +++ test/language-service/language-service.ts | 39 +++++++++++++---------- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/language-service/diagnostics.ts b/src/language-service/diagnostics.ts index b25a55a..88d6617 100644 --- a/src/language-service/diagnostics.ts +++ b/src/language-service/diagnostics.ts @@ -378,3 +378,26 @@ export function createDiagnosticFromParseError( source: 'expr-eval' }; } + +/** + * Creates a diagnostic from a generic Error. + * This function handles errors thrown by the parser that are not ParseError instances. + * Since these errors don't have position information, the diagnostic highlights the whole text. + */ +export function createDiagnosticFromError( + textDocument: TextDocument, + error: Error +): Diagnostic { + const text = textDocument.getText(); + const range: Range = { + start: textDocument.positionAt(0), + end: textDocument.positionAt(text.length) + }; + + return { + range, + severity: DiagnosticSeverity.Error, + message: error.message, + source: 'expr-eval' + }; +} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index eead8ad..0b7f415 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -40,6 +40,7 @@ import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable- import { getDiagnosticsForDocument, createDiagnosticFromParseError, + createDiagnosticFromError, TokenSpan } from './diagnostics'; import { ParseError } from '../types/errors'; @@ -324,6 +325,9 @@ export function createLanguageService(options: LanguageServiceOptions | undefine } catch (error) { if (error instanceof ParseError) { diagnostics.push(createDiagnosticFromParseError(textDocument, error)); + } else if (error instanceof Error) { + // Handle generic errors thrown by the parser (e.g., invalid object definition) + diagnostics.push(createDiagnosticFromError(textDocument, error)); } } diff --git a/test/language-service/language-service.ts b/test/language-service/language-service.ts index e7c1278..04cb635 100644 --- a/test/language-service/language-service.ts +++ b/test/language-service/language-service.ts @@ -831,6 +831,7 @@ describe('Language Service', () => { }); // Extended diagnostics tests for syntax errors + // These tests verify that parser errors are properly converted to diagnostics describe('syntax error diagnostics', () => { it('should detect unclosed string literal with double quotes', () => { const text = '"hello world'; @@ -838,9 +839,9 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const stringDiag = diagnostics.find(d => d.message.includes('Unclosed string')); + // Parser reports this as 'Unknown character' since unclosed string is not tokenized + const stringDiag = diagnostics.find(d => d.message.includes('Unknown character')); expect(stringDiag).toBeDefined(); - expect(stringDiag?.message).toContain('double quote'); expect(stringDiag?.severity).toBe(DiagnosticSeverity.Error); }); @@ -850,9 +851,9 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const stringDiag = diagnostics.find(d => d.message.includes('Unclosed string')); + // Parser reports this as 'Unknown character' since unclosed string is not tokenized + const stringDiag = diagnostics.find(d => d.message.includes('Unknown character')); expect(stringDiag).toBeDefined(); - expect(stringDiag?.message).toContain('single quote'); }); it('should detect unclosed parenthesis', () => { @@ -861,7 +862,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const parenDiag = diagnostics.find(d => d.message.includes('Unclosed parenthesis')); + // Parser expects closing parenthesis + const parenDiag = diagnostics.find(d => d.message.includes('Expected )')); expect(parenDiag).toBeDefined(); }); @@ -871,7 +873,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const bracketDiag = diagnostics.find(d => d.message.includes('Unclosed bracket')); + // Parser reports unexpected end of input + const bracketDiag = diagnostics.find(d => d.message.includes('Unexpected token')); expect(bracketDiag).toBeDefined(); }); @@ -881,7 +884,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const braceDiag = diagnostics.find(d => d.message.includes('Unclosed brace')); + // Parser reports invalid object definition + const braceDiag = diagnostics.find(d => d.message.includes('invalid object definition')); expect(braceDiag).toBeDefined(); }); @@ -891,7 +895,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const parenDiag = diagnostics.find(d => d.message.includes('Unexpected closing parenthesis')); + // Parser expects EOF but found ) + const parenDiag = diagnostics.find(d => d.message.includes('Expected EOF')); expect(parenDiag).toBeDefined(); }); @@ -901,7 +906,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const bracketDiag = diagnostics.find(d => d.message.includes('Unexpected closing bracket')); + // Parser expects EOF but found ] + const bracketDiag = diagnostics.find(d => d.message.includes('Expected EOF')); expect(bracketDiag).toBeDefined(); }); @@ -911,7 +917,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const braceDiag = diagnostics.find(d => d.message.includes('Unexpected closing brace')); + // Parser expects EOF but found } + const braceDiag = diagnostics.find(d => d.message.includes('Expected EOF')); expect(braceDiag).toBeDefined(); }); @@ -921,7 +928,8 @@ describe('Language Service', () => { const diagnostics = ls.getDiagnostics({ textDocument: doc }); expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const commentDiag = diagnostics.find(d => d.message.includes('Unclosed comment')); + // Parser reports unexpected end of input + const commentDiag = diagnostics.find(d => d.message.includes('Unexpected token')); expect(commentDiag).toBeDefined(); }); @@ -970,15 +978,14 @@ describe('Language Service', () => { expect(diagnostics).toEqual([]); }); - it('should detect multiple syntax errors', () => { + it('should detect syntax errors in complex expressions', () => { const text = '(1 + "unclosed'; const doc = TextDocument.create('file://test', 'plaintext', 1, text); const diagnostics = ls.getDiagnostics({ textDocument: doc }); - // Should detect both unclosed paren and unclosed string - expect(diagnostics.length).toBeGreaterThanOrEqual(2); - expect(diagnostics.some(d => d.message.includes('Unclosed parenthesis'))).toBe(true); - expect(diagnostics.some(d => d.message.includes('Unclosed string'))).toBe(true); + // Parser will report the first error it encounters (unknown character for unclosed string) + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error); }); }); });