diff --git a/docs/language-service.md b/docs/language-service.md index 2758305..6094fd8 100644 --- a/docs/language-service.md +++ b/docs/language-service.md @@ -5,7 +5,12 @@ The library includes a built-in language service that provides IDE-like features ## Features - **Code Completions** - Autocomplete for functions, operators, keywords, and user-defined variables + - **Snippet support** - Function completions include tab stops with parameter placeholders (e.g., `sum(${1:a})`) + - **Path-based variable completions** - Completions for nested object properties (e.g., typing `user.` shows `user.name`, `user.profile.email`) + - **Text edits with ranges** - Proper replacement ranges for more accurate completions - **Hover Information** - Documentation tooltips when hovering over functions and variables + - **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. ## Basic Usage @@ -58,3 +63,301 @@ 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 + +## Advanced Features + +### Nested Variable Completions + +The language service supports path-based completions for nested object properties. When you type a dot after a variable name, you'll get completions for its properties: + +```js +const variables = { + user: { + name: 'Ada', + profile: { + email: 'ada@example.com', + age: 30 + } + }, + config: { + timeout: 5000, + retries: 3 + } +}; + +// Typing "user." will show completions: user.name, user.profile +// Typing "user.profile." will show: user.profile.email, user.profile.age +``` + +**Monaco Editor Integration**: Add `triggerCharacters: ['.']` to your completion provider to automatically trigger completions when typing a dot: + +```js +monaco.languages.registerCompletionItemProvider(languageId, { + triggerCharacters: ['.'], + provideCompletionItems: function (model, position) { + // ... completion logic + } +}); +``` + +### Snippet Support in Completions + +Function completions include snippet support with tab stops for parameters. This provides a better editing experience in editors that support snippets: + +```js +// When completing a function like "sum", the insertText is "sum(${1:a})" +// After selecting the completion: +// 1. The text "sum(a)" is inserted +// 2. The parameter "a" is selected, ready for editing +// 3. You can tab to the next parameter (if any) +``` + +**Monaco Editor Integration**: Use `insertTextRules` with `monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet` when the completion's `insertTextFormat` is 2 (snippet): + +```js +const suggestions = items.map(it => ({ + label: it.label, + kind: mapKind(it.kind), + detail: it.detail, + documentation: it.documentation, + insertText: it.insertText || it.label, + insertTextRules: it.insertTextFormat === 2 + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range +})); +``` + +### Text Edit Ranges + +Completion items may include a `textEdit` with a specific `range` for more precise text replacement. This is especially important for path-based completions where only the partial segment after the last dot should be replaced: + +```js +// When completing "user.na|" (cursor at |), only "na" should be replaced, not "user.na" +// The textEdit.range will specify the exact range to replace +``` + +**Monaco Editor Integration**: Check for `textEdit.range` and use it when available: + +```js +const suggestions = items.map(it => { + const range = it.textEdit?.range + ? new monaco.Range( + it.textEdit.range.start.line + 1, + it.textEdit.range.start.character + 1, + it.textEdit.range.end.line + 1, + it.textEdit.range.end.character + 1 + ) + : defaultRange; + + return { + label: it.label, + insertText: it.textEdit?.newText || it.insertText || it.label, + range + }; +}); +``` + +### Variable Value Previews in Hover + +When hovering over a variable (including nested paths), the hover will display: +- The variable's type +- A truncated JSON preview of its value + +```js +const variables = { + user: { name: 'Ada', score: 95 }, + items: [1, 2, 3, 4, 5] +}; + +// Hovering over "user" shows: +// user: Variable (object) +// Value Preview +// { +// "name": "Ada", +// "score": 95 +// } + +// Hovering over "user.name" shows: +// user.name: Variable (string) +// Value Preview +// "Ada" +``` + +The preview is automatically truncated to prevent overwhelming hovers with large data structures. + +### HoverV2 Type + +The `getHover` method returns a `HoverV2` type, which guarantees that `contents` is a `MarkupContent` object (not a deprecated string or array): + +```typescript +interface HoverV2 extends Hover { + contents: MarkupContent; // Always MarkupContent, never string or array +} +``` + +**Monaco Editor Integration**: The hover contents are always in the `MarkupContent` format: + +```js +const hover = ls.getHover({textDocument: doc, position, variables}); +if (hover && hover.contents) { + // hover.contents is always a MarkupContent object + const value = hover.contents.value; + const kind = hover.contents.kind; // 'plaintext' or 'markdown' + + contents = [{value}]; +} +``` + +## API Reference + +### createLanguageService(options?) + +Creates a new language service instance. + +**Parameters:** +- `options` (optional): `LanguageServiceOptions` - Configuration options for the language service + - `operators`: `Record` - Map of operator names to booleans indicating whether they are allowed + +**Returns:** `LanguageServiceApi` - The language service instance + +**Example:** +```js +import { createLanguageService } from '@pro-fa/expr-eval'; + +const ls = createLanguageService({ + operators: { + '+': true, + '-': true, + '*': true, + '/': true + } +}); +``` + +### ls.getCompletions(params) + +Returns a list of possible completions for the given position in the document. + +**Parameters:** +- `params`: `GetCompletionsParams` + - `textDocument`: `TextDocument` - The text document to analyze + - `position`: `Position` - The cursor position (0-based line and character) + - `variables`: `Values` (optional) - User-defined variables available in the expression + +**Returns:** `CompletionItem[]` - Array of completion items + +**CompletionItem Properties:** +- `label`: `string` - The display label +- `kind`: `CompletionItemKind` - The kind of completion (Function, Variable, Keyword, etc.) +- `detail`: `string` (optional) - Additional details shown in the completion UI +- `documentation`: `string | MarkupContent` (optional) - Documentation for the item +- `insertText`: `string` (optional) - The text to insert (may be a snippet) +- `insertTextFormat`: `InsertTextFormat` (optional) - 1 = PlainText, 2 = Snippet +- `textEdit`: `TextEdit` (optional) - Text edit with specific range and newText + +**Example:** +```js +const completions = ls.getCompletions({ + textDocument: doc, + position: { line: 0, character: 5 }, + variables: { user: { name: 'Ada' }, x: 42 } +}); +``` + +### ls.getHover(params) + +Returns hover information for the given position in the document. + +**Parameters:** +- `params`: `GetHoverParams` + - `textDocument`: `TextDocument` - The text document to analyze + - `position`: `Position` - The cursor position (0-based line and character) + - `variables`: `Values` (optional) - User-defined variables available in the expression + +**Returns:** `HoverV2` - Hover information with guaranteed MarkupContent + +**HoverV2 Properties:** +- `contents`: `MarkupContent` - The hover content + - `kind`: `MarkupKind` - Either 'plaintext' or 'markdown' + - `value`: `string` - The hover text +- `range`: `Range` (optional) - The range of the hovered element + +**Example:** +```js +const hover = ls.getHover({ + textDocument: doc, + position: { line: 0, character: 3 }, + variables: { user: { name: 'Ada' } } +}); + +console.log(hover.contents.value); // The hover text +console.log(hover.contents.kind); // 'markdown' or 'plaintext' +``` + +### ls.getHighlighting(textDocument) + +Returns a list of syntax highlighting tokens for the given text document. + +**Parameters:** +- `textDocument`: `TextDocument` - The text document to analyze + +**Returns:** `HighlightToken[]` - Array of highlighting tokens + +**HighlightToken Properties:** +- `type`: `'number' | 'string' | 'name' | 'keyword' | 'operator' | 'function' | 'punctuation'` +- `start`: `number` - Start offset in the document +- `end`: `number` - End offset in the document +- `value`: `string | number | boolean | undefined` (optional) - The token value + +**Example:** +```js +const tokens = ls.getHighlighting(doc); +tokens.forEach(token => { + console.log(`${token.type} at ${token.start}-${token.end}: ${token.value}`); +}); +``` + +## TypeScript Types + +The library exports the following TypeScript types for use in your applications: + +### Exported Types + +```typescript +import type { + LanguageServiceApi, + HoverV2, + GetCompletionsParams, + GetHoverParams, + HighlightToken, + LanguageServiceOptions +} from '@pro-fa/expr-eval'; +``` + +- **`LanguageServiceApi`** - The main language service interface with `getCompletions`, `getHover`, and `getHighlighting` 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` +- **`HighlightToken`** - Syntax highlighting token with `type`, `start`, `end`, and optional `value` +- **`LanguageServiceOptions`** - Configuration options for creating a language service, including optional `operators` map + +### LSP Types + +The language service uses types from `vscode-languageserver-types` for LSP compatibility: + +```typescript +import type { + Position, + Range, + CompletionItem, + CompletionItemKind, + MarkupContent, + MarkupKind, + InsertTextFormat +} from 'vscode-languageserver-types'; + +import type { TextDocument } from 'vscode-languageserver-textdocument'; +``` + +These types ensure compatibility with Language Server Protocol-based editors and tools. diff --git a/samples/language-service-sample/app.js b/samples/language-service-sample/app.js index c3586d6..09d0b17 100644 --- a/samples/language-service-sample/app.js +++ b/samples/language-service-sample/app.js @@ -101,15 +101,28 @@ require(['vs/editor/editor.main'], function () { // Set initial theme const currentTheme = html.classList.contains('dark') ? 'vs-dark' : 'vs'; - // Default values - const defaultExpression = 'sum([1, 2, 3]) + max(x, y) * multiplier'; + // Default values - showcasing nested path access and deeper objects + const defaultExpression = 'user.profile.score + config.timeout / 1000'; const defaultContext = JSON.stringify({ x: 42, y: 100, multiplier: 2, user: { name: "Ada", - score: 95 + profile: { + email: "ada@example.com", + score: 95, + level: 5 + }, + preferences: { + theme: "dark", + notifications: true + } + }, + config: { + timeout: 5000, + retries: 3, + maxConnections: 10 }, items: [1, 2, 3, 4, 5] }, null, 2); @@ -187,8 +200,9 @@ require(['vs/editor/editor.main'], function () { } } - // Completions provider + // Completions provider with trigger characters and snippet support monaco.languages.registerCompletionItemProvider(languageId, { + triggerCharacters: ['.'], provideCompletionItems: function (model, position) { const doc = makeTextDocument(model); const variables = getContextVariables() || {}; @@ -198,9 +212,6 @@ require(['vs/editor/editor.main'], function () { variables }) || []; - const word = model.getWordUntilPosition(position); - const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); - function mapKind(k) { const map = { 3: monaco.languages.CompletionItemKind.Function, @@ -211,20 +222,41 @@ require(['vs/editor/editor.main'], function () { return map[k] || monaco.languages.CompletionItemKind.Text; } - const suggestions = items.map(it => ({ - label: it.label, - kind: mapKind(it.kind), - detail: it.detail, - documentation: it.documentation, - insertText: it.insertText || it.label, - range - })); + const suggestions = items.map(it => { + // Handle textEdit.range if present + let range; + if (it.textEdit?.range) { + range = new monaco.Range( + it.textEdit.range.start.line + 1, + it.textEdit.range.start.character + 1, + it.textEdit.range.end.line + 1, + it.textEdit.range.end.character + 1 + ); + } else { + // Default range - word at position + const word = model.getWordUntilPosition(position); + range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); + } + + return { + label: it.label, + kind: mapKind(it.kind), + detail: it.detail, + documentation: it.documentation, + insertText: it.textEdit?.newText || it.insertText || it.label, + // Add snippet support when insertTextFormat is 2 + insertTextRules: it.insertTextFormat === 2 + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range + }; + }); return {suggestions}; } }); - // Hover provider + // Hover provider with MarkupContent support monaco.languages.registerHoverProvider(languageId, { provideHover: function (model, position) { const doc = makeTextDocument(model); @@ -232,12 +264,10 @@ require(['vs/editor/editor.main'], function () { const hover = ls.getHover({textDocument: doc, position: toLspPosition(position), variables}); if (!hover || !hover.contents) return {contents: []}; + // HoverV2 always returns MarkupContent format let contents = []; - if (typeof hover.contents === 'string') { - contents = [{value: hover.contents}]; - } else if (hover.contents && typeof hover.contents === 'object') { - const val = hover.contents.value || ''; - contents = [{value: val}]; + if (hover.contents && hover.contents.value) { + contents = [{value: hover.contents.value}]; } let range = undefined;