From e0b4f95aa77e65e456256685b48ce20eb906d9cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:50:11 +0000 Subject: [PATCH 1/7] Initial plan From 78381b279074cec650ca0e97c20ef809155e95f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:57:58 +0000 Subject: [PATCH 2/7] Add function argument count validation to language service Added getDiagnostics() method to the language service API that validates function argument counts and returns LSP diagnostics when functions are called with too few or too many arguments. Changes: - Added ArityInfo interface and GetDiagnosticsParams interface - Added arityInfo() method to FunctionDetails class - Implemented getDiagnostics() in createLanguageService() - Exported new types from public API - Added comprehensive tests for the new functionality Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- index.ts | 4 +- .../language-service.models.ts | 36 ++++ src/language-service/language-service.ts | 150 +++++++++++++++- .../language-service.types.ts | 23 ++- test/language-service/language-service.ts | 163 +++++++++++++++++- 5 files changed, 370 insertions(+), 6 deletions(-) diff --git a/index.ts b/index.ts index 72e2e6b..397a07c 100644 --- a/index.ts +++ b/index.ts @@ -43,8 +43,10 @@ export type { HoverV2, GetCompletionsParams, GetHoverParams, + GetDiagnosticsParams, HighlightToken, - LanguageServiceOptions + LanguageServiceOptions, + ArityInfo } from './src/language-service/index.js'; export { createLanguageService, Expression, Parser }; diff --git a/src/language-service/language-service.models.ts b/src/language-service/language-service.models.ts index 273f8dc..ae1d96d 100644 --- a/src/language-service/language-service.models.ts +++ b/src/language-service/language-service.models.ts @@ -1,5 +1,6 @@ import { Parser } from '../parsing/parser'; import { BUILTIN_FUNCTION_DOCS, FunctionDoc } from './language-service.documentation'; +import type { ArityInfo } from './language-service.types'; export class FunctionDetails { private readonly builtInFunctionDoc : FunctionDoc | undefined; @@ -17,6 +18,41 @@ export class FunctionDetails { return typeof f === 'function' ? f.length : undefined; } + /** + * Returns the arity information for this function: + * - min: minimum number of required arguments + * - max: maximum number of arguments, or undefined if variadic + */ + public arityInfo(): ArityInfo | undefined { + if (this.builtInFunctionDoc) { + const params = this.builtInFunctionDoc.params || []; + if (params.length === 0) { + return { min: 0, max: 0 }; + } + + // Check if any parameter is variadic + const hasVariadic = params.some(p => p.isVariadic); + // Count required (non-optional, non-variadic) parameters + const requiredParams = params.filter(p => !p.optional && !p.isVariadic); + const optionalParams = params.filter(p => p.optional && !p.isVariadic); + + const min = requiredParams.length; + // If variadic, max is undefined (unlimited); otherwise, it's all non-variadic params + const max = hasVariadic ? undefined : (requiredParams.length + optionalParams.length); + + return { min, max }; + } + + // For functions without documentation, use the JavaScript function's .length property + const f: unknown = (this.parser.functions && this.parser.functions[this.name]) || (this.parser.unaryOps && this.parser.unaryOps[this.name]); + if (typeof f === 'function') { + // JavaScript's .length gives number of expected arguments (doesn't account for variadic) + return { min: f.length, max: f.length }; + } + + return undefined; + } + public docs() { if (this.builtInFunctionDoc) { const description = this.builtInFunctionDoc.description || ''; diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 3615fa2..078cf4a 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -21,11 +21,12 @@ import type { LanguageServiceOptions, GetCompletionsParams, GetHoverParams, + GetDiagnosticsParams, LanguageServiceApi, HoverV2 } from './language-service.types'; -import type { CompletionItem, Range } from 'vscode-languageserver-types'; -import { CompletionItemKind, MarkupKind, InsertTextFormat } from 'vscode-languageserver-types'; +import type { CompletionItem, Range, Diagnostic } from 'vscode-languageserver-types'; +import { CompletionItemKind, MarkupKind, InsertTextFormat, DiagnosticSeverity } from 'vscode-languageserver-types'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS } from './language-service.documentation'; import { FunctionDetails } from './language-service.models'; @@ -295,10 +296,153 @@ 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. + */ + function getDiagnostics(params: GetDiagnosticsParams): Diagnostic[] { + const { textDocument } = params; + const text = textDocument.getText(); + const diagnostics: Diagnostic[] = []; + + const ts = makeTokenStream(parser, text); + const spans = iterateTokens(ts); + + // Build a map from function name to FunctionDetails for quick lookup + const funcDetailsMap = new Map(); + for (const func of allFunctions()) { + funcDetailsMap.set(func.name, func); + } + + // Find function calls: TNAME followed by TPAREN '(' + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const token = span.token; + + // Check if this is a function name followed by '(' + if (token.type === TNAME && functionNamesSet().has(String(token.value))) { + const funcName = String(token.value); + + // Look for the next token being '(' + if (i + 1 < spans.length && spans[i + 1].token.type === TPAREN && spans[i + 1].token.value === '(') { + const openParenIndex = i + 1; + const openParenSpan = spans[openParenIndex]; + + // Count arguments by tracking parentheses/brackets depth and commas + let argCount = 0; + let parenDepth = 1; + let bracketDepth = 0; + let braceDepth = 0; + let foundClosingParen = false; + let closeParenSpan = openParenSpan; + let hasSeenArgumentToken = false; + + for (let j = openParenIndex + 1; j < spans.length && parenDepth > 0; j++) { + const currentToken = spans[j].token; + + if (currentToken.type === TPAREN) { + if (currentToken.value === '(') { + parenDepth++; + // Opening paren can start an argument (e.g., nested function call) + if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + argCount = 1; + } + } else if (currentToken.value === ')') { + parenDepth--; + if (parenDepth === 0) { + foundClosingParen = true; + closeParenSpan = spans[j]; + } + } + } else if (currentToken.type === TBRACKET) { + if (currentToken.value === '[') { + bracketDepth++; + // Opening bracket starts an argument (array literal) + if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + argCount = 1; + } + } else if (currentToken.value === ']') { + bracketDepth--; + } + } else if (currentToken.type === TBRACE) { + if (currentToken.value === '{') { + braceDepth++; + // Opening brace starts an argument (object literal) + if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + argCount = 1; + } + } else if (currentToken.value === '}') { + braceDepth--; + } + } else if (currentToken.type === TCOMMA && parenDepth === 1 && bracketDepth === 0 && braceDepth === 0) { + // Only count commas at the top level of the function call + argCount++; + hasSeenArgumentToken = false; // Reset for next argument + } else if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { + // First non-comma, non-paren, non-bracket, non-brace token at depth 1 means we have at least one argument + hasSeenArgumentToken = true; + argCount = Math.max(argCount, 1); + } + } + + // If we found a closing paren and there was content, argCount is commas + 1 + // If there were no arguments (empty parens), argCount stays 0 + if (foundClosingParen && argCount > 0) { + // argCount currently holds the count from counting commas + // When we saw first token at depth 1, we set argCount = 1 + // Each comma adds 1 more, so argCount is correct + } + + // Get the function's expected arity + const funcDetails = funcDetailsMap.get(funcName); + if (funcDetails) { + const arityInfo = funcDetails.arityInfo(); + if (arityInfo) { + const { min, max } = arityInfo; + + // Check if argument count is too few + if (argCount < min) { + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(closeParenSpan.end) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Function '${funcName}' expects at least ${min} argument${min !== 1 ? 's' : ''}, but got ${argCount}.`, + source: 'expr-eval' + }); + } + // Check if argument count is too many (only if max is defined, i.e., not variadic) + else if (max !== undefined && argCount > max) { + const range: Range = { + start: textDocument.positionAt(span.start), + end: textDocument.positionAt(closeParenSpan.end) + }; + diagnostics.push({ + range, + severity: DiagnosticSeverity.Error, + message: `Function '${funcName}' expects at most ${max} argument${max !== 1 ? 's' : ''}, but got ${argCount}.`, + source: 'expr-eval' + }); + } + } + } + } + } + } + + return diagnostics; + } + return { getCompletions, getHover, - getHighlighting + getHighlighting, + getDiagnostics }; } diff --git a/src/language-service/language-service.types.ts b/src/language-service/language-service.types.ts index c26208a..e4879d8 100644 --- a/src/language-service/language-service.types.ts +++ b/src/language-service/language-service.types.ts @@ -1,5 +1,5 @@ import type { Values } from '../types'; -import type { Position, Hover, CompletionItem, MarkupContent } from 'vscode-languageserver-types'; +import type { Position, Hover, CompletionItem, MarkupContent, Diagnostic } from 'vscode-languageserver-types'; import type { TextDocument } from 'vscode-languageserver-textdocument'; /** @@ -23,6 +23,13 @@ export interface LanguageServiceApi { * @param textDocument - The text document to analyze */ getHighlighting(textDocument: TextDocument): HighlightToken[]; + + /** + * Returns a list of diagnostics for the given text document. + * This includes errors like incorrect number of function arguments. + * @param params - Parameters for the diagnostics request + */ + getDiagnostics(params: GetDiagnosticsParams): Diagnostic[]; } export interface HighlightToken { @@ -53,3 +60,17 @@ export interface GetHoverParams { export interface HoverV2 extends Hover { contents: MarkupContent; // Type narrowing since we know we are not going to return deprecated content } + +export interface GetDiagnosticsParams { + textDocument: TextDocument; +} + +/** + * Describes the arity (expected number of arguments) for a function. + */ +export interface ArityInfo { + /** Minimum number of required arguments */ + min: number; + /** Maximum number of arguments, or undefined if variadic (unlimited) */ + max: number | undefined; +} diff --git a/test/language-service/language-service.ts b/test/language-service/language-service.ts index c4aa3ac..d920d97 100644 --- a/test/language-service/language-service.ts +++ b/test/language-service/language-service.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { createLanguageService } from '../../src/language-service/language-service'; -import { CompletionItemKind, MarkupKind } from 'vscode-languageserver-types'; +import { CompletionItemKind, MarkupKind, DiagnosticSeverity } from 'vscode-languageserver-types'; function getContentsValue(contents: any): string { if (typeof contents === 'string') { @@ -668,5 +668,166 @@ describe('Language Service', () => { expect(tokens1).toEqual(tokens2); }); + + it('should include getDiagnostics method', () => { + const service = createLanguageService(); + expect(service.getDiagnostics).toBeDefined(); + }); + }); + + describe('getDiagnostics', () => { + it('should return empty array for valid function calls', () => { + const text = 'pow(2, 3)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should return empty array for expressions without function calls', () => { + const text = '1 + 2 * 3'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should detect too few arguments for a function', () => { + const text = 'pow(2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).toContain('pow'); + expect(diagnostics[0].message).toContain('at least 2'); + expect(diagnostics[0].message).toContain('got 1'); + }); + + it('should detect too many arguments for a function', () => { + const text = 'pow(2, 3, 4)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).toContain('pow'); + expect(diagnostics[0].message).toContain('at most 2'); + expect(diagnostics[0].message).toContain('got 3'); + }); + + it('should allow variadic functions with any number of arguments', () => { + // min and max are variadic functions + const text = 'max(1, 2, 3, 4, 5)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should detect multiple errors in a single expression', () => { + const text = 'pow(1) + pow(2, 3, 4)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(2); + expect(diagnostics[0].message).toContain('at least'); + expect(diagnostics[1].message).toContain('at most'); + }); + + it('should handle nested function calls correctly', () => { + const text = 'pow(pow(2, 3), 2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should detect error in nested function call', () => { + const text = 'pow(pow(2), 2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).toContain('pow'); + expect(diagnostics[0].message).toContain('at least 2'); + }); + + it('should handle functions with optional parameters', () => { + // random has one optional parameter + const textNoArg = 'random()'; + const docNoArg = TextDocument.create('file://test', 'plaintext', 1, textNoArg); + const diagnosticsNoArg = ls.getDiagnostics({ textDocument: docNoArg }); + expect(diagnosticsNoArg).toEqual([]); + + const textOneArg = 'random(10)'; + const docOneArg = TextDocument.create('file://test', 'plaintext', 1, textOneArg); + const diagnosticsOneArg = ls.getDiagnostics({ textDocument: docOneArg }); + expect(diagnosticsOneArg).toEqual([]); + }); + + it('should detect too many arguments for functions with optional parameters', () => { + // random(n) has one optional parameter, so random(1, 2) is too many + const text = 'random(1, 2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).toContain('random'); + expect(diagnostics[0].message).toContain('at most 1'); + expect(diagnostics[0].message).toContain('got 2'); + }); + + it('should provide correct diagnostic range', () => { + const text = 'pow(2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + // Range should cover the entire function call including arguments + expect(diagnostics[0].range.start.line).toBe(0); + expect(diagnostics[0].range.start.character).toBe(0); + expect(diagnostics[0].range.end.line).toBe(0); + expect(diagnostics[0].range.end.character).toBe(text.length); + }); + + it('should have correct severity (Error)', () => { + const text = 'pow(2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error); + }); + + it('should have correct source', () => { + const text = 'pow(2)'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].source).toBe('expr-eval'); + }); + + it('should handle empty function calls', () => { + // if function requires 3 arguments + const text = 'if()'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).toContain('if'); + expect(diagnostics[0].message).toContain('at least 3'); + expect(diagnostics[0].message).toContain('got 0'); + }); + + it('should handle function calls with array arguments', () => { + // sum takes one array argument + const text = 'sum([1, 2, 3])'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); + + it('should handle function calls with object arguments', () => { + const text = 'keys({a: 1, b: 2})'; + const doc = TextDocument.create('file://test', 'plaintext', 1, text); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + expect(diagnostics).toEqual([]); + }); }); }); From 717adb1c27a7d708d8f3470d82558dc2e170d290 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:02:09 +0000 Subject: [PATCH 3/7] Fix argument counting in getDiagnostics Fixed the argument counting logic to correctly handle function calls with multiple arguments. The issue was that markArgumentSeen() was incorrectly resetting argCount to 1 even after commas had already been counted. Now it only sets argCount = 1 if this is the first argument (argCount === 0). Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/language-service.ts | 53 +++++++++++++----------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 078cf4a..2902ce0 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -337,6 +337,22 @@ export function createLanguageService(options: LanguageServiceOptions | undefine let closeParenSpan = openParenSpan; let hasSeenArgumentToken = false; + // Helper to check if we're at the top level of the function call + const isAtFunctionCallTopLevel = () => + parenDepth === 1 && bracketDepth === 0 && braceDepth === 0; + + // Helper to mark that we've seen the first token of an argument + const markArgumentSeen = () => { + if (!hasSeenArgumentToken) { + hasSeenArgumentToken = true; + // Only set argCount to 1 if this is the first argument + // (if we've seen commas, argCount is already > 0) + if (argCount === 0) { + argCount = 1; + } + } + }; + for (let j = openParenIndex + 1; j < spans.length && parenDepth > 0; j++) { const currentToken = spans[j].token; @@ -344,9 +360,8 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (currentToken.value === '(') { parenDepth++; // Opening paren can start an argument (e.g., nested function call) - if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - argCount = 1; + if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0) { + markArgumentSeen(); } } else if (currentToken.value === ')') { parenDepth--; @@ -359,9 +374,8 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (currentToken.value === '[') { bracketDepth++; // Opening bracket starts an argument (array literal) - if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - argCount = 1; + if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0) { + markArgumentSeen(); } } else if (currentToken.value === ']') { bracketDepth--; @@ -370,38 +384,29 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (currentToken.value === '{') { braceDepth++; // Opening brace starts an argument (object literal) - if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - argCount = 1; + if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1) { + markArgumentSeen(); } } else if (currentToken.value === '}') { braceDepth--; } - } else if (currentToken.type === TCOMMA && parenDepth === 1 && bracketDepth === 0 && braceDepth === 0) { + } else if (currentToken.type === TCOMMA && isAtFunctionCallTopLevel()) { // Only count commas at the top level of the function call argCount++; hasSeenArgumentToken = false; // Reset for next argument - } else if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { - // First non-comma, non-paren, non-bracket, non-brace token at depth 1 means we have at least one argument - hasSeenArgumentToken = true; - argCount = Math.max(argCount, 1); + } else if (isAtFunctionCallTopLevel()) { + // First token at top level means we have at least one argument + markArgumentSeen(); } } - // If we found a closing paren and there was content, argCount is commas + 1 - // If there were no arguments (empty parens), argCount stays 0 - if (foundClosingParen && argCount > 0) { - // argCount currently holds the count from counting commas - // When we saw first token at depth 1, we set argCount = 1 - // Each comma adds 1 more, so argCount is correct - } - // Get the function's expected arity const funcDetails = funcDetailsMap.get(funcName); if (funcDetails) { const arityInfo = funcDetails.arityInfo(); if (arityInfo) { const { min, max } = arityInfo; + const pluralize = (count: number) => count !== 1 ? 's' : ''; // Check if argument count is too few if (argCount < min) { @@ -412,7 +417,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine diagnostics.push({ range, severity: DiagnosticSeverity.Error, - message: `Function '${funcName}' expects at least ${min} argument${min !== 1 ? 's' : ''}, but got ${argCount}.`, + message: `Function '${funcName}' expects at least ${min} argument${pluralize(min)}, but got ${argCount}.`, source: 'expr-eval' }); } @@ -425,7 +430,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine diagnostics.push({ range, severity: DiagnosticSeverity.Error, - message: `Function '${funcName}' expects at most ${max} argument${max !== 1 ? 's' : ''}, but got ${argCount}.`, + message: `Function '${funcName}' expects at most ${max} argument${pluralize(max)}, but got ${argCount}.`, source: 'expr-eval' }); } From 1df0197e6003c17c2d9d91c36f763e383d450bd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:05:28 +0000 Subject: [PATCH 4/7] Refactor getDiagnostics for improved performance - Moved pluralize helper outside the nested loops to avoid repeated function creation - Simplified the argument tracking by inlining the logic instead of using closures - Converted isAtFunctionCallTopLevel from a closure to a computed variable - Removed unused foundClosingParen variable Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/language-service.ts | 47 ++++++++++-------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index 2902ce0..d0b6115 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -314,6 +314,9 @@ export function createLanguageService(options: LanguageServiceOptions | undefine funcDetailsMap.set(func.name, func); } + // Helper for pluralization (defined once, reused for all diagnostics) + const pluralize = (count: number) => count !== 1 ? 's' : ''; + // Find function calls: TNAME followed by TPAREN '(' for (let i = 0; i < spans.length; i++) { const span = spans[i]; @@ -333,40 +336,26 @@ export function createLanguageService(options: LanguageServiceOptions | undefine let parenDepth = 1; let bracketDepth = 0; let braceDepth = 0; - let foundClosingParen = false; let closeParenSpan = openParenSpan; let hasSeenArgumentToken = false; - // Helper to check if we're at the top level of the function call - const isAtFunctionCallTopLevel = () => - parenDepth === 1 && bracketDepth === 0 && braceDepth === 0; - - // Helper to mark that we've seen the first token of an argument - const markArgumentSeen = () => { - if (!hasSeenArgumentToken) { - hasSeenArgumentToken = true; - // Only set argCount to 1 if this is the first argument - // (if we've seen commas, argCount is already > 0) - if (argCount === 0) { - argCount = 1; - } - } - }; - for (let j = openParenIndex + 1; j < spans.length && parenDepth > 0; j++) { const currentToken = spans[j].token; + // Helper to check if we're at the top level of the function call + const isAtTopLevel = parenDepth === 1 && bracketDepth === 0 && braceDepth === 0; + if (currentToken.type === TPAREN) { if (currentToken.value === '(') { parenDepth++; // Opening paren can start an argument (e.g., nested function call) - if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0) { - markArgumentSeen(); + if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + if (argCount === 0) argCount = 1; } } else if (currentToken.value === ')') { parenDepth--; if (parenDepth === 0) { - foundClosingParen = true; closeParenSpan = spans[j]; } } @@ -374,8 +363,9 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (currentToken.value === '[') { bracketDepth++; // Opening bracket starts an argument (array literal) - if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0) { - markArgumentSeen(); + if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + if (argCount === 0) argCount = 1; } } else if (currentToken.value === ']') { bracketDepth--; @@ -384,19 +374,21 @@ export function createLanguageService(options: LanguageServiceOptions | undefine if (currentToken.value === '{') { braceDepth++; // Opening brace starts an argument (object literal) - if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1) { - markArgumentSeen(); + if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1 && !hasSeenArgumentToken) { + hasSeenArgumentToken = true; + if (argCount === 0) argCount = 1; } } else if (currentToken.value === '}') { braceDepth--; } - } else if (currentToken.type === TCOMMA && isAtFunctionCallTopLevel()) { + } else if (currentToken.type === TCOMMA && isAtTopLevel) { // Only count commas at the top level of the function call argCount++; hasSeenArgumentToken = false; // Reset for next argument - } else if (isAtFunctionCallTopLevel()) { + } else if (isAtTopLevel && !hasSeenArgumentToken) { // First token at top level means we have at least one argument - markArgumentSeen(); + hasSeenArgumentToken = true; + if (argCount === 0) argCount = 1; } } @@ -406,7 +398,6 @@ export function createLanguageService(options: LanguageServiceOptions | undefine const arityInfo = funcDetails.arityInfo(); if (arityInfo) { const { min, max } = arityInfo; - const pluralize = (count: number) => count !== 1 ? 's' : ''; // Check if argument count is too few if (argCount < min) { From 1c797cc0baaaa3572cebb29f6017fa7c0a5ebdd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:13:15 +0000 Subject: [PATCH 5/7] Extract getDiagnostics logic to separate diagnostics.ts file - Created new diagnostics.ts module with extracted diagnostic logic - Broke down complex argument counting into smaller, focused functions: - ArgumentCounterState interface for state management - createArgumentCounterState() for initialization - isAtTopLevel() for depth checking - markArgumentSeen() for argument tracking - processParenToken(), processBracketToken(), processBraceToken() for token handling - processCommaToken(), processOtherToken() for remaining tokens - countFunctionArguments() for argument counting - validateFunctionCall() for validation - createTooFewArgumentsDiagnostic(), createTooManyArgumentsDiagnostic() for diagnostic creation - Simplified getDiagnostics() in language-service.ts to delegate to the new module - Added TokenSpan interface and comprehensive documentation Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- src/language-service/diagnostics.ts | 334 +++++++++++++++++++++++ src/language-service/language-service.ts | 123 +-------- 2 files changed, 337 insertions(+), 120 deletions(-) create mode 100644 src/language-service/diagnostics.ts diff --git a/src/language-service/diagnostics.ts b/src/language-service/diagnostics.ts new file mode 100644 index 0000000..51895b5 --- /dev/null +++ b/src/language-service/diagnostics.ts @@ -0,0 +1,334 @@ +/** + * Diagnostics module for the language service. + * Provides function argument count validation. + */ + +import { + TPAREN, + TBRACKET, + TCOMMA, + TNAME, + TBRACE, + Token +} from '../parsing'; +import type { Diagnostic, Range } from 'vscode-languageserver-types'; +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'; + +/** + * Represents a token with its position in the source text. + */ +export interface TokenSpan { + token: Token; + start: number; + end: number; +} + +/** + * State used while counting function arguments. + */ +interface ArgumentCounterState { + argCount: number; + parenDepth: number; + bracketDepth: number; + braceDepth: number; + hasSeenArgumentToken: boolean; +} + +/** + * Creates the initial state for argument counting. + */ +function createArgumentCounterState(): ArgumentCounterState { + return { + argCount: 0, + parenDepth: 1, + bracketDepth: 0, + braceDepth: 0, + hasSeenArgumentToken: false + }; +} + +/** + * Checks if the counter is at the top level of the function call. + */ +function isAtTopLevel(state: ArgumentCounterState): boolean { + return state.parenDepth === 1 && state.bracketDepth === 0 && state.braceDepth === 0; +} + +/** + * Marks that an argument token has been seen at the current level. + */ +function markArgumentSeen(state: ArgumentCounterState): void { + if (!state.hasSeenArgumentToken) { + state.hasSeenArgumentToken = true; + if (state.argCount === 0) { + state.argCount = 1; + } + } +} + +/** + * Processes a parenthesis token and updates the state accordingly. + * Returns the closing paren span index if found, or -1 otherwise. + */ +function processParenToken( + token: Token, + state: ArgumentCounterState, + spanIndex: number +): number { + if (token.value === '(') { + state.parenDepth++; + // Opening paren can start an argument (e.g., nested function call) + if (state.parenDepth === 2 && state.bracketDepth === 0 && state.braceDepth === 0) { + markArgumentSeen(state); + } + } else if (token.value === ')') { + state.parenDepth--; + if (state.parenDepth === 0) { + return spanIndex; + } + } + return -1; +} + +/** + * Processes a bracket token and updates the state accordingly. + */ +function processBracketToken(token: Token, state: ArgumentCounterState): void { + if (token.value === '[') { + state.bracketDepth++; + // Opening bracket starts an argument (array literal) + if (state.parenDepth === 1 && state.bracketDepth === 1 && state.braceDepth === 0) { + markArgumentSeen(state); + } + } else if (token.value === ']') { + state.bracketDepth--; + } +} + +/** + * Processes a brace token and updates the state accordingly. + */ +function processBraceToken(token: Token, state: ArgumentCounterState): void { + if (token.value === '{') { + state.braceDepth++; + // Opening brace starts an argument (object literal) + if (state.parenDepth === 1 && state.bracketDepth === 0 && state.braceDepth === 1) { + markArgumentSeen(state); + } + } else if (token.value === '}') { + state.braceDepth--; + } +} + +/** + * Processes a comma token and updates the state accordingly. + */ +function processCommaToken(state: ArgumentCounterState): void { + if (isAtTopLevel(state)) { + state.argCount++; + state.hasSeenArgumentToken = false; + } +} + +/** + * Processes any other token and updates the state accordingly. + */ +function processOtherToken(state: ArgumentCounterState): void { + if (isAtTopLevel(state) && !state.hasSeenArgumentToken) { + markArgumentSeen(state); + } +} + +/** + * Result of counting arguments in a function call. + */ +interface ArgumentCountResult { + argCount: number; + closeParenSpanIndex: number; +} + +/** + * Counts the number of arguments in a function call starting from the opening parenthesis. + */ +function countFunctionArguments( + spans: TokenSpan[], + openParenIndex: number +): ArgumentCountResult { + const state = createArgumentCounterState(); + let closeParenSpanIndex = openParenIndex; + + for (let j = openParenIndex + 1; j < spans.length && state.parenDepth > 0; j++) { + const currentToken = spans[j].token; + + if (currentToken.type === TPAREN) { + const result = processParenToken(currentToken, state, j); + if (result !== -1) { + closeParenSpanIndex = result; + } + } else if (currentToken.type === TBRACKET) { + processBracketToken(currentToken, state); + } else if (currentToken.type === TBRACE) { + processBraceToken(currentToken, state); + } else if (currentToken.type === TCOMMA) { + processCommaToken(state); + } else { + processOtherToken(state); + } + } + + return { + argCount: state.argCount, + closeParenSpanIndex + }; +} + +/** + * Helper for pluralization of argument/arguments. + */ +function pluralize(count: number): string { + return count !== 1 ? 's' : ''; +} + +/** + * Creates a diagnostic for a function with too few arguments. + */ +function createTooFewArgumentsDiagnostic( + textDocument: TextDocument, + funcName: string, + min: number, + argCount: number, + startOffset: number, + endOffset: number +): Diagnostic { + const range: Range = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset) + }; + return { + range, + severity: DiagnosticSeverity.Error, + message: `Function '${funcName}' expects at least ${min} argument${pluralize(min)}, but got ${argCount}.`, + source: 'expr-eval' + }; +} + +/** + * Creates a diagnostic for a function with too many arguments. + */ +function createTooManyArgumentsDiagnostic( + textDocument: TextDocument, + funcName: string, + max: number, + argCount: number, + startOffset: number, + endOffset: number +): Diagnostic { + const range: Range = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset) + }; + return { + range, + severity: DiagnosticSeverity.Error, + message: `Function '${funcName}' expects at most ${max} argument${pluralize(max)}, but got ${argCount}.`, + source: 'expr-eval' + }; +} + +/** + * Validates the argument count for a function call and returns a diagnostic if invalid. + */ +function validateFunctionCall( + textDocument: TextDocument, + funcName: string, + arityInfo: ArityInfo, + argCount: number, + startOffset: number, + endOffset: number +): Diagnostic | null { + const { min, max } = arityInfo; + + if (argCount < min) { + return createTooFewArgumentsDiagnostic( + textDocument, funcName, min, argCount, startOffset, endOffset + ); + } + + if (max !== undefined && argCount > max) { + return createTooManyArgumentsDiagnostic( + textDocument, funcName, max, argCount, startOffset, endOffset + ); + } + + return null; +} + +/** + * 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. + */ +export function getDiagnosticsForDocument( + params: GetDiagnosticsParams, + spans: TokenSpan[], + functionNames: Set, + funcDetailsMap: Map +): Diagnostic[] { + const { textDocument } = params; + const diagnostics: Diagnostic[] = []; + + // Find function calls: TNAME followed by TPAREN '(' + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const token = span.token; + + // Check if this is a function name followed by '(' + if (token.type !== TNAME || !functionNames.has(String(token.value))) { + continue; + } + + const funcName = String(token.value); + + // Look for the next token being '(' + if (i + 1 >= spans.length || + spans[i + 1].token.type !== TPAREN || + spans[i + 1].token.value !== '(') { + continue; + } + + const openParenIndex = i + 1; + + // Count arguments + const { argCount, closeParenSpanIndex } = countFunctionArguments(spans, openParenIndex); + const closeParenSpan = spans[closeParenSpanIndex]; + + // Get the function's expected arity + const funcDetails = funcDetailsMap.get(funcName); + if (!funcDetails) { + continue; + } + + const arityInfo = funcDetails.arityInfo(); + if (!arityInfo) { + continue; + } + + // Validate and create diagnostic if needed + const diagnostic = validateFunctionCall( + textDocument, + funcName, + arityInfo, + argCount, + span.start, + closeParenSpan.end + ); + + if (diagnostic) { + diagnostics.push(diagnostic); + } + } + + return diagnostics; +} diff --git a/src/language-service/language-service.ts b/src/language-service/language-service.ts index d0b6115..01d74b3 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -26,7 +26,7 @@ import type { HoverV2 } from './language-service.types'; import type { CompletionItem, Range, Diagnostic } from 'vscode-languageserver-types'; -import { CompletionItemKind, MarkupKind, InsertTextFormat, DiagnosticSeverity } from 'vscode-languageserver-types'; +import { CompletionItemKind, MarkupKind, InsertTextFormat } from 'vscode-languageserver-types'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { BUILTIN_KEYWORD_DOCS, DEFAULT_CONSTANT_DOCS } from './language-service.documentation'; import { FunctionDetails } from './language-service.models'; @@ -37,6 +37,7 @@ import { iterateTokens } from './ls-utils'; import { pathVariableCompletions, tryVariableHoverUsingSpans } from './variable-utils'; +import { getDiagnosticsForDocument } from './diagnostics'; export function createLanguageService(options: LanguageServiceOptions | undefined = undefined): LanguageServiceApi { // Build a parser instance to access keywords/operators/functions/consts @@ -303,7 +304,6 @@ export function createLanguageService(options: LanguageServiceOptions | undefine function getDiagnostics(params: GetDiagnosticsParams): Diagnostic[] { const { textDocument } = params; const text = textDocument.getText(); - const diagnostics: Diagnostic[] = []; const ts = makeTokenStream(parser, text); const spans = iterateTokens(ts); @@ -314,124 +314,7 @@ export function createLanguageService(options: LanguageServiceOptions | undefine funcDetailsMap.set(func.name, func); } - // Helper for pluralization (defined once, reused for all diagnostics) - const pluralize = (count: number) => count !== 1 ? 's' : ''; - - // Find function calls: TNAME followed by TPAREN '(' - for (let i = 0; i < spans.length; i++) { - const span = spans[i]; - const token = span.token; - - // Check if this is a function name followed by '(' - if (token.type === TNAME && functionNamesSet().has(String(token.value))) { - const funcName = String(token.value); - - // Look for the next token being '(' - if (i + 1 < spans.length && spans[i + 1].token.type === TPAREN && spans[i + 1].token.value === '(') { - const openParenIndex = i + 1; - const openParenSpan = spans[openParenIndex]; - - // Count arguments by tracking parentheses/brackets depth and commas - let argCount = 0; - let parenDepth = 1; - let bracketDepth = 0; - let braceDepth = 0; - let closeParenSpan = openParenSpan; - let hasSeenArgumentToken = false; - - for (let j = openParenIndex + 1; j < spans.length && parenDepth > 0; j++) { - const currentToken = spans[j].token; - - // Helper to check if we're at the top level of the function call - const isAtTopLevel = parenDepth === 1 && bracketDepth === 0 && braceDepth === 0; - - if (currentToken.type === TPAREN) { - if (currentToken.value === '(') { - parenDepth++; - // Opening paren can start an argument (e.g., nested function call) - if (parenDepth === 2 && bracketDepth === 0 && braceDepth === 0 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - if (argCount === 0) argCount = 1; - } - } else if (currentToken.value === ')') { - parenDepth--; - if (parenDepth === 0) { - closeParenSpan = spans[j]; - } - } - } else if (currentToken.type === TBRACKET) { - if (currentToken.value === '[') { - bracketDepth++; - // Opening bracket starts an argument (array literal) - if (parenDepth === 1 && bracketDepth === 1 && braceDepth === 0 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - if (argCount === 0) argCount = 1; - } - } else if (currentToken.value === ']') { - bracketDepth--; - } - } else if (currentToken.type === TBRACE) { - if (currentToken.value === '{') { - braceDepth++; - // Opening brace starts an argument (object literal) - if (parenDepth === 1 && bracketDepth === 0 && braceDepth === 1 && !hasSeenArgumentToken) { - hasSeenArgumentToken = true; - if (argCount === 0) argCount = 1; - } - } else if (currentToken.value === '}') { - braceDepth--; - } - } else if (currentToken.type === TCOMMA && isAtTopLevel) { - // Only count commas at the top level of the function call - argCount++; - hasSeenArgumentToken = false; // Reset for next argument - } else if (isAtTopLevel && !hasSeenArgumentToken) { - // First token at top level means we have at least one argument - hasSeenArgumentToken = true; - if (argCount === 0) argCount = 1; - } - } - - // Get the function's expected arity - const funcDetails = funcDetailsMap.get(funcName); - if (funcDetails) { - const arityInfo = funcDetails.arityInfo(); - if (arityInfo) { - const { min, max } = arityInfo; - - // Check if argument count is too few - if (argCount < min) { - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(closeParenSpan.end) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Function '${funcName}' expects at least ${min} argument${pluralize(min)}, but got ${argCount}.`, - source: 'expr-eval' - }); - } - // Check if argument count is too many (only if max is defined, i.e., not variadic) - else if (max !== undefined && argCount > max) { - const range: Range = { - start: textDocument.positionAt(span.start), - end: textDocument.positionAt(closeParenSpan.end) - }; - diagnostics.push({ - range, - severity: DiagnosticSeverity.Error, - message: `Function '${funcName}' expects at most ${max} argument${pluralize(max)}, but got ${argCount}.`, - source: 'expr-eval' - }); - } - } - } - } - } - } - - return diagnostics; + return getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap); } return { From 7d5a2a8651ae3e4ba8e7f6cef19a784792ca5890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:18:55 +0000 Subject: [PATCH 6/7] Integrate diagnostics into playground - Added applyDiagnostics() function to app.js that calls ls.getDiagnostics() and displays error markers in Monaco editor using setModelMarkers - Call applyDiagnostics() on initialization and on expression changes - Added "Diagnostics Demo" example to examples.js that demonstrates function argument count validation with pow(2) and random(1, 2, 3) Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- samples/language-service-sample/app.js | 26 +++++++++++++++++++++ samples/language-service-sample/examples.js | 10 ++++++++ 2 files changed, 36 insertions(+) diff --git a/samples/language-service-sample/app.js b/samples/language-service-sample/app.js index 64b8698..3955d48 100644 --- a/samples/language-service-sample/app.js +++ b/samples/language-service-sample/app.js @@ -417,6 +417,30 @@ require(['vs/editor/editor.main'], function () { highlightDecorations = expressionEditor.deltaDecorations(highlightDecorations, decorations); } + // Diagnostics - show function argument count errors + function applyDiagnostics() { + const doc = makeTextDocument(expressionModel); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + // Convert LSP diagnostics to Monaco markers + const markers = diagnostics.map(d => { + const startPos = fromLspPosition(d.range.start); + const endPos = fromLspPosition(d.range.end); + return { + severity: monaco.MarkerSeverity.Error, + message: d.message, + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + source: d.source || 'expr-eval' + }; + }); + + // Set markers on the model + monaco.editor.setModelMarkers(expressionModel, 'expr-eval', markers); + } + // Syntax highlight JSON function syntaxHighlightJson(json) { if (typeof json !== 'string') { @@ -598,6 +622,7 @@ require(['vs/editor/editor.main'], function () { // Initialize applyHighlighting(); + applyDiagnostics(); evaluate(); // Load example from URL query parameter if present @@ -606,6 +631,7 @@ require(['vs/editor/editor.main'], function () { // Event listeners for changes expressionModel.onDidChangeContent(() => { applyHighlighting(); + applyDiagnostics(); evaluate(); }); diff --git a/samples/language-service-sample/examples.js b/samples/language-service-sample/examples.js index 9161b7b..d0c30da 100644 --- a/samples/language-service-sample/examples.js +++ b/samples/language-service-sample/examples.js @@ -93,5 +93,15 @@ const exampleCases = [ {"rowId": 3, "state": "unchanged", "data": { "InventoryId": 9362, "Description": "Wood", "Weight": { "Unit": "kg", "Amount": 18 } }} ] } + }, + { + id: 'diagnostics-demo', + title: 'Diagnostics Demo', + description: 'Shows error highlighting for incorrect function arguments', + expression: '// Try functions with wrong argument counts:\n// pow() needs 2 args, random() needs 0-1 args\npow(2) + random(1, 2, 3)', + context: { + x: 5, + y: 10 + } } ]; From 1705043493846a3705f5ebe888b0002e4bdf6360 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:30:46 +0000 Subject: [PATCH 7/7] Update language service documentation with getDiagnostics API - Added Diagnostics feature to the Features section - Added getDiagnostics() to Basic Usage example - Updated Monaco Editor sample description to mention diagnostics demo - Added complete ls.getDiagnostics() API reference section - Added Monaco Editor integration example for diagnostics - Updated Exported Types to include GetDiagnosticsParams and ArityInfo - Updated LSP Types to include Diagnostic and DiagnosticSeverity Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --- docs/language-service.md | 69 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/language-service.md b/docs/language-service.md index 0954c44..ac6440a 100644 --- a/docs/language-service.md +++ b/docs/language-service.md @@ -12,6 +12,10 @@ The library includes a built-in language service that provides IDE-like features - **Variable value previews** - Hovers on variables show a truncated JSON preview of the value - **Nested path support** - Hovering over `user.name` resolves and shows the value at that path - **Syntax Highlighting** - Token-based highlighting for numbers, strings, keywords, operators, etc. +- **Diagnostics** - Error detection for function argument count validation + - **Too few arguments** - Reports when a function is called with fewer arguments than required (e.g., `pow(2)` needs 2 arguments) + - **Too many arguments** - Reports when a function is called with more arguments than allowed (e.g., `random(1, 2, 3)` accepts at most 1) + - **Variadic functions** - Correctly handles functions that accept unlimited arguments (e.g., `min`, `max`, `coalesce`) ## Basic Usage @@ -39,6 +43,9 @@ const hover = ls.getHover({ // Get syntax highlighting tokens const tokens = ls.getHighlighting(doc); + +// Get diagnostics (function argument count errors) +const diagnostics = ls.getDiagnostics({ textDocument: doc }); ``` ## Monaco Editor Integration Sample @@ -56,6 +63,7 @@ Then open http://localhost:8080 in your browser. The sample demonstrates: - Hover documentation for functions and variables - Live syntax highlighting - Real-time expression evaluation +- **Diagnostics** - Red squiggly underlines for function argument count errors (select the "Diagnostics Demo" example to see this in action) The sample code is located in `samples/language-service-sample/` and shows how to: @@ -63,6 +71,7 @@ The sample code is located in `samples/language-service-sample/` and shows how t 2. Connect the language service to Monaco's completion and hover providers 3. Apply syntax highlighting using decorations 4. Create an LSP-compatible text document wrapper for Monaco models +5. Display diagnostics using Monaco's `setModelMarkers` API ## Advanced Features @@ -318,6 +327,54 @@ tokens.forEach(token => { }); ``` +### ls.getDiagnostics(params) + +Returns a list of diagnostics for the given text document. Currently validates function argument counts. + +**Parameters:** +- `params`: `GetDiagnosticsParams` + - `textDocument`: `TextDocument` - The text document to analyze + +**Returns:** `Diagnostic[]` - Array of LSP-compatible diagnostic objects + +**Diagnostic Properties:** +- `range`: `Range` - The range of the problematic function call +- `severity`: `DiagnosticSeverity` - The severity level (Error) +- `message`: `string` - Human-readable description of the issue +- `source`: `string` - Always `'expr-eval'` + +**Example:** +```js +const diagnostics = ls.getDiagnostics({ textDocument: doc }); +diagnostics.forEach(d => { + console.log(`${d.message} at line ${d.range.start.line}`); +}); + +// For expression "pow(2) + random(1, 2, 3)": +// "Function 'pow' expects at least 2 arguments, but got 1." at line 0 +// "Function 'random' expects at most 1 argument, but got 3." at line 0 +``` + +**Monaco Editor Integration:** +```js +function applyDiagnostics() { + const doc = makeTextDocument(model); + const diagnostics = ls.getDiagnostics({ textDocument: doc }); + + const markers = diagnostics.map(d => ({ + severity: monaco.MarkerSeverity.Error, + message: d.message, + startLineNumber: d.range.start.line + 1, + startColumn: d.range.start.character + 1, + endLineNumber: d.range.end.line + 1, + endColumn: d.range.end.character + 1, + source: d.source + })); + + monaco.editor.setModelMarkers(model, 'expr-eval', markers); +} +``` + ## TypeScript Types The library exports the following TypeScript types for use in your applications: @@ -330,17 +387,21 @@ import type { HoverV2, GetCompletionsParams, GetHoverParams, + GetDiagnosticsParams, HighlightToken, - LanguageServiceOptions + LanguageServiceOptions, + ArityInfo } from '@pro-fa/expr-eval'; ``` -- **`LanguageServiceApi`** - The main language service interface with `getCompletions`, `getHover`, and `getHighlighting` methods +- **`LanguageServiceApi`** - The main language service interface with `getCompletions`, `getHover`, `getHighlighting`, and `getDiagnostics` methods - **`HoverV2`** - Extended Hover type with guaranteed `MarkupContent` for contents (not deprecated string/array formats) - **`GetCompletionsParams`** - Parameters for `getCompletions`: `textDocument`, `position`, and optional `variables` - **`GetHoverParams`** - Parameters for `getHover`: `textDocument`, `position`, and optional `variables` +- **`GetDiagnosticsParams`** - Parameters for `getDiagnostics`: `textDocument` - **`HighlightToken`** - Syntax highlighting token with `type`, `start`, `end`, and optional `value` - **`LanguageServiceOptions`** - Configuration options for creating a language service, including optional `operators` map +- **`ArityInfo`** - Describes a function's expected argument count with `min` and optional `max` (undefined for variadic functions) ### LSP Types @@ -354,7 +415,9 @@ import type { CompletionItemKind, MarkupContent, MarkupKind, - InsertTextFormat + InsertTextFormat, + Diagnostic, + DiagnosticSeverity } from 'vscode-languageserver-types'; import type { TextDocument } from 'vscode-languageserver-textdocument';