// Copyright 2018-2025 the Deno authors. MIT license. import { ASSET_SCOPES, ASSETS_URL_PREFIX, clearScriptNamesCache, debug, error, filterMapDiagnostic, fromTypeScriptDiagnostics, getAssets, getCreateSourceFileOptions, host, IS_NODE_SOURCE_FILE_CACHE, LANGUAGE_SERVICE_ENTRIES, LAST_REQUEST_METHOD, LAST_REQUEST_SCOPE, OperationCanceledError, PROJECT_VERSION_CACHE, SCRIPT_SNAPSHOT_CACHE, SCRIPT_VERSION_CACHE, setLogDebug, SOURCE_REF_COUNTS, } from "./97_ts_host.js"; /** @type {DenoCore} */ const core = globalThis.Deno.core; const ops = core.ops; const ChangeKind = { Opened: 0, Modified: 1, Closed: 2, }; /** * @param {ts.CompilerOptions | ts.MinimalResolutionCacheHost} settingsOrHost * @returns {ts.CompilerOptions} */ function getCompilationSettings(settingsOrHost) { if (typeof settingsOrHost.getCompilationSettings === "function") { return settingsOrHost.getCompilationSettings(); } return /** @type {ts.CompilerOptions} */ (settingsOrHost); } // We need to use a custom document registry in order to provide source files // with an impliedNodeFormat to the ts language service /** @type {Map} */ const documentRegistrySourceFileCache = new Map(); const { getKeyForCompilationSettings } = ts.createDocumentRegistry(); // reuse this code /** @type {ts.DocumentRegistry} */ const documentRegistry = { acquireDocument( fileName, compilationSettingsOrHost, scriptSnapshot, version, scriptKind, sourceFileOptions, ) { const key = getKeyForCompilationSettings( getCompilationSettings(compilationSettingsOrHost), ); return this.acquireDocumentWithKey( fileName, /** @type {ts.Path} */ (fileName), compilationSettingsOrHost, key, scriptSnapshot, version, scriptKind, sourceFileOptions, ); }, acquireDocumentWithKey( fileName, path, _compilationSettingsOrHost, key, scriptSnapshot, version, scriptKind, sourceFileOptions, ) { const mapKey = path + key; let sourceFile = documentRegistrySourceFileCache.get(mapKey); if (!sourceFile || sourceFile.version !== version) { const isCjs = /** @type {any} */ (scriptSnapshot).isCjs; sourceFile = ts.createLanguageServiceSourceFile( fileName, scriptSnapshot, { ...getCreateSourceFileOptions(sourceFileOptions), impliedNodeFormat: isCjs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext, // in the lsp we want to be able to show documentation jsDocParsingMode: ts.JSDocParsingMode.ParseAll, }, version, true, scriptKind, ); documentRegistrySourceFileCache.set(mapKey, sourceFile); } const sourceRefCount = SOURCE_REF_COUNTS.get(fileName) ?? 0; SOURCE_REF_COUNTS.set(fileName, sourceRefCount + 1); return sourceFile; }, updateDocument( fileName, compilationSettingsOrHost, scriptSnapshot, version, scriptKind, sourceFileOptions, ) { const key = getKeyForCompilationSettings( getCompilationSettings(compilationSettingsOrHost), ); return this.updateDocumentWithKey( fileName, /** @type {ts.Path} */ (fileName), compilationSettingsOrHost, key, scriptSnapshot, version, scriptKind, sourceFileOptions, ); }, updateDocumentWithKey( fileName, path, compilationSettingsOrHost, key, scriptSnapshot, version, scriptKind, sourceFileOptions, ) { const mapKey = path + key; let sourceFile = documentRegistrySourceFileCache.get(mapKey) ?? this.acquireDocumentWithKey( fileName, path, compilationSettingsOrHost, key, scriptSnapshot, version, scriptKind, sourceFileOptions, ); if (sourceFile.version !== version) { sourceFile = ts.updateLanguageServiceSourceFile( sourceFile, scriptSnapshot, version, scriptSnapshot.getChangeRange( /** @type {ts.IScriptSnapshot} */ (sourceFile.scriptSnapShot), ), ); documentRegistrySourceFileCache.set(mapKey, sourceFile); } return sourceFile; }, getKeyForCompilationSettings(settings) { return getKeyForCompilationSettings(settings); }, releaseDocument( fileName, compilationSettings, scriptKind, impliedNodeFormat, ) { const key = getKeyForCompilationSettings(compilationSettings); return this.releaseDocumentWithKey( /** @type {ts.Path} */ (fileName), key, scriptKind, impliedNodeFormat, ); }, releaseDocumentWithKey(path, key, _scriptKind, _impliedNodeFormat) { const sourceRefCount = SOURCE_REF_COUNTS.get(path) ?? 1; if (sourceRefCount <= 1) { SOURCE_REF_COUNTS.delete(path); // We call `cleanupSemanticCache` for other purposes, don't bust the // source cache in this case. if (LAST_REQUEST_METHOD.get() != "cleanupSemanticCache") { const mapKey = path + key; documentRegistrySourceFileCache.delete(mapKey); SCRIPT_SNAPSHOT_CACHE.delete(path); ops.op_release(path); } } else { SOURCE_REF_COUNTS.set(path, sourceRefCount - 1); } }, reportStats() { return "[]"; }, }; /** @param {Record} config */ function normalizeConfig(config) { // the typescript compiler doesn't know about the precompile // transform at the moment, so just tell it we're using react-jsx if (config.jsx === "precompile") { config.jsx = "react-jsx"; } if (config.jsxPrecompileSkipElements) { delete config.jsxPrecompileSkipElements; } return config; } /** @param {Record} config */ function lspTsConfigToCompilerOptions(config) { const normalizedConfig = normalizeConfig(config); const { options, errors } = ts .convertCompilerOptionsFromJson(normalizedConfig, ""); Object.assign(options, { allowNonTsExtensions: true, allowImportingTsExtensions: true, module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, }); if (errors.length > 0) { debug(ts.formatDiagnostics(errors, host)); } return options; } /** * @param {any} e * @returns {e is (OperationCanceledError | ts.OperationCanceledException)} */ function isCancellationError(e) { return e instanceof OperationCanceledError || e instanceof ts.OperationCanceledException; } /** * @param {number} _id * @param {any} data * @param {string | null} error */ // TODO(bartlomieju): this feels needlessly generic, both type checking // and language server use it with inefficient serialization. Id is not used // anyway... function respond(_id, data = null, error = null) { if (error) { ops.op_respond( "error", error, ); } else { ops.op_respond(JSON.stringify(data), ""); } } /** @typedef {[[string, number][], number, [string, any][]] } PendingChange */ /** * @template T * @typedef {T | null} Option */ /** @returns {Promise<[number, string, any[], string | null, Option] | null>} */ async function pollRequests() { return await ops.op_poll_requests(); } let hasStarted = false; /** @param {boolean} enableDebugLogging */ export async function serverMainLoop(enableDebugLogging) { if (hasStarted) { throw new Error("The language server has already been initialized."); } hasStarted = true; LANGUAGE_SERVICE_ENTRIES.unscoped = { ls: ts.createLanguageService( host, documentRegistry, ), compilerOptions: lspTsConfigToCompilerOptions({ "allowJs": true, "esModuleInterop": true, "experimentalDecorators": false, "isolatedModules": true, "lib": ["deno.ns", "deno.window", "deno.unstable"], "module": "NodeNext", "moduleResolution": "NodeNext", "moduleDetection": "force", "noEmit": true, "noImplicitOverride": true, "resolveJsonModule": true, "strict": true, "target": "esnext", "useDefineForClassFields": true, "jsx": "react", "jsxFactory": "React.createElement", "jsxFragmentFactory": "React.Fragment", }), }; setLogDebug(enableDebugLogging, "TSLS"); debug("serverInit()"); while (true) { const request = await pollRequests(); if (request === null) { break; } try { serverRequest( request[0], request[1], request[2], request[3], request[4], ); } catch (err) { error(`Internal error occurred processing request: ${err}`); } } } /** * @param {any} error * @param {any[] | null} args */ function formatErrorWithArgs(error, args) { let errorString = "stack" in error ? error.stack.toString() : error.toString(); if (args) { errorString += `\nFor request: [${ args.map((v) => JSON.stringify(v)).join(", ") }]`; } return errorString; } /** * @param {number} id * @param {string} method * @param {any[]} args * @param {string | null} scope * @param {PendingChange | null} maybeChange */ function serverRequest(id, method, args, scope, maybeChange) { debug(`serverRequest()`, id, method, args, scope, maybeChange); if (maybeChange !== null) { const changedScripts = maybeChange[0]; const newProjectVersion = maybeChange[1]; const newConfigsByScope = maybeChange[2]; if (newConfigsByScope) { IS_NODE_SOURCE_FILE_CACHE.clear(); ASSET_SCOPES.clear(); /** @type { typeof LANGUAGE_SERVICE_ENTRIES.byScope } */ const newByScope = new Map(); for (const [scope, config] of newConfigsByScope) { LAST_REQUEST_SCOPE.set(scope); const oldEntry = LANGUAGE_SERVICE_ENTRIES.byScope.get(scope); const ls = oldEntry ? oldEntry.ls : ts.createLanguageService(host, documentRegistry); const compilerOptions = lspTsConfigToCompilerOptions(config); newByScope.set(scope, { ls, compilerOptions }); LANGUAGE_SERVICE_ENTRIES.byScope.delete(scope); } for (const oldEntry of LANGUAGE_SERVICE_ENTRIES.byScope.values()) { oldEntry.ls.dispose(); } LANGUAGE_SERVICE_ENTRIES.byScope = newByScope; } PROJECT_VERSION_CACHE.set(newProjectVersion); let opened = false; let closed = false; for (const { 0: script, 1: changeKind } of changedScripts) { if (changeKind === ChangeKind.Opened) { opened = true; } else if (changeKind === ChangeKind.Closed) { closed = true; } SCRIPT_VERSION_CACHE.delete(script); SCRIPT_SNAPSHOT_CACHE.delete(script); } if (newConfigsByScope || opened || closed) { clearScriptNamesCache(); } } // For requests pertaining to an asset document, we make it so that the // passed scope is just its own specifier. We map it to an actual scope here // based on the first scope that the asset was loaded into. if (scope?.startsWith(ASSETS_URL_PREFIX)) { scope = ASSET_SCOPES.get(scope) ?? null; } LAST_REQUEST_METHOD.set(method); LAST_REQUEST_SCOPE.set(scope); const ls = (scope ? LANGUAGE_SERVICE_ENTRIES.byScope.get(scope)?.ls : null) ?? LANGUAGE_SERVICE_ENTRIES.unscoped.ls; switch (method) { case "$getSupportedCodeFixes": { return respond( id, ts.getSupportedCodeFixes(), ); } case "$getAssets": { return respond(id, getAssets()); } case "$getDiagnostics": { const projectVersion = args[1]; // there's a possibility that we receive a change notification // but the diagnostic server queues a `$getDiagnostics` request // with a stale project version. in that case, treat it as cancelled // (it's about to be invalidated anyway). const cachedProjectVersion = PROJECT_VERSION_CACHE.get(); if (cachedProjectVersion && projectVersion !== cachedProjectVersion) { return respond(id, {}); } try { /** @type {Record} */ const diagnosticMap = {}; for (const specifier of args[0]) { diagnosticMap[specifier] = fromTypeScriptDiagnostics([ ...ls.getSemanticDiagnostics(specifier), ...ls.getSuggestionDiagnostics(specifier), ...ls.getSyntacticDiagnostics(specifier), ].filter(filterMapDiagnostic)); } return respond(id, diagnosticMap); } catch (e) { if ( !isCancellationError(e) ) { return respond( id, {}, formatErrorWithArgs(e, [id, method, args, scope, maybeChange]), ); } return respond(id, {}); } } default: if (typeof ls[method] === "function") { // The `getCompletionEntryDetails()` method returns null if the // `source` is `null` for whatever reason. It must be `undefined`. if (method == "getCompletionEntryDetails") { args[4] ??= undefined; } try { return respond(id, ls[method](...args)); } catch (e) { if (!isCancellationError(e)) { return respond( id, null, formatErrorWithArgs(e, [id, method, args, scope, maybeChange]), ); } return respond(id); } } throw new TypeError( // @ts-ignore exhausted case statement sets type to never `Invalid request method for request: "${method}" (${id})`, ); } }