diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index d704b71f38..b8bde4edb1 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -107,6 +107,7 @@ fetchCustomWmillTypesData } from './lint/typescriptExtraLibs' import { createWindmillAta, genAtaRoot } from './lint/typescriptAta' + import { readModelMarkers } from './lint/headlessLint' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' import type { Selection } from 'monaco-editor' import { canHavePreprocessor, getPreprocessorModuleCode } from '$lib/script_helpers' @@ -536,17 +537,7 @@ if (!model) { return { errorCount: 0, warningCount: 0, errors: [], warnings: [] } } - - const markers = meditor.getModelMarkers({ resource: model.uri }) - const errors = markers.filter((m) => m.severity === MarkerSeverity.Error) - const warnings = markers.filter((m) => m.severity === MarkerSeverity.Warning) - - return { - errorCount: errors.length, - warningCount: warnings.length, - errors, - warnings - } + return readModelMarkers(model.uri) } let command: IDisposable | undefined = undefined diff --git a/frontend/src/lib/components/lint/headlessLint.ts b/frontend/src/lib/components/lint/headlessLint.ts new file mode 100644 index 0000000000..75a08bc431 --- /dev/null +++ b/frontend/src/lib/components/lint/headlessLint.ts @@ -0,0 +1,212 @@ +import { editor as meditor, MarkerSeverity, Uri } from 'monaco-editor' +import { + getJavaScriptWorker, + getTypeScriptWorker +} from '@codingame/monaco-vscode-standalone-typescript-language-features' +import { scriptLangToEditorLang } from '$lib/scripts' +import type { ScriptLintResult } from '../copilot/chat/shared' +import { initializeVscode, keepModelAroundToAvoidDisposalOfWorkers } from '../vscode' +import { + setMonacoJavascriptOptions, + setMonacoTypescriptOptions, + TS_DIAGNOSTIC_CODES_TO_IGNORE +} from '../monacoLanguagesOptions' +import { computeModelPath, computeModelUri } from './monacoUri' +import { ensureCustomWmillTypes, ensureResourceTypeNamespace } from './typescriptExtraLibs' +import { createWindmillAta, genAtaRoot } from './typescriptAta' + +// Lints code without a mounted editor, by driving the same Monaco model + global +// typescriptDefaults an editor would. Diagnostics belong to the model, not the editor, +// so results match what opening the editor on the same item would show — provided the +// URI and the extra libs are derived through the shared helpers in this directory. + +const HEADLESS_LINT_LANGS = ['bun', 'bunnative', 'nativets', 'tsx', 'javascript', 'jsx'] + +export function canLintHeadless(scriptLang: string | undefined): boolean { + return HEADLESS_LINT_LANGS.includes(scriptLang ?? '') +} + +export interface HeadlessLintRequest { + content: string + scriptLang: string + /** Canonical editor path without extension: script path, `/`, `/`. */ + path: string + workspace: string +} + +export interface HeadlessLintOutcome extends ScriptLintResult { + /** An editor already owns this model and its content differs from what was requested. */ + contentMismatch: boolean +} + +export function readModelMarkers(uri: Uri): ScriptLintResult { + const markers = meditor.getModelMarkers({ resource: uri }) + const errors = markers.filter((m) => m.severity === MarkerSeverity.Error) + const warnings = markers.filter((m) => m.severity === MarkerSeverity.Warning) + return { errorCount: errors.length, warningCount: warnings.length, errors, warnings } +} + +// Models we created ourselves, oldest first. An editor may later adopt one by URI, so +// eviction must never dispose a model that is attached to an editor. +const ownedModels: string[] = [] +const MAX_OWNED_MODELS = 4 + +const absolutePathExtraLibs = new Map void }>() +const ataByKey = new Map Promise>() +const pendingByUri = new Map>() + +function evictOwnedModels(keepUri: string) { + while (ownedModels.length > MAX_OWNED_MODELS) { + const candidate = ownedModels.shift() + if (!candidate || candidate === keepUri) continue + const model = meditor.getModel(Uri.parse(candidate)) + if (model && !model.isAttachedToEditor()) { + model.dispose() + } + } +} + +export async function lintCode( + req: HeadlessLintRequest, + opts?: { timeoutMs?: number } +): Promise { + if (!canLintHeadless(req.scriptLang)) { + throw new Error(`Headless linting is not supported for language ${req.scriptLang}`) + } + const editorLang = scriptLangToEditorLang(req.scriptLang as any) + const uriString = computeModelUri( + computeModelPath(req.path, req.scriptLang), + req.scriptLang, + editorLang + ) + + // Two lints of the same URI would race on model content; run them in order. + const previous = pendingByUri.get(uriString) ?? Promise.resolve(undefined) + const run = previous + .catch(() => undefined) + .then(() => lintOne(req, uriString, editorLang, opts?.timeoutMs ?? 3000)) + pendingByUri.set( + uriString, + run.finally(() => { + if (pendingByUri.get(uriString) === run) pendingByUri.delete(uriString) + }) as Promise + ) + return run +} + +async function lintOne( + req: HeadlessLintRequest, + uriString: string, + editorLang: string, + timeoutMs: number +): Promise { + setMonacoTypescriptOptions() + if (editorLang === 'javascript') { + setMonacoJavascriptOptions() + } + await initializeVscode('headlessLint') + keepModelAroundToAvoidDisposalOfWorkers() + + const uri = Uri.parse(uriString) + let model = meditor.getModel(uri) + let contentMismatch = false + if (model) { + const isOurs = ownedModels.includes(uriString) + if (isOurs) { + if (model.getValue() !== req.content) model.setValue(req.content) + } else { + // An editor owns this model: its content is what the user sees and what they would + // see the markers for. Lint it as-is rather than overwriting their buffer. + contentMismatch = model.getValue() !== req.content + } + } else { + model = meditor.createModel(req.content, editorLang, uri) + ownedModels.push(uriString) + evictOwnedModels(uriString) + } + + if (editorLang === 'typescript') { + await Promise.all([ + ensureResourceTypeNamespace(req.workspace, req.scriptLang).catch((e) => + console.error('headlessLint: resource types unavailable', e) + ), + ensureCustomWmillTypes(req.workspace).catch(() => undefined) + ]) + } + + if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') { + await acquireTypes(req, uriString).catch((e) => + console.error('headlessLint: type acquisition failed', e) + ) + } + + await waitForMarkersToSettle(uri, editorLang, timeoutMs) + return { ...readModelMarkers(uri), contentMismatch } +} + +async function acquireTypes(req: HeadlessLintRequest, uriString: string): Promise { + const key = `${req.workspace}:${uriString}` + let ata = ataByKey.get(key) + if (!ata) { + ata = await createWindmillAta({ + root: await genAtaRoot(req.workspace), + scriptPath: req.path, + modelUri: uriString, + absolutePathExtraLibs + }) + ataByKey.set(key, ata) + if (req.scriptLang === 'bun') { + await ata('import "bun-types"') + } + } + await ata(req.content) +} + +async function getWorkerFor(uri: Uri, editorLang: string) { + const getWorker = editorLang === 'javascript' ? getJavaScriptWorker : getTypeScriptWorker + // The language mode activates asynchronously once a model of that language exists, so + // the worker accessor rejects for a short while after the very first model is created. + const deadline = Date.now() + 5000 + let lastError: unknown + while (Date.now() < deadline) { + try { + const client = await getWorker() + return await client(uri) + } catch (e) { + lastError = e + await new Promise((r) => setTimeout(r, 200)) + } + } + throw lastError ?? new Error('typescript worker unavailable') +} + +async function countExpectedMarkers(uri: Uri, editorLang: string): Promise { + try { + const worker = await getWorkerFor(uri, editorLang) + const fileName = uri.toString() + const [syntactic, semantic, suggestions] = await Promise.all([ + worker.getSyntacticDiagnostics(fileName), + worker.getSemanticDiagnostics(fileName), + worker.getSuggestionDiagnostics(fileName) + ]) + return [...syntactic, ...semantic, ...suggestions].filter( + (d) => !TS_DIAGNOSTIC_CODES_TO_IGNORE.includes(d.code) + ).length + } catch (e) { + console.error('headlessLint: could not query the typescript worker', e) + return undefined + } +} + +// The editor's own lint paths sleep a fixed delay and hope. Instead, ask the worker how +// many diagnostics it has and wait for that many markers to be published — which also +// terminates promptly for clean code, where no marker-change event ever fires. +async function waitForMarkersToSettle(uri: Uri, editorLang: string, timeoutMs: number) { + const expected = await countExpectedMarkers(uri, editorLang) + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const published = meditor.getModelMarkers({ resource: uri, owner: editorLang }).length + if (expected !== undefined && published === expected) return + await new Promise((r) => setTimeout(r, 50)) + } +} diff --git a/frontend/src/lib/components/monacoLanguagesOptions.ts b/frontend/src/lib/components/monacoLanguagesOptions.ts index 5e0a19c08a..6b16f41a81 100644 --- a/frontend/src/lib/components/monacoLanguagesOptions.ts +++ b/frontend/src/lib/components/monacoLanguagesOptions.ts @@ -34,6 +34,10 @@ export function setMonacoJsonOptions() { let typescriptInitialized = false +// Codes Monaco is told to drop before publishing markers. Anything reasoning about how +// many markers a model should end up with has to apply the same filter. +export const TS_DIAGNOSTIC_CODES_TO_IGNORE = [1108, 7006, 7034, 7019, 7005] + export function setMonacoTypescriptOptions() { if (typescriptInitialized) return typescriptInitialized = true @@ -71,7 +75,7 @@ export function setMonacoTypescriptOptions() { noSyntaxValidation: false, noSuggestionDiagnostics: false, - diagnosticCodesToIgnore: [1108, 7006, 7034, 7019, 7005] + diagnosticCodesToIgnore: TS_DIAGNOSTIC_CODES_TO_IGNORE }) typescriptDefaults.setCompilerOptions({