Use JavaScript worker for interpolation IntelliSense

Inside ${...} interpolations, the package now uses Monaco's JavaScript
language service worker for full IntelliSense:

- getCompletionsAtPosition: Property access, method completions
- getQuickInfoAtPosition: Type info and documentation on hover
- getSignatureHelpItems: Function parameter hints

This allows users to configure the context using setExtraLibs:

```typescript
monaco.languages.typescript.javascriptDefaults.setExtraLibs([{
  content: `
    declare const config: { debug: boolean; port: number };
    declare const env: string;
  `,
  filePath: 'context.d.ts'
}]);
```

Then typing `config.` inside ${...} will show `debug` and `port` completions
with proper types.
This commit is contained in:
Claude 2025-12-09 20:29:58 +00:00
parent 59eafca1f2
commit a96c488593
No known key found for this signature in database

View file

@ -1,7 +1,7 @@
/*--------------------------------------------------------------------------------------------- /*---------------------------------------------------------------------------------------------
* Monaco JSON Interpolation * Monaco JSON Interpolation
* Standalone add-on for Monaco Editor providing JSON with ${...} variable interpolation * Standalone add-on for Monaco Editor providing JSON with ${...} variable interpolation
* Integrates with Monaco's built-in JSON language service for full functionality * Integrates with Monaco's built-in JSON and JavaScript language services
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
@ -154,7 +154,6 @@ type JSONWorker = {
}; };
async function getJSONWorker(resource: monaco.Uri): Promise<JSONWorker | null> { async function getJSONWorker(resource: monaco.Uri): Promise<JSONWorker | null> {
// Access Monaco's built-in JSON language service
const json = (monaco.languages as any).json; const json = (monaco.languages as any).json;
if (!json || !json.getWorker) { if (!json || !json.getWorker) {
console.warn('monaco-json-interpolation: JSON language service not available'); console.warn('monaco-json-interpolation: JSON language service not available');
@ -171,31 +170,115 @@ async function getJSONWorker(resource: monaco.Uri): Promise<JSONWorker | null> {
} }
} }
// --- Check if position is inside interpolation --- // --- Helper to get JavaScript worker ---
function isInsideInterpolation(model: monaco.editor.ITextModel, position: monaco.Position): boolean { type JSWorker = {
const textUntilPosition = model.getValueInRange({ getCompletionsAtPosition(uri: string, offset: number): Promise<any>;
startLineNumber: 1, getQuickInfoAtPosition(uri: string, offset: number): Promise<any>;
startColumn: 1, getSignatureHelpItems(uri: string, offset: number): Promise<any>;
endLineNumber: position.lineNumber, };
endColumn: position.column
});
const lastInterpolationStart = textUntilPosition.lastIndexOf('${'); async function getJavaScriptWorker(resource: monaco.Uri): Promise<JSWorker | null> {
if (lastInterpolationStart === -1) { const ts = (monaco.languages as any).typescript;
return false; if (!ts || !ts.getJavaScriptWorker) {
console.warn('monaco-json-interpolation: JavaScript language service not available');
return null;
} }
const afterInterpolationStart = textUntilPosition.substring(lastInterpolationStart); try {
return !afterInterpolationStart.includes('}'); const getWorker = await ts.getJavaScriptWorker();
const worker = await getWorker(resource);
return worker;
} catch (e) {
console.warn('monaco-json-interpolation: Failed to get JavaScript worker', e);
return null;
}
}
// --- Interpolation context helpers ---
interface InterpolationContext {
/** The full expression inside ${...} */
expression: string;
/** Offset of ${ in the document */
interpolationStart: number;
/** Offset within the expression where cursor is */
offsetInExpression: number;
/** Start line/column of the interpolation */
startPosition: { lineNumber: number; column: number };
}
function getInterpolationContext(
model: monaco.editor.ITextModel,
position: monaco.Position
): InterpolationContext | null {
const text = model.getValue();
const offset = model.getOffsetAt(position);
// Find the ${...} that contains the current position
const beforeCursor = text.substring(0, offset);
const lastStart = beforeCursor.lastIndexOf('${');
if (lastStart === -1) {
return null;
}
// Check if there's a closing } between ${ and cursor
const betweenStartAndCursor = text.substring(lastStart + 2, offset);
if (betweenStartAndCursor.includes('}')) {
return null;
}
// Find the closing }
let depth = 1;
let endOffset = offset;
for (let i = offset; i < text.length; i++) {
if (text[i] === '{') depth++;
else if (text[i] === '}') {
depth--;
if (depth === 0) {
endOffset = i;
break;
}
}
}
// Extract the expression (content between ${ and })
const expressionStart = lastStart + 2;
const expression = text.substring(expressionStart, endOffset);
const offsetInExpression = offset - expressionStart;
const startPos = model.getPositionAt(lastStart);
return {
expression,
interpolationStart: lastStart,
offsetInExpression,
startPosition: {
lineNumber: startPos.lineNumber,
column: startPos.column
}
};
}
// --- Virtual model for JavaScript worker ---
const VIRTUAL_JS_URI = monaco.Uri.parse('file:///interpolation-context.js');
let virtualJsModel: monaco.editor.ITextModel | null = null;
function getOrCreateVirtualJsModel(): monaco.editor.ITextModel {
if (!virtualJsModel || virtualJsModel.isDisposed()) {
virtualJsModel = monaco.editor.createModel('', 'javascript', VIRTUAL_JS_URI);
}
return virtualJsModel;
} }
// --- Providers --- // --- Providers ---
function registerProviders(defaults: LanguageServiceDefaultsImpl): void { function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
// Completion provider - combines JSON completions with variable completions // Completion provider - combines JSON completions with JS completions for interpolation
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, { monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['"', ':', ' ', '$', '{'], triggerCharacters: ['"', ':', ' ', '$', '{', '.'],
async provideCompletionItems( async provideCompletionItems(
model: monaco.editor.ITextModel, model: monaco.editor.ITextModel,
@ -203,11 +286,30 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
context: monaco.languages.CompletionContext context: monaco.languages.CompletionContext
): Promise<monaco.languages.CompletionList | null> { ): Promise<monaco.languages.CompletionList | null> {
// Check if we're inside an interpolation // Check if we're inside an interpolation
if (isInsideInterpolation(model, position)) { const interpContext = getInterpolationContext(model, position);
// Provide variable completions
const variableContext = defaults.variableContext; if (interpContext) {
if (variableContext) { // Use JavaScript worker for completions inside interpolation
const variables = await variableContext.getVariables(); const jsWorker = await getJavaScriptWorker(VIRTUAL_JS_URI);
if (!jsWorker) {
return null;
}
try {
// Update the virtual model with the expression
const virtualModel = getOrCreateVirtualJsModel();
virtualModel.setValue(interpContext.expression);
// Get completions from JavaScript worker
const info = await jsWorker.getCompletionsAtPosition(
VIRTUAL_JS_URI.toString(),
interpContext.offsetInExpression
);
if (!info || !info.entries) {
return null;
}
const wordInfo = model.getWordUntilPosition(position); const wordInfo = model.getWordUntilPosition(position);
const range = { const range = {
startLineNumber: position.lineNumber, startLineNumber: position.lineNumber,
@ -216,19 +318,21 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
endColumn: wordInfo.endColumn endColumn: wordInfo.endColumn
}; };
const suggestions: monaco.languages.CompletionItem[] = variables.map((variable) => ({ const suggestions: monaco.languages.CompletionItem[] = info.entries.map((entry: any) => ({
label: variable.name, label: entry.name,
kind: monaco.languages.CompletionItemKind.Variable, kind: convertTsCompletionKind(entry.kind),
detail: variable.detail || variable.type, detail: entry.kindModifiers,
documentation: formatDocumentation(variable), sortText: entry.sortText,
insertText: variable.name, insertText: entry.insertText || entry.name,
range: range range: range
})); }));
return { suggestions }; return { suggestions };
} } catch (e) {
console.error('JS completion error:', e);
return null; return null;
} }
}
// Get JSON completions from the worker // Get JSON completions from the worker
const worker = await getJSONWorker(model.uri); const worker = await getJSONWorker(model.uri);
@ -246,7 +350,6 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
return null; return null;
} }
// Convert LSP completions to Monaco completions
const suggestions: monaco.languages.CompletionItem[] = (info.items || []).map((item: any) => ({ const suggestions: monaco.languages.CompletionItem[] = (info.items || []).map((item: any) => ({
label: item.label, label: item.label,
kind: convertCompletionItemKind(item.kind), kind: convertCompletionItemKind(item.kind),
@ -269,58 +372,74 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
} }
}); });
// Hover provider - combines JSON hover with variable hover // Hover provider - combines JSON hover with JS hover for interpolation
monaco.languages.registerHoverProvider(LANGUAGE_ID, { monaco.languages.registerHoverProvider(LANGUAGE_ID, {
async provideHover( async provideHover(
model: monaco.editor.ITextModel, model: monaco.editor.ITextModel,
position: monaco.Position position: monaco.Position
): Promise<monaco.languages.Hover | null> { ): Promise<monaco.languages.Hover | null> {
// Check if we're inside an interpolation // Check if we're inside an interpolation
if (isInsideInterpolation(model, position)) { const interpContext = getInterpolationContext(model, position);
const variableContext = defaults.variableContext;
if (variableContext) { if (interpContext) {
const wordInfo = model.getWordAtPosition(position); // Use JavaScript worker for hover inside interpolation
if (wordInfo) { const jsWorker = await getJavaScriptWorker(VIRTUAL_JS_URI);
const variables = await variableContext.getVariables(); if (!jsWorker) {
const variable = variables.find((v) => v.name === wordInfo.word); return null;
}
try {
// Update the virtual model with the expression
const virtualModel = getOrCreateVirtualJsModel();
virtualModel.setValue(interpContext.expression);
// Get quick info from JavaScript worker
const info = await jsWorker.getQuickInfoAtPosition(
VIRTUAL_JS_URI.toString(),
interpContext.offsetInExpression
);
if (!info) {
return null;
}
if (variable) {
const contents: monaco.IMarkdownString[] = []; const contents: monaco.IMarkdownString[] = [];
if (variable.type) { // Display string (type signature)
if (info.displayParts) {
const displayText = info.displayParts.map((p: any) => p.text).join('');
contents.push({ contents.push({
value: `\`\`\`typescript\n(variable) ${variable.name}: ${variable.type}\n\`\`\`` value: '```typescript\n' + displayText + '\n```'
});
} else {
contents.push({
value: `\`\`\`typescript\n(variable) ${variable.name}\n\`\`\``
}); });
} }
if (variable.description) { // Documentation
contents.push({ value: variable.description }); if (info.documentation && info.documentation.length > 0) {
const docText = info.documentation.map((p: any) => p.text).join('');
contents.push({ value: docText });
} }
if (variable.value !== undefined) { if (contents.length === 0) {
contents.push({ return null;
value: `**Current value:**\n\`\`\`json\n${JSON.stringify(variable.value, null, 2)}\n\`\`\``
});
} }
return { // Calculate range in the original document
contents, const wordInfo = model.getWordAtPosition(position);
range: { const range = wordInfo
? {
startLineNumber: position.lineNumber, startLineNumber: position.lineNumber,
startColumn: wordInfo.startColumn, startColumn: wordInfo.startColumn,
endLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
endColumn: wordInfo.endColumn endColumn: wordInfo.endColumn
} }
}; : undefined;
}
} return { contents, range };
} } catch (e) {
console.error('JS hover error:', e);
return null; return null;
} }
}
// Get JSON hover from the worker // Get JSON hover from the worker
const worker = await getJSONWorker(model.uri); const worker = await getJSONWorker(model.uri);
@ -356,6 +475,69 @@ function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
} }
}); });
// Signature help provider for function calls inside interpolation
monaco.languages.registerSignatureHelpProvider(LANGUAGE_ID, {
signatureHelpTriggerCharacters: ['(', ','],
async provideSignatureHelp(
model: monaco.editor.ITextModel,
position: monaco.Position
): Promise<monaco.languages.SignatureHelpResult | null> {
const interpContext = getInterpolationContext(model, position);
if (!interpContext) {
return null;
}
const jsWorker = await getJavaScriptWorker(VIRTUAL_JS_URI);
if (!jsWorker) {
return null;
}
try {
const virtualModel = getOrCreateVirtualJsModel();
virtualModel.setValue(interpContext.expression);
const info = await jsWorker.getSignatureHelpItems(
VIRTUAL_JS_URI.toString(),
interpContext.offsetInExpression
);
if (!info || !info.items || info.items.length === 0) {
return null;
}
const signatures: monaco.languages.SignatureInformation[] = info.items.map((item: any) => {
const params: monaco.languages.ParameterInformation[] = item.parameters.map((p: any) => ({
label: p.displayParts.map((d: any) => d.text).join(''),
documentation: p.documentation?.map((d: any) => d.text).join('') || undefined
}));
const prefix = item.prefixDisplayParts?.map((p: any) => p.text).join('') || '';
const suffix = item.suffixDisplayParts?.map((p: any) => p.text).join('') || '';
const separator = item.separatorDisplayParts?.map((p: any) => p.text).join('') || ', ';
return {
label: prefix + params.map((p) => p.label).join(separator) + suffix,
documentation: item.documentation?.map((d: any) => d.text).join('') || undefined,
parameters: params
};
});
return {
value: {
signatures,
activeSignature: info.selectedItemIndex || 0,
activeParameter: info.argumentIndex || 0
},
dispose: () => {}
};
} catch (e) {
console.error('JS signature help error:', e);
return null;
}
}
});
// Diagnostics adapter // Diagnostics adapter
setupDiagnostics(defaults); setupDiagnostics(defaults);
@ -721,6 +903,35 @@ function convertCompletionItemKind(kind: number): monaco.languages.CompletionIte
return kindMap[kind] || monaco.languages.CompletionItemKind.Property; return kindMap[kind] || monaco.languages.CompletionItemKind.Property;
} }
function convertTsCompletionKind(kind: string): monaco.languages.CompletionItemKind {
const kindMap: { [key: string]: monaco.languages.CompletionItemKind } = {
primitive: monaco.languages.CompletionItemKind.Keyword,
keyword: monaco.languages.CompletionItemKind.Keyword,
var: monaco.languages.CompletionItemKind.Variable,
let: monaco.languages.CompletionItemKind.Variable,
const: monaco.languages.CompletionItemKind.Constant,
localVar: monaco.languages.CompletionItemKind.Variable,
function: monaco.languages.CompletionItemKind.Function,
localFunction: monaco.languages.CompletionItemKind.Function,
method: monaco.languages.CompletionItemKind.Method,
getter: monaco.languages.CompletionItemKind.Property,
setter: monaco.languages.CompletionItemKind.Property,
property: monaco.languages.CompletionItemKind.Property,
constructor: monaco.languages.CompletionItemKind.Constructor,
class: monaco.languages.CompletionItemKind.Class,
interface: monaco.languages.CompletionItemKind.Interface,
type: monaco.languages.CompletionItemKind.Interface,
enum: monaco.languages.CompletionItemKind.Enum,
enumMember: monaco.languages.CompletionItemKind.EnumMember,
module: monaco.languages.CompletionItemKind.Module,
alias: monaco.languages.CompletionItemKind.Variable,
typeParameter: monaco.languages.CompletionItemKind.TypeParameter,
parameter: monaco.languages.CompletionItemKind.Variable,
string: monaco.languages.CompletionItemKind.Value
};
return kindMap[kind] || monaco.languages.CompletionItemKind.Property;
}
function convertSymbolKind(kind: number): monaco.languages.SymbolKind { function convertSymbolKind(kind: number): monaco.languages.SymbolKind {
const kindMap: { [key: number]: monaco.languages.SymbolKind } = { const kindMap: { [key: number]: monaco.languages.SymbolKind } = {
1: monaco.languages.SymbolKind.File, 1: monaco.languages.SymbolKind.File,
@ -765,25 +976,6 @@ function convertSymbol(symbol: any): monaco.languages.DocumentSymbol {
}; };
} }
function formatDocumentation(
variable: VariableDefinition
): string | monaco.IMarkdownString {
let doc = '';
if (variable.description) {
doc += variable.description;
}
if (variable.value !== undefined) {
if (doc) {
doc += '\n\n';
}
doc += `**Current value:** \`${JSON.stringify(variable.value)}\``;
}
return doc ? { value: doc } : '';
}
// --- Convenience export --- // --- Convenience export ---
export const jsonInterpolation = { export const jsonInterpolation = {