import type { IDisposable, IPosition, MonacoEditor } from "monaco-types"; import { configureMonacoYaml } from "monaco-yaml"; import { extractEmbeddedJs } from "./extractEmbeddedJs"; import { monarchConfig } from "./MonarchYamlJs"; import { fetchAndConvertSchemaToInterface } from "./runTimeModel"; import TOOL_SOURCE_SCHEMA from "./ToolSourceSchema.json"; import { buildProviderFunctions } from "./yaml"; const LANG = "yaml-with-js"; // Lazy-initialized monaco reference - set when setupMonaco is called let monacoInstance: MonacoEditor | null = null; function getEmbeddedModelUri() { if (!monacoInstance) { throw new Error("Monaco not initialized - call setupMonaco first"); } return monacoInstance.Uri.parse("file://embedded-model.js"); } function getDefModelUri() { if (!monacoInstance) { throw new Error("Monaco not initialized - call setupMonaco first"); } return monacoInstance.Uri.parse("file://runtime-defs.ts"); } export async function setupMonaco(monaco: MonacoEditor) { // Store the monaco instance for use by other functions in this module monacoInstance = monaco; // Define the custom YAML language with embedded JavaScript monaco.languages.register({ id: LANG }); monaco.languages.register({ id: "typescript" }); monaco.languages.register({ id: "javascript" }); monaco.languages.register({ id: "yaml" }); const disposables: IDisposable[] = []; disposables.push(monaco.languages.setMonarchTokensProvider(LANG, monarchConfig)); disposables.push( monaco.languages.setLanguageConfiguration(LANG, { comments: { lineComment: "#" }, brackets: [ ["{", "}"], ["[", "]"], ["(", ")"], ["$(", ")"], ], autoClosingPairs: [ { open: "{", close: "}" }, { open: "[", close: "]" }, { open: "(", close: ")" }, { open: '"', close: '"' }, { open: "'", close: "'" }, { open: "$(", close: ")" }, ], surroundingPairs: [ { open: "{", close: "}" }, { open: "[", close: "]" }, { open: "(", close: ")" }, { open: '"', close: '"' }, { open: "'", close: "'" }, ], }), ); // Set TypeScript/JavaScript configuration monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ target: monaco.languages.typescript.ScriptTarget.ES2017, allowNonTsExtensions: true, strict: true, moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, checkJs: true, noEmit: true, lib: ["es2017"], }); monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true); addExtraLibs(); const { dispose } = configureMonacoYaml(monaco, { enableSchemaRequest: false, schemas: [ { // If YAML file is opened matching this glob fileMatch: ["tool.yml"], // The following schema will be applied schema: TOOL_SOURCE_SCHEMA, // And the URI will be linked to as the source. uri: "https://schema.galaxyproject.org/customTool.json", }, ], }); const { dispose: disposeWorker, providerFunctions } = buildProviderFunctions(monaco, { enableSchemaRequest: false, schemas: [ { // If YAML file is opened matching this glob fileMatch: ["tool.yml"], // The following schema will be applied schema: TOOL_SOURCE_SCHEMA, // And the URI will be linked to as the source. uri: "https://schema.galaxyproject.org/customTool.json", }, ], }); disposables.push(disposeWorker); const moreDisposeables = await setupEditor(providerFunctions); function disposeEditor() { dispose(); monaco.editor.getModels().map((model) => model.dispose()); [...disposables, ...moreDisposeables].map((disposable) => disposable.dispose()); } return disposeEditor; } export async function setupEditor(providerFunctions: any) { const disposables = []; // Virtual model for JavaScript const embeddedModelUri = getEmbeddedModelUri(); const yamlModel = monacoInstance!.editor.getModels().find((item) => item.getLanguageId() == LANG)!; const embeddedModel = monacoInstance!.editor.getModel(embeddedModelUri) || monacoInstance!.editor.createModel("", "typescript", embeddedModelUri); mixJsYamlProviders(providerFunctions); disposables.push(monacoInstance!.languages.registerHoverProvider(LANG, providerFunctions)); disposables.push( monacoInstance!.languages.registerCompletionItemProvider(LANG, { triggerCharacters: ["."], provideCompletionItems: providerFunctions.provideCompletionItems, }), ); disposables.push(monacoInstance!.languages.registerDefinitionProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerDocumentSymbolProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerDocumentFormattingEditProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerLinkProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerCodeActionProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerFoldingRangeProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerOnTypeFormattingEditProvider(LANG, providerFunctions)); disposables.push(monacoInstance!.languages.registerSelectionRangeProvider(LANG, providerFunctions)); attachDiagnosticsProvider(yamlModel, embeddedModel, providerFunctions.provideMarkerData); return disposables; } function extractExpressionLibJavaScript(yamlContent: string) { const scriptRegex = /(expression_lib):\s*-\s*\|([\s\S]*?)(?=\n\s*-\s|\n\s*\w+:|\n\s*$)/g; const match = scriptRegex.exec(yamlContent); if (match) { if (match[2]) { return match[2]; } else if (match[3]) { return match[3]; } } return ""; } const fragment = ` declare global { const inputs: components["schemas"]["inputs"] } `; async function addExtraLibs(yamlContent?: string) { if (yamlContent) { const { schemaInterface, error } = await fetchAndConvertSchemaToInterface(yamlContent); if (error) { console.error(error); } else { const runtimeFragment = `${schemaInterface}\n${fragment}`; const defModelUri = getDefModelUri(); const runtimeModel = monacoInstance!.editor.getModel(defModelUri) || monacoInstance!.editor.createModel("", "typescript", defModelUri); if (runtimeModel.getValue() != runtimeFragment) { runtimeModel.setValue(runtimeFragment); } } } } export async function contentSync(yamlContent: string, scriptContent: string, embeddedModel: any) { // Keep the embedded JavaScript model in sync with the YAML editor if (yamlContent) { await addExtraLibs(yamlContent); } embeddedModel.setValue(scriptContent); } async function mixJsYamlProviders(yamlProviderFunctions: any) { // Complete and hover consume position and return null if not in focus, // so execute JS provider, then yaml as fallback const yamlProvideCompletionItems = yamlProviderFunctions.provideCompletionItems; const yamlProvideHover = yamlProviderFunctions.provideHover; yamlProviderFunctions.provideCompletionItems = async (model: any, position: IPosition) => { const jsCompletions = await provideCompletionItems(model, position); if (jsCompletions?.suggestions?.length > 0) { return jsCompletions; } else { return await yamlProvideCompletionItems(model, position); } }; yamlProviderFunctions.provideHover = async (model: any, position: IPosition) => (await provideHover(model, position)) || (await yamlProvideHover(model, position)); } async function languageServiceForModel(model: any) { const worker = await monacoInstance!.languages.typescript.getTypeScriptWorker(); const languageService = await worker(model.uri); return languageService; } async function allModels(model: any) { const yamlContent = model.getValue(); const embeddedModel = monacoInstance!.editor.getModel(getEmbeddedModelUri())!; const embeddedStart = yamlContent.indexOf(embeddedModel.getValue()); const models = [{ start: embeddedStart, model: embeddedModel }]; const embeddedContents = extractEmbeddedJs(yamlContent); const fragmentModels = embeddedContents.map((fragment, index) => { const fragmentModel = getOrCreateFragmentModel(index, fragment.fragment); return { start: fragment.start, model: fragmentModel, }; }); return [...models, ...fragmentModels]; } function getOrCreateFragmentModel(index: number, value: string) { const modelUri = monacoInstance!.Uri.parse(`file://temp-fragment-${index}`); const fragmentModel = monacoInstance!.editor.getModel(modelUri) || monacoInstance!.editor.createModel(value, "typescript", modelUri); fragmentModel.setValue(value); return fragmentModel; } async function modelForCurrentPosition(model: any, position: IPosition) { const yamlContent = model.getValue(); const embeddedContents = extractEmbeddedJs(yamlContent); const offsetForPosition = model.getOffsetAt(position); const fragmentIndex = embeddedContents.findIndex( (content) => content.start <= offsetForPosition && content.start + content.fragment.length >= offsetForPosition, ); if (fragmentIndex >= 0) { const fragment = embeddedContents[fragmentIndex]!; const offsetWithinFragment = offsetForPosition - fragment.start; const fragmentModel = getOrCreateFragmentModel(fragmentIndex, fragment.fragment); return { offset: offsetWithinFragment, model: fragmentModel }; } const embeddedContent = extractExpressionLibJavaScript(yamlContent); if (embeddedContent) { const offsetWithinFragment = offsetForPosition - yamlContent.indexOf(embeddedContent); if (offsetWithinFragment < 0 || offsetWithinFragment > embeddedContent.length) { return undefined; } const embeddedModel = monacoInstance!.editor.getModel(getEmbeddedModelUri())!; embeddedModel.setValue(embeddedContent); return { offset: offsetWithinFragment, model: embeddedModel }; } return undefined; } // Add IntelliSense for the embedded JavaScript async function provideCompletionItems(model: any, position: IPosition) { let completionInfo: any; const currentData = await modelForCurrentPosition(model, position); if (currentData) { const { offset, model: currentModel } = currentData; const languageService = await languageServiceForModel(currentModel); completionInfo = await languageService.getCompletionsAtPosition(currentModel.uri.toString(), offset); if (completionInfo && completionInfo.entries) { const wordInfo = model.getWordUntilPosition(position); return { suggestions: completionInfo.entries.map((entry: any) => ({ label: entry.name, kind: monacoInstance!.languages.CompletionItemKind[ entry.kind[0].toUpperCase() + entry.kind.slice(1) ], insertText: entry.name, range: { startLineNumber: position.lineNumber, startColumn: wordInfo.startColumn, endLineNumber: position.lineNumber, endColumn: wordInfo.endColumn, }, })), }; } } return { suggestions: [] }; } function attachDiagnosticsProvider(yamlModel: any, embeddedModel: any, provideMarkerData: any) { monacoInstance!.editor.setModelMarkers(yamlModel, "owner", []); // Clear existing markers. yamlModel.onDidChangeContent(async () => { const yamlContent = yamlModel.getValue(); const embeddedJavaScript = extractExpressionLibJavaScript(yamlContent); // contentSync makes API call, we could consider updating the marker // only when fetch complete, but doesn't seem to be a problem ... await contentSync(yamlContent, embeddedJavaScript, embeddedModel); const yamlMarkers = await provideMarkerData(yamlModel); const models = await allModels(yamlModel); const worker = await monacoInstance!.languages.typescript.getTypeScriptWorker(); const markers = [...yamlMarkers]; const promises = models.map(async (modelData) => { const languageService = await worker(modelData.model.uri); const diagnostics = await languageService.getSemanticDiagnostics(modelData.model.uri.toString()); diagnostics.forEach((diagnostic) => { const startPosition = yamlModel.getPositionAt(modelData.start + diagnostic.start!); const endPosition = yamlModel.getPositionAt(modelData.start + diagnostic.start! + diagnostic.length!); markers.push({ severity: monacoInstance!.MarkerSeverity.Error, // Severity: Error, Warning, or Info message: typeof diagnostic.messageText === "string" ? diagnostic.messageText : diagnostic.messageText.messageText, startLineNumber: startPosition.lineNumber, startColumn: startPosition.column, endLineNumber: endPosition.lineNumber, endColumn: endPosition.column, }); }); }); await Promise.all(promises); monacoInstance!.editor.setModelMarkers(yamlModel, "owner", markers); }); } function quickInfoToMarkdown(quickInfo: any, position: IPosition) { const { displayParts, documentation, tags } = quickInfo; const markdownText = []; // Format displayParts as code if (displayParts && displayParts.length > 0) { const signature = displayParts.map((part: any) => part.text).join(""); markdownText.push({ value: "```ts\n" + signature + "\n```" }); } // Add documentation text if (documentation && documentation.length > 0) { const docText = documentation.map((part: any) => part.text).join(""); markdownText.push({ value: docText }); } // Add tag text (e.g. @description) if (tags && tags.length > 0) { tags.forEach((tag: any) => { if (tag.text && Array.isArray(tag.text)) { const tagText = tag.text.map((t: any) => t.text).join(""); markdownText.push({ value: `**@${tag.name}** — ${tagText}` }); } else if (typeof tag.text === "string") { markdownText.push({ value: `**@${tag.name}** — ${tag.text}` }); } }); } return { range: { startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, endColumn: position.column, }, contents: markdownText, }; } async function provideHover(model: any, position: IPosition) { const currentData = await modelForCurrentPosition(model, position); if (currentData) { const languageService = await languageServiceForModel(currentData.model); const quickInfo = await languageService.getQuickInfoAtPosition( currentData.model.uri.toString(), currentData.offset, ); if (quickInfo) { return quickInfoToMarkdown(quickInfo, position); } } return null; }