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'; 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/samples/language-service-sample/app.js b/samples/language-service-sample/app.js index 9b06f7e..bc8426d 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') { @@ -599,6 +623,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 + } } ]; 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.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..01d74b3 100644 --- a/src/language-service/language-service.ts +++ b/src/language-service/language-service.ts @@ -21,10 +21,11 @@ import type { LanguageServiceOptions, GetCompletionsParams, GetHoverParams, + GetDiagnosticsParams, LanguageServiceApi, HoverV2 } from './language-service.types'; -import type { CompletionItem, Range } from 'vscode-languageserver-types'; +import type { CompletionItem, Range, Diagnostic } 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'; @@ -36,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 @@ -295,10 +297,31 @@ 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 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); + } + + return getDiagnosticsForDocument(params, spans, functionNamesSet(), funcDetailsMap); + } + 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([]); + }); }); });