diff --git a/packages/monaco-json-interpolation/src/index.ts b/packages/monaco-json-interpolation/src/index.ts index b8d59c9f..43a06bea 100644 --- a/packages/monaco-json-interpolation/src/index.ts +++ b/packages/monaco-json-interpolation/src/index.ts @@ -1,6 +1,7 @@ /*--------------------------------------------------------------------------------------------- * Monaco JSON Interpolation * Standalone add-on for Monaco Editor providing JSON with ${...} variable interpolation + * Integrates with Monaco's built-in JSON language service for full functionality *--------------------------------------------------------------------------------------------*/ import * as monaco from 'monaco-editor'; @@ -94,7 +95,11 @@ class LanguageServiceDefaultsImpl implements LanguageServiceDefaults { documentSymbols: true, tokens: true, foldingRanges: true, - diagnostics: true + diagnostics: true, + documentFormattingEdits: true, + documentRangeFormattingEdits: true, + selectionRanges: true, + colors: true }; } @@ -134,174 +139,630 @@ class LanguageServiceDefaultsImpl implements LanguageServiceDefaults { } } +// --- Helper to get JSON worker --- + +type JSONWorker = { + doComplete(uri: string, position: { lineNumber: number; column: number }): Promise; + doHover(uri: string, position: { line: number; character: number }): Promise<{ contents: { kind: string; value: string }[]; range?: any } | null>; + doValidation(uri: string): Promise; + format(uri: string, range: any, options: any): Promise; + findDocumentSymbols(uri: string): Promise; + getFoldingRanges(uri: string): Promise; + getSelectionRanges(uri: string, positions: any[]): Promise; + findDocumentColors(uri: string): Promise; + getColorPresentations(uri: string, color: any, range: any): Promise; +}; + +async function getJSONWorker(resource: monaco.Uri): Promise { + // Access Monaco's built-in JSON language service + const json = (monaco.languages as any).json; + if (!json || !json.getWorker) { + console.warn('monaco-json-interpolation: JSON language service not available'); + return null; + } + + try { + const getWorker = await json.getWorker(); + const worker = await getWorker(resource); + return worker; + } catch (e) { + console.warn('monaco-json-interpolation: Failed to get JSON worker', e); + return null; + } +} + +// --- Check if position is inside interpolation --- + +function isInsideInterpolation(model: monaco.editor.ITextModel, position: monaco.Position): boolean { + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }); + + const lastInterpolationStart = textUntilPosition.lastIndexOf('${'); + if (lastInterpolationStart === -1) { + return false; + } + + const afterInterpolationStart = textUntilPosition.substring(lastInterpolationStart); + return !afterInterpolationStart.includes('}'); +} + // --- Providers --- function registerProviders(defaults: LanguageServiceDefaultsImpl): void { - // Variable completion provider + // Completion provider - combines JSON completions with variable completions monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, { - triggerCharacters: ['$', '{'], + triggerCharacters: ['"', ':', ' ', '$', '{'], async provideCompletionItems( model: monaco.editor.ITextModel, - position: monaco.Position + position: monaco.Position, + context: monaco.languages.CompletionContext ): Promise { - const variableContext = defaults.variableContext; - if (!variableContext) { + // Check if we're inside an interpolation + if (isInsideInterpolation(model, position)) { + // Provide variable completions + const variableContext = defaults.variableContext; + if (variableContext) { + const variables = await variableContext.getVariables(); + const wordInfo = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + startColumn: wordInfo.startColumn, + endLineNumber: position.lineNumber, + endColumn: wordInfo.endColumn + }; + + const suggestions: monaco.languages.CompletionItem[] = variables.map((variable) => ({ + label: variable.name, + kind: monaco.languages.CompletionItemKind.Variable, + detail: variable.detail || variable.type, + documentation: formatDocumentation(variable), + insertText: variable.name, + range: range + })); + + return { suggestions }; + } return null; } - // Check if we're inside an interpolation ${...} - const textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column - }); - - const lastInterpolationStart = textUntilPosition.lastIndexOf('${'); - if (lastInterpolationStart === -1) { + // Get JSON completions from the worker + const worker = await getJSONWorker(model.uri); + if (!worker) { return null; } - const afterInterpolationStart = textUntilPosition.substring(lastInterpolationStart); - if (afterInterpolationStart.includes('}')) { + try { + const info = await worker.doComplete(model.uri.toString(), { + lineNumber: position.lineNumber, + column: position.column + }); + + if (!info) { + return null; + } + + // Convert LSP completions to Monaco completions + const suggestions: monaco.languages.CompletionItem[] = (info.items || []).map((item: any) => ({ + label: item.label, + kind: convertCompletionItemKind(item.kind), + detail: item.detail, + documentation: item.documentation, + insertText: item.insertText || item.label, + insertTextRules: item.insertTextFormat === 2 + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range: item.textEdit?.range ? convertRange(item.textEdit.range) : undefined, + sortText: item.sortText, + filterText: item.filterText + })); + + return { suggestions, incomplete: info.isIncomplete || false }; + } catch (e) { + console.error('JSON completion error:', e); return null; } - - const variables = await variableContext.getVariables(); - const wordInfo = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - startColumn: wordInfo.startColumn, - endLineNumber: position.lineNumber, - endColumn: wordInfo.endColumn - }; - - const suggestions: monaco.languages.CompletionItem[] = variables.map((variable) => ({ - label: variable.name, - kind: monaco.languages.CompletionItemKind.Variable, - detail: variable.detail || variable.type, - documentation: formatDocumentation(variable), - insertText: variable.name, - range: range - })); - - return { suggestions }; } }); - // Variable hover provider + // Hover provider - combines JSON hover with variable hover monaco.languages.registerHoverProvider(LANGUAGE_ID, { async provideHover( model: monaco.editor.ITextModel, position: monaco.Position ): Promise { - const variableContext = defaults.variableContext; - if (!variableContext) { - return null; - } + // Check if we're inside an interpolation + if (isInsideInterpolation(model, position)) { + const variableContext = defaults.variableContext; + if (variableContext) { + const wordInfo = model.getWordAtPosition(position); + if (wordInfo) { + const variables = await variableContext.getVariables(); + const variable = variables.find((v) => v.name === wordInfo.word); - const line = model.getLineContent(position.lineNumber); - const offset = position.column - 1; + if (variable) { + const contents: monaco.IMarkdownString[] = []; - // Find interpolation boundaries - let inInterpolation = false; - let interpStart = -1; + if (variable.type) { + contents.push({ + value: `\`\`\`typescript\n(variable) ${variable.name}: ${variable.type}\n\`\`\`` + }); + } else { + contents.push({ + value: `\`\`\`typescript\n(variable) ${variable.name}\n\`\`\`` + }); + } - for (let i = 0; i < line.length - 1; i++) { - if (line[i] === '$' && line[i + 1] === '{') { - if (i < offset) { - inInterpolation = true; - interpStart = i + 2; - } - } else if (line[i] === '}' && inInterpolation) { - if (i >= offset) { - break; - } - inInterpolation = false; - } - } + if (variable.description) { + contents.push({ value: variable.description }); + } - if (!inInterpolation || interpStart === -1) { - return null; - } + if (variable.value !== undefined) { + contents.push({ + value: `**Current value:**\n\`\`\`json\n${JSON.stringify(variable.value, null, 2)}\n\`\`\`` + }); + } - const wordInfo = model.getWordAtPosition(position); - if (!wordInfo) { - return null; - } - - const variableName = wordInfo.word; - const variables = await variableContext.getVariables(); - const variable = variables.find((v) => v.name === variableName); - - if (!variable) { - return null; - } - - const contents: monaco.IMarkdownString[] = []; - - if (variable.type) { - contents.push({ - value: `\`\`\`typescript\n(variable) ${variable.name}: ${variable.type}\n\`\`\`` - }); - } else { - contents.push({ - value: `\`\`\`typescript\n(variable) ${variable.name}\n\`\`\`` - }); - } - - if (variable.description) { - contents.push({ value: variable.description }); - } - - if (variable.value !== undefined) { - contents.push({ - value: `**Current value:**\n\`\`\`json\n${JSON.stringify(variable.value, null, 2)}\n\`\`\`` - }); - } - - return { - contents, - range: { - startLineNumber: position.lineNumber, - startColumn: wordInfo.startColumn, - endLineNumber: position.lineNumber, - endColumn: wordInfo.endColumn - } - }; - } - }); - - // Folding range provider (basic JSON-like folding) - monaco.languages.registerFoldingRangeProvider(LANGUAGE_ID, { - provideFoldingRanges( - model: monaco.editor.ITextModel - ): monaco.languages.FoldingRange[] { - const ranges: monaco.languages.FoldingRange[] = []; - const stack: { char: string; line: number }[] = []; - - for (let i = 1; i <= model.getLineCount(); i++) { - const line = model.getLineContent(i); - for (const char of line) { - if (char === '{' || char === '[') { - stack.push({ char, line: i }); - } else if (char === '}' || char === ']') { - const open = stack.pop(); - if (open && open.line < i) { - ranges.push({ - start: open.line, - end: i, - kind: monaco.languages.FoldingRangeKind.Region - }); + return { + contents, + range: { + startLineNumber: position.lineNumber, + startColumn: wordInfo.startColumn, + endLineNumber: position.lineNumber, + endColumn: wordInfo.endColumn + } + }; } } } + return null; } - return ranges; + // Get JSON hover from the worker + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const info = await worker.doHover(model.uri.toString(), { + line: position.lineNumber - 1, + character: position.column - 1 + }); + + if (!info) { + return null; + } + + const contents: monaco.IMarkdownString[] = info.contents.map((c: any) => { + if (typeof c === 'string') { + return { value: c }; + } + return { value: c.value || '' }; + }); + + return { + contents, + range: info.range ? convertRange(info.range) : undefined + }; + } catch (e) { + console.error('JSON hover error:', e); + return null; + } } }); + + // Diagnostics adapter + setupDiagnostics(defaults); + + // Document formatting provider + monaco.languages.registerDocumentFormattingEditProvider(LANGUAGE_ID, { + async provideDocumentFormattingEdits( + model: monaco.editor.ITextModel, + options: monaco.languages.FormattingOptions + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const edits = await worker.format(model.uri.toString(), null, { + tabSize: options.tabSize, + insertSpaces: options.insertSpaces + }); + + return edits.map((edit: any) => ({ + range: convertRange(edit.range), + text: edit.newText + })); + } catch (e) { + console.error('JSON formatting error:', e); + return null; + } + } + }); + + // Document range formatting provider + monaco.languages.registerDocumentRangeFormattingEditProvider(LANGUAGE_ID, { + async provideDocumentRangeFormattingEdits( + model: monaco.editor.ITextModel, + range: monaco.Range, + options: monaco.languages.FormattingOptions + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const edits = await worker.format( + model.uri.toString(), + { + start: { line: range.startLineNumber - 1, character: range.startColumn - 1 }, + end: { line: range.endLineNumber - 1, character: range.endColumn - 1 } + }, + { + tabSize: options.tabSize, + insertSpaces: options.insertSpaces + } + ); + + return edits.map((edit: any) => ({ + range: convertRange(edit.range), + text: edit.newText + })); + } catch (e) { + console.error('JSON range formatting error:', e); + return null; + } + } + }); + + // Document symbols provider + monaco.languages.registerDocumentSymbolProvider(LANGUAGE_ID, { + async provideDocumentSymbols( + model: monaco.editor.ITextModel + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const symbols = await worker.findDocumentSymbols(model.uri.toString()); + return symbols.map(convertSymbol); + } catch (e) { + console.error('JSON symbols error:', e); + return null; + } + } + }); + + // Folding range provider + monaco.languages.registerFoldingRangeProvider(LANGUAGE_ID, { + async provideFoldingRanges( + model: monaco.editor.ITextModel + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const ranges = await worker.getFoldingRanges(model.uri.toString()); + return ranges.map((range: any) => ({ + start: range.startLine + 1, + end: range.endLine + 1, + kind: range.kind + })); + } catch (e) { + console.error('JSON folding error:', e); + return null; + } + } + }); + + // Selection range provider + monaco.languages.registerSelectionRangeProvider(LANGUAGE_ID, { + async provideSelectionRanges( + model: monaco.editor.ITextModel, + positions: monaco.Position[] + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const lspPositions = positions.map((p) => ({ + line: p.lineNumber - 1, + character: p.column - 1 + })); + + const ranges = await worker.getSelectionRanges(model.uri.toString(), lspPositions); + return ranges.map((rangeList: any) => { + const result: monaco.languages.SelectionRange[] = []; + let current = rangeList; + while (current) { + result.push({ + range: convertRange(current.range) + }); + current = current.parent; + } + return result; + }); + } catch (e) { + console.error('JSON selection range error:', e); + return null; + } + } + }); + + // Color provider + monaco.languages.registerColorProvider(LANGUAGE_ID, { + async provideDocumentColors( + model: monaco.editor.ITextModel + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const colors = await worker.findDocumentColors(model.uri.toString()); + return colors.map((info: any) => ({ + range: convertRange(info.range), + color: info.color + })); + } catch (e) { + console.error('JSON colors error:', e); + return null; + } + }, + + async provideColorPresentations( + model: monaco.editor.ITextModel, + colorInfo: monaco.languages.IColorInformation + ): Promise { + const worker = await getJSONWorker(model.uri); + if (!worker) { + return null; + } + + try { + const presentations = await worker.getColorPresentations( + model.uri.toString(), + colorInfo.color, + { + start: { + line: colorInfo.range.startLineNumber - 1, + character: colorInfo.range.startColumn - 1 + }, + end: { + line: colorInfo.range.endLineNumber - 1, + character: colorInfo.range.endColumn - 1 + } + } + ); + + return presentations.map((p: any) => ({ + label: p.label, + textEdit: p.textEdit + ? { + range: convertRange(p.textEdit.range), + text: p.textEdit.newText + } + : undefined + })); + } catch (e) { + console.error('JSON color presentations error:', e); + return null; + } + } + }); +} + +// --- Diagnostics --- + +function setupDiagnostics(defaults: LanguageServiceDefaultsImpl): void { + const listeners: { [uri: string]: monaco.IDisposable } = {}; + + const doValidate = async (model: monaco.editor.ITextModel) => { + if (model.getLanguageId() !== LANGUAGE_ID) { + return; + } + + const worker = await getJSONWorker(model.uri); + if (!worker) { + return; + } + + try { + const diagnostics = await worker.doValidation(model.uri.toString()); + + // Filter out diagnostics that overlap with interpolations + const text = model.getValue(); + const filteredDiagnostics = diagnostics.filter((diag: any) => { + const startOffset = model.getOffsetAt({ + lineNumber: diag.range.start.line + 1, + column: diag.range.start.character + 1 + }); + const endOffset = model.getOffsetAt({ + lineNumber: diag.range.end.line + 1, + column: diag.range.end.character + 1 + }); + + // Check for overlap with interpolations + const interpolationRegex = /\$\{[^}]*\}/g; + let match; + + while ((match = interpolationRegex.exec(text)) !== null) { + const interpStart = match.index; + const interpEnd = interpStart + match[0].length; + + if (startOffset < interpEnd && endOffset > interpStart) { + return false; + } + } + + return true; + }); + + // Convert to Monaco markers + const markers: monaco.editor.IMarkerData[] = filteredDiagnostics.map((diag: any) => ({ + severity: convertSeverity(diag.severity), + startLineNumber: diag.range.start.line + 1, + startColumn: diag.range.start.character + 1, + endLineNumber: diag.range.end.line + 1, + endColumn: diag.range.end.character + 1, + message: diag.message, + code: typeof diag.code === 'number' ? String(diag.code) : diag.code + })); + + monaco.editor.setModelMarkers(model, LANGUAGE_ID, markers); + } catch (e) { + console.error('JSON validation error:', e); + } + }; + + const onModelAdd = (model: monaco.editor.ITextModel) => { + if (model.getLanguageId() !== LANGUAGE_ID) { + return; + } + + let handle: ReturnType; + listeners[model.uri.toString()] = model.onDidChangeContent(() => { + clearTimeout(handle); + handle = setTimeout(() => doValidate(model), 500); + }); + + doValidate(model); + }; + + const onModelRemoved = (model: monaco.editor.ITextModel) => { + monaco.editor.setModelMarkers(model, LANGUAGE_ID, []); + const listener = listeners[model.uri.toString()]; + if (listener) { + listener.dispose(); + delete listeners[model.uri.toString()]; + } + }; + + monaco.editor.onDidCreateModel(onModelAdd); + monaco.editor.onWillDisposeModel(onModelRemoved); + monaco.editor.onDidChangeModelLanguage((event) => { + onModelRemoved(event.model); + onModelAdd(event.model); + }); + + // Validate existing models + monaco.editor.getModels().forEach(onModelAdd); +} + +// --- Conversion utilities --- + +function convertRange(range: any): monaco.IRange { + return { + startLineNumber: range.start.line + 1, + startColumn: range.start.character + 1, + endLineNumber: range.end.line + 1, + endColumn: range.end.character + 1 + }; +} + +function convertSeverity(severity: number): monaco.MarkerSeverity { + switch (severity) { + case 1: + return monaco.MarkerSeverity.Error; + case 2: + return monaco.MarkerSeverity.Warning; + case 3: + return monaco.MarkerSeverity.Info; + case 4: + return monaco.MarkerSeverity.Hint; + default: + return monaco.MarkerSeverity.Error; + } +} + +function convertCompletionItemKind(kind: number): monaco.languages.CompletionItemKind { + const kindMap: { [key: number]: monaco.languages.CompletionItemKind } = { + 1: monaco.languages.CompletionItemKind.Text, + 2: monaco.languages.CompletionItemKind.Method, + 3: monaco.languages.CompletionItemKind.Function, + 4: monaco.languages.CompletionItemKind.Constructor, + 5: monaco.languages.CompletionItemKind.Field, + 6: monaco.languages.CompletionItemKind.Variable, + 7: monaco.languages.CompletionItemKind.Class, + 8: monaco.languages.CompletionItemKind.Interface, + 9: monaco.languages.CompletionItemKind.Module, + 10: monaco.languages.CompletionItemKind.Property, + 11: monaco.languages.CompletionItemKind.Unit, + 12: monaco.languages.CompletionItemKind.Value, + 13: monaco.languages.CompletionItemKind.Enum, + 14: monaco.languages.CompletionItemKind.Keyword, + 15: monaco.languages.CompletionItemKind.Snippet, + 16: monaco.languages.CompletionItemKind.Color, + 17: monaco.languages.CompletionItemKind.File, + 18: monaco.languages.CompletionItemKind.Reference, + 19: monaco.languages.CompletionItemKind.Folder, + 20: monaco.languages.CompletionItemKind.EnumMember, + 21: monaco.languages.CompletionItemKind.Constant, + 22: monaco.languages.CompletionItemKind.Struct, + 23: monaco.languages.CompletionItemKind.Event, + 24: monaco.languages.CompletionItemKind.Operator, + 25: monaco.languages.CompletionItemKind.TypeParameter + }; + return kindMap[kind] || monaco.languages.CompletionItemKind.Property; +} + +function convertSymbolKind(kind: number): monaco.languages.SymbolKind { + const kindMap: { [key: number]: monaco.languages.SymbolKind } = { + 1: monaco.languages.SymbolKind.File, + 2: monaco.languages.SymbolKind.Module, + 3: monaco.languages.SymbolKind.Namespace, + 4: monaco.languages.SymbolKind.Package, + 5: monaco.languages.SymbolKind.Class, + 6: monaco.languages.SymbolKind.Method, + 7: monaco.languages.SymbolKind.Property, + 8: monaco.languages.SymbolKind.Field, + 9: monaco.languages.SymbolKind.Constructor, + 10: monaco.languages.SymbolKind.Enum, + 11: monaco.languages.SymbolKind.Interface, + 12: monaco.languages.SymbolKind.Function, + 13: monaco.languages.SymbolKind.Variable, + 14: monaco.languages.SymbolKind.Constant, + 15: monaco.languages.SymbolKind.String, + 16: monaco.languages.SymbolKind.Number, + 17: monaco.languages.SymbolKind.Boolean, + 18: monaco.languages.SymbolKind.Array, + 19: monaco.languages.SymbolKind.Object, + 20: monaco.languages.SymbolKind.Key, + 21: monaco.languages.SymbolKind.Null, + 22: monaco.languages.SymbolKind.EnumMember, + 23: monaco.languages.SymbolKind.Struct, + 24: monaco.languages.SymbolKind.Event, + 25: monaco.languages.SymbolKind.Operator, + 26: monaco.languages.SymbolKind.TypeParameter + }; + return kindMap[kind] || monaco.languages.SymbolKind.Property; +} + +function convertSymbol(symbol: any): monaco.languages.DocumentSymbol { + return { + name: symbol.name, + detail: symbol.detail || '', + kind: convertSymbolKind(symbol.kind), + range: convertRange(symbol.range), + selectionRange: convertRange(symbol.selectionRange), + children: symbol.children ? symbol.children.map(convertSymbol) : undefined, + tags: symbol.tags || [] + }; } function formatDocumentation( diff --git a/packages/monaco-json-interpolation/src/types.ts b/packages/monaco-json-interpolation/src/types.ts index 5b39f154..1169d430 100644 --- a/packages/monaco-json-interpolation/src/types.ts +++ b/packages/monaco-json-interpolation/src/types.ts @@ -126,6 +126,26 @@ export interface ModeConfiguration { * Defines whether the built-in diagnostic provider is enabled */ readonly diagnostics?: boolean; + + /** + * Defines whether the built-in documentFormattingEdit provider is enabled + */ + readonly documentFormattingEdits?: boolean; + + /** + * Defines whether the built-in documentRangeFormattingEdit provider is enabled + */ + readonly documentRangeFormattingEdits?: boolean; + + /** + * Defines whether the built-in selectionRange provider is enabled + */ + readonly selectionRanges?: boolean; + + /** + * Defines whether the built-in color provider is enabled + */ + readonly colors?: boolean; } /**