UNPKG

@typespec/compiler

Version:

TypeSpec compiler and standard library

1,092 lines 61.2 kB
import { CodeActionKind, CompletionList, DiagnosticSeverity, DiagnosticTag, DocumentHighlightKind, FileChangeType, MarkupKind, Range, SemanticTokensBuilder, TextDocumentSyncKind, TextEdit, } from "vscode-languageserver"; import { getSymNode } from "../core/binder.js"; import { resolveCodeFix } from "../core/code-fixes.js"; import { compilerAssert, getSourceLocation } from "../core/diagnostics.js"; import { formatTypeSpec } from "../core/formatter.js"; import { getEntityName, getTypeName } from "../core/helpers/type-name-utils.js"; import { builtInLinterRule_UnusedTemplateParameter } from "../core/linter-rules/unused-template-parameter.rule.js"; import { builtInLinterRule_UnusedUsing } from "../core/linter-rules/unused-using.rule.js"; import { builtInLinterLibraryName } from "../core/linter.js"; import { formatLog } from "../core/logger/index.js"; import { getPositionBeforeTrivia } from "../core/parser-utils.js"; import { getNodeAtPosition, getNodeAtPositionDetail, visitChildren } from "../core/parser.js"; import { ensureTrailingDirectorySeparator, getDirectoryPath, joinPaths, normalizePath, resolvePath, } from "../core/path-utils.js"; import { skipTrivia, skipWhiteSpace } from "../core/scanner.js"; import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; import { createRemoveUnusedSuppressionCodeFix } from "../core/suppression-tracking.js"; import { NoTarget, SyntaxKind, } from "../core/types.js"; import { getTypeSpecCoreTemplates } from "../init/core-templates.js"; import { validateTemplateDefinitions } from "../init/init-template-validate.js"; import { scaffoldNewProject } from "../init/scaffold.js"; import { typespecVersion } from "../manifest.js"; import { resolveModule } from "../module-resolver/index.js"; import { listAllFilesInDir } from "../utils/fs-utils.js"; import { getNormalizedRealPath, resolveTspMain } from "../utils/misc.js"; import { getSemanticTokens } from "./classify.js"; import { createCompileService } from "./compile-service.js"; import { resolveCompletion } from "./completion.js"; import { convertDiagnosticToLsp } from "./diagnostics.js"; import { createFileService } from "./file-service.js"; import { createFileSystemCache } from "./file-system-cache.js"; import { LibraryProvider } from "./lib-provider.js"; import { NpmPackageProvider } from "./npm-package-provider.js"; import { getRenameImportEdit, getUpdatedImportValue } from "./rename-file.js"; import { getSymbolStructure } from "./symbol-structure.js"; import { provideTspconfigCompletionItems } from "./tspconfig/completion.js"; import { getParameterDocumentation, getSymbolDetails, getTemplateParameterDocumentation, } from "./type-details.js"; import { SemanticTokenKind, } from "./types.js"; import { UpdateManager } from "./update-manager.js"; export function createServer(host, clientConfigsProvider) { const fileService = createFileService({ serverHost: host }); // Cache all file I/O. Only open documents are sent over the LSP pipe. When // the compiler reads a file that isn't open, we use this cache to avoid // hitting the disk. Entries are invalidated when LSP client notifies us of // a file change. const fileSystemCache = createFileSystemCache({ fileService, log, }); const compilerHost = createCompilerHost(); const npmPackageProvider = new NpmPackageProvider(compilerHost); const emitterProvider = new LibraryProvider(npmPackageProvider, (exports) => exports.$onEmit !== undefined); const linterProvider = new LibraryProvider(npmPackageProvider, (exports) => exports.$linter !== undefined); const updateManager = new UpdateManager("doc-update", log); const signatureHelpUpdateManager = new UpdateManager("signature-help", log, host.getDocumentUpdateDebounceDelay); const compileService = createCompileService({ fileService, fileSystemCache, compilerHost, serverHost: host, updateManager, log, clientConfigsProvider, }); let currentDiagnosticIndex = new Map(); let diagnosticIdCounter = 0; let workspaceFolders = []; let isInitialized = false; let pendingMessages = []; /** * Wraps an LSP handler to preserve the server-side error details when it crashes. * * By default, the JSON-RPC layer (vscode-languageserver) catches handler errors and * creates a new ResponseError using only `error.message`, discarding the original stack * trace. On the client side, the telemetry framework then captures this as an unhandled * error, but the `unhandled_error_stack` only shows the client-side message handling code: * * ``` * Error: Request textDocument/hover failed with message: Cannot read properties of undefined (reading 'kind') * at handleResponse (extension.cjs:2104:40) // <-- client-side LSP message handler * at handleMessage (extension.cjs:1914:11) * at processMessageQueue (extension.cjs:1929:13) * at Immediate.<anonymous> (extension.cjs:1905:11) * ``` * * The actual server-side crash location (e.g., in the checker or parser) is completely lost. * * This wrapper catches the error first and re-throws a new Error whose message includes * the full original error details (stack trace for Error instances, String() for others). * The JSON-RPC layer then forwards this enriched message to the client, so the * telemetry `unhandled_error_message` will contain the server-side crash location: * * ``` * [getHover] TypeError: Cannot read properties of undefined (reading 'kind') * at Checker.getTypeForNode (checker.ts:1234:15) // <-- actual crash location * at getHover (serverlib.ts:826:52) * ... * ``` */ function wrapUnhandledError(fn) { const name = fn.name || "anonymous"; return (async (...args) => { try { return await fn(...args); } catch (e) { if (e instanceof Error) { const detail = e.stack ? `${e.message}\n${e.stack}` : e.message; throw new Error(`[${name}] ${detail}`, { cause: e }); } else if (typeof e === "string") { throw new Error(`[${name}] ${e}`, { cause: e }); } else if (typeof e === "object" && e !== null) { let detail; try { detail = JSON.stringify(e); } catch { throw e; } throw new Error(`[${name}] ${detail}`, { cause: e }); } throw e; } }); } return { get pendingMessages() { return pendingMessages; }, get workspaceFolders() { return workspaceFolders; }, compile: wrapUnhandledError(compile), initialize: wrapUnhandledError(initialize), initialized: wrapUnhandledError(initialized), workspaceFoldersChanged: wrapUnhandledError(workspaceFoldersChanged), watchedFilesChanged: wrapUnhandledError(watchedFilesChanged), formatDocument: wrapUnhandledError(formatDocument), gotoDefinition: wrapUnhandledError(gotoDefinition), documentClosed: wrapUnhandledError(documentClosed), documentOpened: wrapUnhandledError(documentOpened), complete: wrapUnhandledError(complete), findReferences: wrapUnhandledError(findReferences), findDocumentHighlight: wrapUnhandledError(findDocumentHighlight), prepareRename: wrapUnhandledError(prepareRename), rename: wrapUnhandledError(rename), renameFiles: wrapUnhandledError(renameFiles), getSemanticTokens: wrapUnhandledError(getSemanticTokensForDocument), buildSemanticTokens: wrapUnhandledError(buildSemanticTokens), checkChange: wrapUnhandledError(checkChange), getFoldingRanges: wrapUnhandledError(getFoldingRanges), getHover: wrapUnhandledError(getHover), getSignatureHelp: wrapUnhandledError(getSignatureHelp), getDocumentSymbols: wrapUnhandledError(getDocumentSymbols), getCodeActions: wrapUnhandledError(getCodeActions), resolveCodeAction: wrapUnhandledError(resolveCodeAction), log, reportDiagnostics: wrapUnhandledError(reportDiagnostics), getInitProjectContext: wrapUnhandledError(getInitProjectContext), validateInitProjectTemplate: wrapUnhandledError(validateInitProjectTemplate), initProject: wrapUnhandledError(initProject), internalCompile: wrapUnhandledError(internalCompile), }; async function initialize(params) { const tokenLegend = { tokenTypes: Object.keys(SemanticTokenKind) .filter((x) => Number.isNaN(Number(x))) .map((x) => x.slice(0, 1).toLocaleLowerCase() + x.slice(1)), tokenModifiers: [], }; updateManager.setCallback(async (updates) => { if (updates.length === 0) { return; } // we only need to compile the last update, the previous update will be overwritten by the last one anyway const lastUpdate = updates.reduce((pre, cur) => { return cur.latestUpdateTimestamp > pre.latestUpdateTimestamp ? cur : pre; }); const curVersion = updateManager.docChangedVersion; const result = await compileService.compile(lastUpdate.latest, undefined, { mode: "full", // the callback should be or have been triggered for the new version, so this one should be // cancelled in this case isCancelled: () => updateManager.docChangedVersion > curVersion, }); if (result && curVersion === updateManager.docChangedVersion) { await reportDiagnostics(result); } }); signatureHelpUpdateManager.setCallback(async (_updates, triggeredBy) => { // for signature help, we should always compile against the document that triggered the request and core mode is enough for us // debounce can help to avoid the unnecessary triggering and compiler cache should be able to avoid the duplicates compile return await compileInCoreMode(triggeredBy); }); const capabilities = { textDocumentSync: TextDocumentSyncKind.Incremental, definitionProvider: true, foldingRangeProvider: true, hoverProvider: true, documentSymbolProvider: true, documentHighlightProvider: true, completionProvider: { resolveProvider: false, triggerCharacters: [".", "@", "/"], }, semanticTokensProvider: { full: true, legend: tokenLegend, }, referencesProvider: true, renameProvider: { prepareProvider: true, }, documentFormattingProvider: true, signatureHelpProvider: { triggerCharacters: ["(", ",", "<"], retriggerCharacters: [")"], }, codeActionProvider: { codeActionKinds: ["quickfix"], resolveProvider: true, }, }; if (params.capabilities.workspace?.workspaceFolders) { for (const w of params.workspaceFolders ?? []) { workspaceFolders.push({ ...w, path: ensureTrailingDirectorySeparator(await fileService.fileURLToRealPath(w.uri)), }); } capabilities.workspace = { workspaceFolders: { supported: true, changeNotifications: true, }, fileOperations: { didRename: { filters: [ { scheme: "file", pattern: { glob: "**/*.tsp", matches: "file" }, }, { scheme: "file", pattern: { glob: "**/*", matches: "folder" }, }, ], }, }, }; // eslint-disable-next-line @typescript-eslint/no-deprecated } else if (params.rootUri) { workspaceFolders = [ { name: "<root>", // eslint-disable-next-line @typescript-eslint/no-deprecated uri: params.rootUri, path: ensureTrailingDirectorySeparator( // eslint-disable-next-line @typescript-eslint/no-deprecated await fileService.fileURLToRealPath(params.rootUri)), }, ]; // eslint-disable-next-line @typescript-eslint/no-deprecated } else if (params.rootPath) { workspaceFolders = [ { name: "<root>", // eslint-disable-next-line @typescript-eslint/no-deprecated uri: compilerHost.pathToFileURL(params.rootPath), path: ensureTrailingDirectorySeparator( // eslint-disable-next-line @typescript-eslint/no-deprecated await getNormalizedRealPath(compilerHost, params.rootPath)), }, ]; } log({ level: "info", message: `Workspace Folders`, detail: workspaceFolders }); const customCapacities = { getInitProjectContext: true, initProject: true, validateInitProjectTemplate: true, internalCompile: true, }; // the file path is expected to be .../@typespec/compiler/dist/src/server/serverlib.js const curFile = normalizePath(compilerHost.fileURLToPath(import.meta.url)); const SERVERLIB_PATH_ENDWITH = "/dist/src/server/serverlib.js"; let compilerRootFolder = undefined; if (!curFile.endsWith(SERVERLIB_PATH_ENDWITH)) { // Ignore this could be the playground or standalone cli } else { compilerRootFolder = curFile.slice(0, curFile.length - SERVERLIB_PATH_ENDWITH.length); } const result = { serverInfo: { name: "TypeSpec Language Server", version: typespecVersion, }, capabilities, customCapacities, compilerRootFolder, compilerCliJsPath: compilerRootFolder ? joinPaths(compilerRootFolder, "cmd", "tsp.js") : undefined, }; return result; } function initialized(params) { updateManager.start(); signatureHelpUpdateManager.start(); isInitialized = true; log({ level: "info", message: "Initialization complete." }); } async function getInitProjectContext() { return { coreInitTemplates: await getTypeSpecCoreTemplates(host.compilerHost), }; } async function compile(document, additionalOptions, serverCompileOptions) { return compileService.compile(document, additionalOptions, serverCompileOptions); } async function compileInCoreMode(document) { return compile(document, undefined, { mode: "core" }); } async function validateInitProjectTemplate(param) { const { template } = param; // even when the strict validation fails, we still try to proceed with relaxed validation // so just do relaxed validation directly here const validationResult = validateTemplateDefinitions(template, NoTarget, false); if (!validationResult.valid) { for (const diag of validationResult.diagnostics) { log({ level: diag.severity, message: diag.message, detail: { code: diag.code, url: diag.url, }, }); } } return validationResult.valid; } async function initProject(param) { try { await scaffoldNewProject(compilerHost, param.config); return true; } catch (e) { log({ level: "error", message: "Unexpected error when initializing project", detail: e }); return false; } } async function internalCompile(param) { const option = { ...param.options, }; const result = await compileService.compile(param.doc, option, { skipCache: true, skipOldProgramFromCache: true, mode: "full", }); if (result === undefined) { return { hasError: true, diagnostics: [ { code: "internal-error", message: "Failed to get compiler result, please check the compilation output for details", severity: "error", target: NoTarget, url: undefined, }, ], entrypoint: undefined, options: undefined, }; } else { result.tracker.getLogs().forEach((logItem) => host.log(logItem)); return { hasError: result.program.hasError(), diagnostics: result.program.diagnostics.map((diagnostic) => { const target = getSourceLocation(diagnostic.target, { locateId: true }); let position = undefined; if (target?.file) { const lineAndCharacter = target.file.getLineAndCharacterOfPosition(target.pos); position = { line: lineAndCharacter.line + 1, column: lineAndCharacter.character + 1, }; } return { code: diagnostic.code, message: diagnostic.message, severity: diagnostic.severity, target: { ...target, position: position }, url: diagnostic.url, }; }), entrypoint: result.document?.uri, options: result.program.compilerOptions, }; } } async function renameFiles(params) { const firstFilePath = params.files[0]; if (!firstFilePath) { return; } // Update cache for renamed folders. for (const file of params.files) { const oldFilePath = await fileService.getPath({ uri: file.oldUri }); const newFilePath = await fileService.getPath({ uri: file.newUri }); const isDirRename = !file.oldUri.endsWith(".tsp"); if (isDirRename) { const files = await listAllFilesInDir(compilerHost, newFilePath); for (const file of files) { const oldFile = resolvePath(oldFilePath, file); fileSystemCache.notify([ { uri: fileService.getURL(oldFile), type: FileChangeType.Deleted }, ]); } } } const mainFile = await compileService.getMainFileForDocument(await fileService.getPath({ uri: firstFilePath.newUri })); if (mainFile === undefined) { log({ level: "debug", message: `failed to resolve main file for ${firstFilePath.newUri}` }); return; } // Add this method to resolve timing issues between renamed files and `fs.stat` // to prevent `fs.stat` from getting the files before modification. // Currently the test requires a delay of 300ms await new Promise((resolve) => setTimeout(resolve, 300)); // There will be no event triggered if the renamed file is not opened in vscode, also even when it's opened // there will be only closed and opened event triggered for the old and new file url, so send fire the update // explicitly here to make sure the change is not missed. void updateManager.scheduleUpdate({ uri: fileService.getURL(mainFile) }, "renamed"); const result = await compileInCoreMode({ uri: fileService.getURL(mainFile) }); if (!result) { log({ level: "debug", message: `The main tsp file '${mainFile}' compilation failed, please check the detailed compilation message`, }); return; } for (const file of params.files) { const oldFilePath = await fileService.getPath({ uri: file.oldUri }); const newFilePath = await fileService.getPath({ uri: file.newUri }); const isDirRename = !file.oldUri.endsWith(".tsp"); for (const diagnostic of result.program.diagnostics) { if (diagnostic.code !== "import-not-found") continue; const target = diagnostic.target; if (target.kind === SyntaxKind.ImportStatement) { const result = getUpdatedImportValue(target, { oldPath: oldFilePath, newPath: newFilePath, isDirRename, }); if (result) { log({ level: "info", message: `The imports content '${target.path.value}' needs to be modified in tsp file '${result.filePath}' to '${result.newValue}'`, }); const edit = getRenameImportEdit(target, result.newValue); await host.applyEdit({ changes: { [fileService.getURL(result.filePath)]: [edit] } }); } } } } } async function workspaceFoldersChanged(e) { log({ level: "info", message: "Workspace Folders Changed", detail: e }); const map = new Map(workspaceFolders.map((f) => [f.uri, f])); for (const folder of e.removed) { map.delete(folder.uri); } for (const folder of e.added) { map.set(folder.uri, { ...folder, path: ensureTrailingDirectorySeparator(await fileService.fileURLToRealPath(folder.uri)), }); } workspaceFolders = Array.from(map.values()); log({ level: "info", message: `Workspace Folders`, detail: workspaceFolders }); } function watchedFilesChanged(params) { fileSystemCache.notify(params.changes); npmPackageProvider.notify(params.changes); } function isTspConfigFile(doc) { return doc.uri.endsWith("tspconfig.yaml"); } async function getFoldingRanges(params) { if (isTspConfigFile(params.textDocument)) return []; const ast = await compileService.getScript(params.textDocument); if (!ast) { return []; } const file = ast.file; const ranges = []; let rangeStartSingleLines = -1; for (let i = 0; i < ast.comments.length; i++) { const comment = ast.comments[i]; if (comment.kind === SyntaxKind.LineComment && i + 1 < ast.comments.length && ast.comments[i + 1].kind === SyntaxKind.LineComment && ast.comments[i + 1].pos === skipWhiteSpace(file.text, comment.end)) { if (rangeStartSingleLines === -1) { rangeStartSingleLines = comment.pos; } } else if (rangeStartSingleLines !== -1) { addRange(rangeStartSingleLines, comment.end); rangeStartSingleLines = -1; } else { addRange(comment.pos, comment.end); } } visitChildren(ast, addRangesForNode); function addRangesForNode(node) { if (node.kind === SyntaxKind.Doc) { return; // fold doc comments as regular comments } let nodeStart = node.pos; if ("decorators" in node && node.decorators.length > 0) { const decoratorEnd = node.decorators[node.decorators.length - 1].end; addRange(nodeStart, decoratorEnd); nodeStart = skipTrivia(file.text, decoratorEnd); } addRange(nodeStart, node.end); visitChildren(node, addRangesForNode); } return ranges; function addRange(startPos, endPos) { const start = file.getLineAndCharacterOfPosition(startPos); const end = file.getLineAndCharacterOfPosition(endPos); if (start.line !== end.line) { ranges.push({ startLine: start.line, startCharacter: start.character, endLine: end.line, endCharacter: end.character, }); } } } async function getDocumentSymbols(params) { if (isTspConfigFile(params.textDocument)) return []; const ast = await compileService.getScript(params.textDocument); if (!ast) { return []; } return getSymbolStructure(ast); } async function findDocumentHighlight(params) { if (isTspConfigFile(params.textDocument)) return []; const result = await compileInCoreMode(params.textDocument); if (result === undefined) { return []; } const { program, document, script } = result; if (!document || !script) { return []; } const identifiers = findReferenceIdentifiers(program, script, document.offsetAt(params.position), [script]); return identifiers.map((identifier) => ({ range: getRange(identifier, script.file), kind: DocumentHighlightKind.Read, })); } function checkChange(change) { const initVersion = fileService.getOpenDocumentInitVersion(change.document.uri); if (!initVersion) { // not expected, log something for troubleshooting log({ level: "debug", message: "Document change received for document not in documentsOpened set", detail: change.document.uri, }); documentOpened(change); } else if (initVersion < change.document.version) { compileService.notifyChange(change.document, "changed"); } else { // do nothing for initVersion === change.document.version which should have been handled by didOpen event } } function documentOpened(change) { fileService.notifyDocumentOpened(change); compileService.notifyChange(change.document, "opened"); } async function reportDiagnostics({ program, document, optionsFromConfig }) { if (!document) return undefined; if (isTspConfigFile(document)) return undefined; const newDiagnosticIndex = new Map(); // Group diagnostics by file. // // Initialize diagnostics for all source files in program to empty array // as we must send an empty array when a file has no diagnostics or else // stale diagnostics from a previous run will stick around in the IDE. // const diagnosticMap = new Map(); diagnosticMap.set(document, []); for (const each of program.sourceFiles.values()) { const saved = each.file?.document; if (saved) { diagnosticMap.set(saved, []); } else { // The file may be opened later when the compile used as cache // so double check the fileService for opened document. Since // TextDocuments will always return the same TextDocument instance // so we should be good to use the TextDocument as the key here. const opened = fileService.getOpenDocument(each.file.path); if (opened) { diagnosticMap.set(opened, []); } } } for (const each of program.diagnostics) { const results = convertDiagnosticToLsp(fileService, program, document, each); for (const result of results) { const [diagnostic, diagDocument] = result; if (each.url) { diagnostic.codeDescription = { href: each.url, }; } const unusedUsingRule = `${builtInLinterLibraryName}/${builtInLinterRule_UnusedUsing}`; const unusedTemlateParameterRule = `${builtInLinterLibraryName}/${builtInLinterRule_UnusedTemplateParameter}`; if (each.code === "deprecated") { diagnostic.tags = [DiagnosticTag.Deprecated]; } else if (each.code === unusedUsingRule) { // Unused or unnecessary code. Diagnostics with this tag are rendered faded out, so no extra work needed from IDE side // https://vscode-api.js.org/enums/vscode.DiagnosticTag.html#google_vignette // https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.languageserver.protocol.diagnostictag?view=visualstudiosdk-2022 diagnostic.tags = [DiagnosticTag.Unnecessary]; if (optionsFromConfig.linterRuleSet?.enable?.[unusedUsingRule] === undefined && optionsFromConfig.linterRuleSet?.disable?.[unusedUsingRule] === undefined) { // if the unused using is not configured by user explicitly, report it as hint by default diagnostic.severity = DiagnosticSeverity.Hint; } } else if (each.code === unusedTemlateParameterRule) { // Unused or unnecessary code. Diagnostics with this tag are rendered faded out, so no extra work needed from IDE side // https://vscode-api.js.org/enums/vscode.DiagnosticTag.html#google_vignette // https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.languageserver.protocol.diagnostictag?view=visualstudiosdk-2022 diagnostic.tags = [DiagnosticTag.Unnecessary]; if (optionsFromConfig.linterRuleSet?.enable?.[unusedTemlateParameterRule] === undefined && optionsFromConfig.linterRuleSet?.disable?.[unusedTemlateParameterRule] === undefined) { // if the unused template parameter is not configured by user explicitly, report it as hint by default diagnostic.severity = DiagnosticSeverity.Hint; } } diagnostic.data = { id: diagnosticIdCounter++, file: diagDocument.uri, }; const diagnostics = diagnosticMap.get(diagDocument); compilerAssert(diagnostics, "Diagnostic reported against a source file that was not added to the program."); diagnostics.push(diagnostic); newDiagnosticIndex.set(diagnostic.data.id, each); } } // Report unused suppressions as hints with faded-out styling for (const { directive } of program.suppressionTracker?.getUnusedSuppressions() ?? []) { const unusedSuppressionDiagnostic = { code: "unused-suppression", severity: "warning", message: `Suppression for "${directive.code}" is unused.`, target: directive.node, codefixes: [createRemoveUnusedSuppressionCodeFix(directive.node)], }; const results = convertDiagnosticToLsp(fileService, program, document, unusedSuppressionDiagnostic); for (const [diagnostic, diagDocument] of results) { diagnostic.tags = [DiagnosticTag.Unnecessary]; diagnostic.severity = DiagnosticSeverity.Hint; diagnostic.data = { id: diagnosticIdCounter++, file: diagDocument.uri, }; const diagnostics = diagnosticMap.get(diagDocument); compilerAssert(diagnostics, "Diagnostic reported against a source file that was not added to the program."); diagnostics.push(diagnostic); newDiagnosticIndex.set(diagnostic.data.id, unusedSuppressionDiagnostic); } } // Atomically swap the diagnostic index so that in-flight code action resolves // referencing old diagnostic IDs can still find their entries until new diagnostics are sent. currentDiagnosticIndex = newDiagnosticIndex; for (const [document, diagnostics] of diagnosticMap) { sendDiagnostics(document, diagnostics); } } async function getHover(params) { if (isTspConfigFile(params.textDocument)) return { contents: [] }; const result = await compileInCoreMode(params.textDocument); if (result === undefined) { return { contents: [] }; } const { program, document, script } = result; if (!document || !script) { return { contents: [] }; } const id = getNodeAtPosition(script, document.offsetAt(params.position)); const sym = id?.kind === SyntaxKind.Identifier ? program.checker.resolveRelatedSymbols(id) : undefined; if (!sym || sym.length === 0) { return { contents: { kind: MarkupKind.Markdown, value: "" } }; } else { // Only show full definition if the symbol is a model or interface that has extends or is clauses. // Avoid showing full definition in other cases which can be long and not useful let includeExpandedDefinition = false; const sn = getSymNode(sym[0]); if (sn && sn.kind !== SyntaxKind.AliasStatement) { const type = sym[0].type ?? program.checker.getTypeOrValueForNode(sn); if (type && "kind" in type) { const modelHasExtendOrIs = type.kind === "Model" && (type.baseModel !== undefined || type.sourceModel !== undefined || type.sourceModels.length > 0); const interfaceHasExtend = type.kind === "Interface" && type.sourceInterfaces.length > 0; includeExpandedDefinition = modelHasExtendOrIs || interfaceHasExtend; } } const markdown = { kind: MarkupKind.Markdown, value: sym && sym.length > 0 ? await getSymbolDetails(program, sym[0], { includeSignature: true, includeParameterTags: true, includeExpandedDefinition, }) : "", }; return { contents: markdown, }; } } async function getSignatureHelp(params) { if (isTspConfigFile(params.textDocument)) return undefined; const result = await signatureHelpUpdateManager.scheduleUpdate(params.textDocument, "changed"); log({ level: "debug", message: `getSignatureHelp got compile result: isUndefined = ${result === undefined}`, }); if (result === undefined) { return undefined; } const { script, document, program } = result; if (!document || !script) { return undefined; } const data = getSignatureHelpNodeAtPosition(script, document.offsetAt(params.position)); if (data === undefined) { return undefined; } const { node, argumentIndex } = data; switch (node.kind) { case SyntaxKind.TypeReference: return getSignatureHelpForTemplate(program, node, argumentIndex); case SyntaxKind.DecoratorExpression: case SyntaxKind.AugmentDecoratorStatement: return getSignatureHelpForDecorator(program, node, argumentIndex); default: const _assertNever = node; compilerAssert(false, "Unreachable"); } } async function getSignatureHelpForTemplate(program, node, argumentIndex) { const sym = program.checker.resolveRelatedSymbols(node.target.kind === SyntaxKind.MemberExpression ? node.target.id : node.target); if (!sym || sym.length <= 0) { return undefined; } const templateDeclNode = sym[0].declarations[0]; if (!templateDeclNode || !("templateParameters" in templateDeclNode) || templateDeclNode.templateParameters.length === 0) { return undefined; } const parameterDocs = getTemplateParameterDocumentation(templateDeclNode); const parameters = templateDeclNode.templateParameters.map((x) => { const info = { label: x.id.sv }; const doc = parameterDocs.get(x.id.sv); if (doc) { info.documentation = { kind: MarkupKind.Markdown, value: doc }; } return info; }); const help = { signatures: [ { label: `${sym[0].name}<${parameters.map((x) => x.label).join(", ")}>`, parameters, activeParameter: Math.min(parameters.length - 1, argumentIndex), }, ], activeSignature: 0, activeParameter: 0, }; const doc = await getSymbolDetails(program, sym[0], { includeSignature: false, includeParameterTags: false, }); if (doc) { help.signatures[0].documentation = { kind: MarkupKind.Markdown, value: doc }; } return help; } async function getSignatureHelpForDecorator(program, node, argumentIndex) { const sym = program.checker.resolveRelatedSymbols(node.target.kind === SyntaxKind.MemberExpression ? node.target.id : node.target); if (!sym || sym.length <= 0) { return undefined; } const decoratorDeclNode = sym[0].declarations.find((x) => x.kind === SyntaxKind.DecoratorDeclarationStatement); if (decoratorDeclNode === undefined) { return undefined; } const type = program.checker.getTypeForNode(decoratorDeclNode); compilerAssert(type.kind === "Decorator", "Expected type to be a decorator."); const parameterDocs = getParameterDocumentation(program, type); let labelPrefix = ""; const parameters = []; if (node.kind === SyntaxKind.AugmentDecoratorStatement) { const targetType = decoratorDeclNode.target.type ? program.checker.getTypeForNode(decoratorDeclNode.target.type) : undefined; parameters.push({ label: `${decoratorDeclNode.target.id.sv}: ${targetType ? getTypeName(targetType) : "unknown"}`, }); labelPrefix = "@"; } parameters.push(...type.parameters.map((x) => { const info = { // prettier-ignore label: `${x.rest ? "..." : ""}${x.name}${x.optional ? "?" : ""}: ${getEntityName(x.type)}`, }; const doc = parameterDocs.get(x.name); if (doc) { info.documentation = { kind: MarkupKind.Markdown, value: doc }; } return info; })); const help = { signatures: [ { label: `${labelPrefix}${type.name}(${parameters.map((x) => x.label).join(", ")})`, parameters, activeParameter: Math.min(parameters.length - 1, argumentIndex), }, ], activeSignature: 0, activeParameter: 0, }; const doc = await getSymbolDetails(program, sym[0], { includeSignature: false, includeParameterTags: false, }); if (doc) { help.signatures[0].documentation = { kind: MarkupKind.Markdown, value: doc }; } return help; } async function formatDocument(params) { if (isTspConfigFile(params.textDocument)) return []; const document = host.getOpenDocumentByURL(params.textDocument.uri); if (document === undefined) { return []; } const path = await fileService.fileURLToRealPath(params.textDocument.uri); const prettierConfig = await resolvePrettierConfig(path); const resolvedConfig = prettierConfig ?? { tabWidth: params.options.tabSize, useTabs: !params.options.insertSpaces, }; log({ level: "info", message: `Formatting TypeSpec document: ${JSON.stringify({ fileUri: params.textDocument.uri, vscodeOptions: params.options, prettierConfig, resolvedConfig }, null, 2)}`, }); try { const formattedText = await formatTypeSpec(document.getText(), resolvedConfig); return [minimalEdit(document, formattedText)]; } catch (error) { log({ level: "error", message: `Failed to format TypeSpec document: ${params.textDocument.uri}`, detail: error, }); return []; } } async function resolvePrettierConfig(path) { try { // Resolve prettier if it is installed. const prettier = await import("prettier"); return prettier.resolveConfig(path); } catch (e) { return null; } } function minimalEdit(document, string1) { const string0 = document.getText(); // length of common prefix let i = 0; while (i < string0.length && i < string1.length && string0[i] === string1[i]) { ++i; } // length of common suffix let j = 0; while (i + j < string0.length && i + j < string1.length && string0[string0.length - j - 1] === string1[string1.length - j - 1]) { ++j; } const newText = string1.substring(i, string1.length - j); const pos0 = document.positionAt(i); const pos1 = document.positionAt(string0.length - j); return TextEdit.replace(Range.create(pos0, pos1), newText); } async function gotoDefinition(params) { if (isTspConfigFile(params.textDocument)) return []; const result = await compileInCoreMode(params.textDocument); if (result === undefined || result.document === undefined || result.script === undefined) { return []; } const node = getNodeAtPosition(result.script, result.document.offsetAt(params.position)); switch (node?.kind) { case SyntaxKind.Identifier: const sym = result.program.checker.resolveRelatedSymbols(node); return getLocations(sym && sym.length > 0 ? sym[0].declarations : undefined); case SyntaxKind.StringLiteral: if (node.parent?.kind === SyntaxKind.ImportStatement) { return [await getImportLocation(node.value, result.script)]; } else { return []; } } return []; } async function getImportLocation(importPath, currentFile) { const host = { realpath: compilerHost.realpath, readFile: async (path) => { const file = await compilerHost.readFile(path); return file.text; }, stat: compilerHost.stat, }; const resolved = await resolveModule(host, importPath, { baseDir: getDirectoryPath(currentFile.file.path), directoryIndexFiles: ["main.tsp", "index.mjs", "index.js"], resolveMain(pkg) { // this lets us follow node resolve semantics more-or-less exactly // but using tspMain instead of main. return resolveTspMain(pkg) ?? pkg.main; }, conditions: ["typespec"], fallbackOnMissingCondition: true, }); return { uri: fileService.getURL(resolved.type === "file" ? resolved.path : resolved.mainFile), range: Range.create(0, 0, 0, 0), }; } async function complete(params) { if (isTspConfigFile(params.textDocument)) { const doc = host.getOpenDocumentByURL(params.textDocument.uri); if (doc) { const items = await provideTspconfigCompletionItems(doc, params.position, { fileService, compilerHost, emitterProvider, linterProvider, log, }); return CompletionList.create(items); } return CompletionList.create([]); } const completions = { isIncomplete: false, items: [], }; const result = await compileInCoreMode(params.textDocument); if (result) { const { script, document, program } = result; if (!document || !script) { return completions; } const posDetail = getCompletionNodeAtPosition(script, document.offsetAt(params.position)); return await resolveCompletion({ program, file: script, completions, params, }, posDetail); } return completions; } async function findReferences(params) { if (isTspConfigFile(params.textDocument)) return []; const result = await compileInCoreMode(params.textDocument); if (result === undefined || result.document === undefined || result.script === undefined) { return []; } const identifiers = findReferenceIdentifiers(result.program, result.script, result.document.offsetAt(params.position)); return getLocations(identifiers); } async function prepareRename(params) { if (isTspConfigFile(params.textDocument)) return undefined; const result = await compileInCoreMode(params.textDocument); if (result === undefined || result.document === undefined || result.script === undefined) { return undefined; } const id = getNodeAtPosition(result.script, result.document.offsetAt(params.position)); return id?.kind === SyntaxKind.Identifier ? getLocation(id)?.range : undefined; } async function rename(params) { if (isTspConfigFile(params.textDocument)) return { changes: {} }; const changes = {}; const result = await compileInCoreMode(params.textDocument); if (result && result.document && result.script) { const identifiers = findReferenceIdentifiers(result.program, result.script, result.document.offsetAt(params.position)); for (const id of identifiers) { const location = getLocation(id); if (!location) { continue; } const change = TextEdit.replace(location.range, params.newName); if (location.uri in changes) { changes[location.uri].push(change); } else { changes[location.uri] = [change]; } } } return { changes }; } function findReferenceIdentifiers(program, file, pos, searchFiles = program.sourceFiles.values()) { const id = getNodeAtPosition(file, pos); if (id?.kind !== SyntaxKind.Identifier) { return []; } const sym = program.checker.resolveRelatedSymbols(id); if (!sym || sym.length <= 0) { return [id]; } const references = []; for (const searchFile of searchFiles) { visitChildren(searchFile, function visit(node) { if (node.kind === SyntaxKind.Identifier) { const s = program.checker.resolveRelatedSymbols(node); if (!s || s.length <= 0) { return; } if (s.some((candidate) => sym.some((target) => candidate === target || (target.type && candidate.type === target.type)))) { references.push(node); } } visitChildren(node, visit); }); } return references; } async function getSemanticTokensForDocument(params) { if (isTspConfigFile(params.textDocument)) return []; const ast = await compileService.getScri