mirror of
https://github.com/denoland/deno.git
synced 2025-02-01 12:16:11 -05:00
ab18dac09d
This is a pure refactor, the `99_main_compiler.js` file was getting out of hand, being over 1500 lines and serving 3 distinct purposes: - snapshotting - type-checking - running LSP The file was split into: - 97_ts_host.js - 98_lsp.js - 99_main_compiler.js
486 lines
13 KiB
JavaScript
486 lines
13 KiB
JavaScript
// 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<string, ts.SourceFile>} */
|
|
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<string, unknown>} 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<string, unknown>} 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<T> */
|
|
|
|
/** @returns {Promise<[number, string, any[], string | null, Option<PendingChange>] | 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<string, any[]>} */
|
|
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})`,
|
|
);
|
|
}
|
|
}
|