mirror of
https://github.com/denoland/deno.git
synced 2025-02-08 07:16:56 -05:00
![Nathan Whitaker](/assets/img/avatar_default.png)
This makes it so imports of ambient modules (e.g. `$app/environment` in svelte, any virtual module in vite, or other module provided by a bundler) don't error in the LSP. The way this works is that when we request diagnostics from TSC, we also respond with the list of ambient modules. Then, in the diagnostics code, we save diagnostics (produced by deno) that may be invalidated as an ambient module and wait to publish the diagnostics until we've received the ambient modules from TSC. The actual ambient modules you get from TSC can contain globs, e.g. `*.css`. So when we get new ambient modules, we compile them all into a regex and check erroring imports against that regex. Ambient modules should change rarely, so in most cases we should be using a pre-compiled regex, which executes in linear time (wrt the specifier length). TODO: - Ideally we should only publish once, right now we publish with the filtered specifiers and then the TSC ones - deno check (#27633)
523 lines
14 KiB
JavaScript
523 lines
14 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;
|
|
|
|
/** @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 "$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, [{}, 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})`,
|
|
);
|
|
}
|
|
}
|