0
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-02-23 13:43:31 -05:00
denoland-deno/cli/tsc/98_lsp.js
Nathan Whitaker 174e496847
refactor: remove tsc snapshot (#27987)
Removes the TSC snapshot to unblock the V8 upgrade (which enables shared
RO heap, and is incompatible with multiple snapshots).


this compresses the sources and declaration files as well, which leads
to a roughly 4.2MB reduction in binary size.

this currently adds about 80ms to deno check times, but code cache isn't
wired up for the extension code (namely `00_typescript.js`) yet

```
❯ hyperfine --warmup 5 -p "rm -rf ~/Library/Caches/deno/check_cache_v2" "../../deno/target/release-lite/deno check main.ts" "deno check main.ts"
Benchmark 1: ../../deno/target/release-lite/deno check main.ts
  Time (mean ± σ):     184.2 ms ±   2.2 ms    [User: 378.3 ms, System: 48.2 ms]
  Range (min … max):   181.5 ms … 189.9 ms    15 runs

Benchmark 2: deno check main.ts
  Time (mean ± σ):     107.4 ms ±   1.2 ms    [User: 155.3 ms, System: 23.9 ms]
  Range (min … max):   105.3 ms … 109.6 ms    26 runs

Summary
  deno check main.ts ran
    1.72 ± 0.03 times faster than ../../deno/target/release-lite/deno check main.ts
```

---------

Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
2025-02-10 16:39:18 +00:00

519 lines
14 KiB
JavaScript

// Copyright 2018-2025 the Deno authors. MIT license.
import {
ASSET_SCOPES,
ASSETS_URL_PREFIX,
clearScriptNamesCache,
debug,
error,
filterMapDiagnostic,
fromTypeScriptDiagnostics,
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;
/** @type {Map<string | null, string[]>} */
const ambientModulesCacheByScope = new Map();
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 {string[]} a
* @param {string[]} b
*/
function arraysEqual(a, b) {
if (a === b) {
return true;
}
if (a === null || b === null) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
/**
* @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 "$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, [{}, null]);
}
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));
}
let ambient =
ls.getProgram()?.getTypeChecker().getAmbientModules().map((symbol) =>
symbol.getName()
) ?? [];
const previousAmbient = ambientModulesCacheByScope.get(scope);
if (
ambient && previousAmbient && arraysEqual(ambient, previousAmbient)
) {
ambient = null; // null => use previous value
} else {
ambientModulesCacheByScope.set(scope, ambient);
}
return respond(id, [diagnosticMap, ambient]);
} catch (e) {
if (
!isCancellationError(e)
) {
return respond(
id,
[{}, null],
formatErrorWithArgs(e, [id, method, args, scope, maybeChange]),
);
}
return respond(id, [{}, null]);
}
}
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})`,
);
}
}