Integrate JSON language service worker for full functionality

The standalone package now taps into Monaco's built-in JSON language
service via monaco.languages.json.getWorker() to provide:

- Schema-aware completions (doComplete)
- JSON hover information (doHover)
- Full validation with interpolation filtering (doValidation)
- Document formatting (format)
- Document symbols/outline (findDocumentSymbols)
- Folding ranges (getFoldingRanges)
- Selection ranges (getSelectionRanges)
- Color provider (findDocumentColors, getColorPresentations)

Inside ${...} interpolations, the custom variable provider takes over
for completions and hover. All JSON diagnostics that overlap with
interpolations are automatically filtered out.
This commit is contained in:
Claude 2025-12-09 20:11:36 +00:00
parent 6039262df7
commit 59eafca1f2
No known key found for this signature in database
2 changed files with 614 additions and 133 deletions

View file

@ -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,23 +139,41 @@ class LanguageServiceDefaultsImpl implements LanguageServiceDefaults {
}
}
// --- Providers ---
// --- Helper to get JSON worker ---
function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
// Variable completion provider
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['$', '{'],
type JSONWorker = {
doComplete(uri: string, position: { lineNumber: number; column: number }): Promise<monaco.languages.CompletionList | null>;
doHover(uri: string, position: { line: number; character: number }): Promise<{ contents: { kind: string; value: string }[]; range?: any } | null>;
doValidation(uri: string): Promise<any[]>;
format(uri: string, range: any, options: any): Promise<any[]>;
findDocumentSymbols(uri: string): Promise<any[]>;
getFoldingRanges(uri: string): Promise<any[]>;
getSelectionRanges(uri: string, positions: any[]): Promise<any[]>;
findDocumentColors(uri: string): Promise<any[]>;
getColorPresentations(uri: string, color: any, range: any): Promise<any[]>;
};
async provideCompletionItems(
model: monaco.editor.ITextModel,
position: monaco.Position
): Promise<monaco.languages.CompletionList | null> {
const variableContext = defaults.variableContext;
if (!variableContext) {
async function getJSONWorker(resource: monaco.Uri): Promise<JSONWorker | null> {
// 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;
}
// Check if we're inside an interpolation ${...}
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,
@ -160,14 +183,30 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
const lastInterpolationStart = textUntilPosition.lastIndexOf('${');
if (lastInterpolationStart === -1) {
return null;
return false;
}
const afterInterpolationStart = textUntilPosition.substring(lastInterpolationStart);
if (afterInterpolationStart.includes('}')) {
return null;
}
return !afterInterpolationStart.includes('}');
}
// --- Providers ---
function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
// Completion provider - combines JSON completions with variable completions
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['"', ':', ' ', '$', '{'],
async provideCompletionItems(
model: monaco.editor.ITextModel,
position: monaco.Position,
context: monaco.languages.CompletionContext
): Promise<monaco.languages.CompletionList | null> {
// 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 = {
@ -188,57 +227,64 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
return { suggestions };
}
return null;
}
// Get JSON completions from the worker
const worker = await getJSONWorker(model.uri);
if (!worker) {
return null;
}
try {
const info = await worker.doComplete(model.uri.toString(), {
lineNumber: position.lineNumber,
column: position.column
});
// Variable hover provider
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;
}
}
});
// Hover provider - combines JSON hover with variable hover
monaco.languages.registerHoverProvider(LANGUAGE_ID, {
async provideHover(
model: monaco.editor.ITextModel,
position: monaco.Position
): Promise<monaco.languages.Hover | null> {
// Check if we're inside an interpolation
if (isInsideInterpolation(model, position)) {
const variableContext = defaults.variableContext;
if (!variableContext) {
return null;
}
const line = model.getLineContent(position.lineNumber);
const offset = position.column - 1;
// Find interpolation boundaries
let inInterpolation = false;
let interpStart = -1;
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 (!inInterpolation || interpStart === -1) {
return null;
}
if (variableContext) {
const wordInfo = model.getWordAtPosition(position);
if (!wordInfo) {
return null;
}
const variableName = wordInfo.word;
if (wordInfo) {
const variables = await variableContext.getVariables();
const variable = variables.find((v) => v.name === variableName);
if (!variable) {
return null;
}
const variable = variables.find((v) => v.name === wordInfo.word);
if (variable) {
const contents: monaco.IMarkdownString[] = [];
if (variable.type) {
@ -271,37 +317,452 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
}
};
}
}
}
return null;
}
// 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
});
// Folding range provider (basic JSON-like folding)
monaco.languages.registerFoldingRangeProvider(LANGUAGE_ID, {
provideFoldingRanges(
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<monaco.languages.TextEdit[] | null> {
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<monaco.languages.TextEdit[] | null> {
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
): monaco.languages.FoldingRange[] {
const ranges: monaco.languages.FoldingRange[] = [];
const stack: { char: string; line: number }[] = [];
): Promise<monaco.languages.DocumentSymbol[] | null> {
const worker = await getJSONWorker(model.uri);
if (!worker) {
return null;
}
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
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<monaco.languages.FoldingRange[] | null> {
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<monaco.languages.SelectionRange[][] | null> {
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<monaco.languages.IColorInformation[] | null> {
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<monaco.languages.IColorPresentation[] | null> {
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 ranges;
}
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<typeof setTimeout>;
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(

View file

@ -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;
}
/**