UNPKG

@glint/core

Version:

A CLI for performing typechecking on Glimmer templates

522 lines 26.3 kB
import { offsetToPosition, filePathToUri, uriToFilePath, scriptElementKindToCompletionItemKind, } from './util/index.js'; import { Location, CodeAction, CodeActionKind, TextDocumentEdit, OptionalVersionedTextDocumentIdentifier, TextEdit, } from 'vscode-languageserver'; import { positionToOffset } from './util/position.js'; import { scriptElementKindToSymbolKind, severityForDiagnostic, tagsForDiagnostic, } from './util/protocol.js'; import { getTagDocumentation, plain } from './util/previewer.js'; export default class GlintLanguageServer { constructor(glintConfig, documents, transformManager) { this.glintConfig = glintConfig; this.documents = documents; this.transformManager = transformManager; let parsedConfig = this.parseTsconfig(glintConfig, transformManager); this.ts = glintConfig.ts; this.openFileNames = new Set(); this.rootFileNames = new Set(parsedConfig.fileNames); let exportMapCache = null; const ts = this.glintConfig.ts; let program; let serviceHost = { getScriptFileNames: () => [...new Set(this.allKnownFileNames())], getScriptVersion: (fileName) => this.documents.getDocumentVersion(fileName), getScriptSnapshot: (fileName) => { let contents = this.transformManager.readTransformedFile(fileName); if (typeof contents === 'string') { return this.ts.ScriptSnapshot.fromString(contents); } }, fileExists: this.transformManager.fileExists, readFile: this.transformManager.readTransformedFile, readDirectory: this.transformManager.readDirectory, // @ts-ignore: This hook was added in TS5, and is safely irrelevant in earlier versions. Once we drop support for 4.x, we can also remove this @ts-ignore comment. resolveModuleNameLiterals: this.transformManager.resolveModuleNameLiterals, getCompilationSettings: () => parsedConfig.options, // Yes, this looks like a mismatch, but built-in lib declarations don't resolve // correctly otherwise, and this is what the TS wiki uses in their code snippet. getDefaultLibFileName: this.ts.getDefaultLibFilePath, // TS defaults from here down getCurrentDirectory: this.ts.sys.getCurrentDirectory, directoryExists: this.ts.sys.directoryExists, getDirectories: this.ts.sys.getDirectories, realpath: this.ts.sys.realpath, // A proper choice for case sensitivity impacts things like resolving // relative paths for module specifiers for auto imports. useCaseSensitiveFileNames: () => this.ts.sys.useCaseSensitiveFileNames, // @ts-ignore Undocumented method. getCachedExportInfoMap() { // This hook is required so that when resolving a completion item, we can fetch export info // cached from the previous call to getCompletions. Without this, attempting to resolve a completion // item for exports that have at least 2 exports (due to re-exporting) will fail with an error. // See here for additional details on the ExportInfoMap. // https://github.com/microsoft/TypeScript/pull/52686 // @ts-ignore This method does actually exist since 4.4+, but not sure why it's not in the types return (exportMapCache || (exportMapCache = ts.createCacheableExportInfoMap({ getCurrentProgram: () => program, getPackageJsonAutoImportProvider: () => null, getGlobalTypingsCacheLocation: () => null, }))); }, // This can be temporarily uncommented when debugging the internal TS Language Server. // Logs will show up in the Debug Console. NOTE: don't change to console.log() because // it will interfere with transmitting messages back to the client. // log(message: string) { // console.error(message); // }, }; this.service = this.ts.createLanguageService(serviceHost); // Kickstart typechecking program = this.service.getProgram(); } dispose() { this.service.dispose(); } openFile(uri, contents) { let path = uriToFilePath(uri); this.documents.updateDocument(path, contents); this.openFileNames.add(this.transformManager.getScriptPathForTS(path)); } updateFile(uri, contents) { this.documents.updateDocument(uriToFilePath(uri), contents); } closeFile(uri) { let path = uriToFilePath(uri); this.documents.removeDocument(path); this.openFileNames.delete(this.transformManager.getScriptPathForTS(path)); } watchedFileWasAdded(uri) { let filePath = uriToFilePath(uri); if (filePath.startsWith(this.glintConfig.rootDir)) { this.rootFileNames.add(this.transformManager.getScriptPathForTS(filePath)); } // Adding or removing a file invalidates most of what we think we know about module resolution. this.transformManager.moduleResolutionCache.clear(); } watchedFileDidChange(uri) { this.documents.markDocumentStale(uriToFilePath(uri)); } watchedFileWasRemoved(uri) { let path = uriToFilePath(uri); this.documents.markDocumentStale(path); // We need to be slightly careful here, because if `foo.ts` and `foo.hbs` both exist and // only one is deleted, we shouldn't remove their joint document from `rootFileNames`. let companionPath = this.documents.getCompanionDocumentPath(path); if (!companionPath || this.glintConfig.getSynthesizedScriptPathForTS(companionPath) !== path) { this.rootFileNames.delete(this.glintConfig.getSynthesizedScriptPathForTS(path)); } // Adding or removing a file invalidates most of what we think we know about module resolution. this.transformManager.moduleResolutionCache.clear(); } getDiagnostics(uri) { let filePath = uriToFilePath(uri); let sourcePath = this.findDiagnosticsSource(filePath); if (!sourcePath) return []; let diagnostics = [ ...this.service.getSyntacticDiagnostics(sourcePath), ...this.transformManager.getTransformDiagnostics(sourcePath), ...this.service.getSemanticDiagnostics(sourcePath), ...this.service.getSuggestionDiagnostics(sourcePath), ]; return this.transformManager .rewriteDiagnostics(diagnostics, sourcePath) .flatMap((diagnostic) => { let { start = 0, length = 0, messageText, file } = diagnostic; if (!file || file.fileName !== filePath) return []; return { source: 'glint', code: diagnostic.code, severity: severityForDiagnostic(this.ts, diagnostic), message: this.ts.flattenDiagnosticMessageText(messageText, '\n'), tags: tagsForDiagnostic(diagnostic), range: { start: offsetToPosition(file.text, start), end: offsetToPosition(file.text, start + length), }, }; }); } findSymbols(query) { return this.service .getNavigateToItems(query) .map(({ name, kind, fileName, textSpan }) => { let location = this.textSpanToLocation(fileName, textSpan); if (location) { return { name, location, kind: scriptElementKindToSymbolKind(this.ts, kind) }; } }) .filter((info) => Boolean(info)); } getCompletions(uri, position, formatting = {}, preferences = {}) { let { transformedFileName, transformedOffset, mapping } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return; // If we're in a free-text region of a template, or if there's no mapping and yet // we're in a template file, then we have no completions to offer. if (mapping?.sourceNode.type === 'TextContent' || mapping?.sourceNode.type === 'TemplateEmbedding' || (!mapping && this.glintConfig.environment.isTemplate(uri))) { return; } let completions = this.service.getCompletionsAtPosition(transformedFileName, transformedOffset, preferences, formatting); return completions?.entries.map((completionEntry) => { const glintCompletionItem = { label: completionEntry.name, preselect: completionEntry.isRecommended ? true : undefined, kind: scriptElementKindToCompletionItemKind(this.ts, completionEntry.kind), labelDetails: { // This displays the module specifier for auto-imports, e.g. "../../component" or "@glimmer/component" description: completionEntry.data?.moduleSpecifier, }, // This data gets passed through to getCompletionDetails to fetch additional completion details data: { uri, transformedFileName, transformedOffset, source: completionEntry.source, tsData: completionEntry.data, }, sortText: completionEntry.sortText, }; return glintCompletionItem; }); } getCompletionDetails(item, formatting = {}, preferences = {}) { let { label, data } = item; if (!data) { return item; } let { transformedFileName, transformedOffset, source, tsData } = data; let details = this.service.getCompletionEntryDetails(transformedFileName, transformedOffset, label, formatting, source, preferences, tsData); if (!details) { return item; } item.detail = plain(this.ts.displayPartsToString(details.displayParts)); const documentation = { kind: 'markdown', value: '', }; if (details.codeActions) { // CodeActions (such as auto-imports) need to be converted to TextEdits // that will be applied when the user selects the Completion. item.additionalTextEdits = this.convertCodeActionToTextEdit(transformedFileName, details.codeActions); details.codeActions.forEach((action) => { if (action.description) { // Prefix details, e.g. 'Add import from "@glimmer/component"' item.detail = `${action.description}\n\n${item.detail}`; } }); } if (details?.documentation?.length) { documentation.value += this.ts.displayPartsToString(details.documentation) + '\n\n'; } if (details.tags) { if (details.tags) { details.tags.forEach((x) => { const tagDoc = getTagDocumentation(x); if (tagDoc) { documentation.value += tagDoc + '\n\n'; } }); } } // Clean up any extra newlines documentation.value = documentation.value.replace(/\n+$/, ''); item.detail = item.detail.replace(/\n+$/, ''); return { ...item, documentation, }; } convertCodeActionToTextEdit(uri, codeActions) { const textEdits = []; for (const action of codeActions) { for (const change of action.changes) { for (const textChange of change.textChanges) { const location = this.textSpanToLocation(uri, textChange.span); if (location) { textEdits.push({ range: location.range, newText: textChange.newText, }); } } } } return textEdits; } prepareRename(uri, position) { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return; let rename = this.service.getRenameInfo(transformedFileName, transformedOffset); if (rename.canRename) { let { originalStart, originalEnd } = this.transformManager.getOriginalRange(transformedFileName, rename.triggerSpan.start, rename.triggerSpan.start + rename.triggerSpan.length); let contents = this.documents.getDocumentContents(uriToFilePath(uri)); return { start: offsetToPosition(contents, originalStart), end: offsetToPosition(contents, originalEnd), }; } } getEditsForRename(uri, position, newText) { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return {}; let renameLocations = this.service.findRenameLocations(transformedFileName, transformedOffset, false, false); if (!renameLocations?.length) { return {}; } let changes = {}; for (let { fileName, textSpan } of renameLocations) { let { originalFileName, originalStart, originalEnd } = this.transformManager.getOriginalRange(fileName, textSpan.start, textSpan.start + textSpan.length); if (originalStart === originalEnd) { // Zero-length spans correspond to synthetic use (such as in the context type // of the template, which references the containing class), so we want to filter // those out. continue; } let originalContents = this.documents.getDocumentContents(originalFileName); let originalFileURI = filePathToUri(originalFileName); let changesForFile = (changes[originalFileURI] ?? (changes[originalFileURI] = [])); changesForFile.push({ newText, range: { start: offsetToPosition(originalContents, originalStart), end: offsetToPosition(originalContents, originalEnd), }, }); } return { changes }; } getHover(uri, position) { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return; let info = this.service.getQuickInfoAtPosition(transformedFileName, transformedOffset); if (!info) return; let value = this.ts.displayPartsToString(info.displayParts); let { originalFileName, originalStart, originalEnd } = this.transformManager.getOriginalRange(transformedFileName, info.textSpan.start, info.textSpan.start + info.textSpan.length); let originalContents = this.documents.getDocumentContents(originalFileName); let start = offsetToPosition(originalContents, originalStart); let end = offsetToPosition(originalContents, originalEnd); let contents = [{ language: 'ts', value }]; if (info.documentation?.length) { contents.push(this.ts.displayPartsToString(info.documentation)); } return { contents, range: { start, end } }; } getDefinition(uri, position) { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return []; let definitions = this.service.getDefinitionAtPosition(transformedFileName, transformedOffset) ?? []; return this.calculateOriginalLocations(definitions); } getReferences(uri, position) { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return []; let references = this.service.getReferencesAtPosition(transformedFileName, transformedOffset) ?? []; return this.calculateOriginalLocations(references); } getOriginalContents(uri) { let filePath = uriToFilePath(uri); return this.documents.getDocumentContents(filePath); } getTransformedContents(uri) { let filePath = uriToFilePath(uri); let source = this.findDiagnosticsSource(filePath); if (!source) return; let contents = this.transformManager.readTransformedFile(source); if (contents) { let uri = filePathToUri(this.documents.getCanonicalDocumentPath(source)); return { uri, contents }; } } getCodeActions(uri, actionKind, range, diagnosticCodes, formatOptions = {}, preferences = {}) { // Only supports quickfixes right now but this can be expanded to support all of the // the different CodeActionKinds (Refactorings, Imports, etc). // @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionKind if (actionKind === CodeActionKind.QuickFix) { return this.applyCodeAction(uri, range, diagnosticCodes, formatOptions, preferences); } return []; } organizeImports(uri, formatOptions = {}, preferences = {}) { const transformInfo = this.transformManager.findTransformInfoForOriginalFile(uriToFilePath(uri)); if (!transformInfo) { return []; } const fileTextChanges = this.service.organizeImports({ type: 'file', fileName: transformInfo.transformedFileName, skipDestructiveCodeActions: true, }, formatOptions, preferences); const edits = []; for (const fileTextChange of fileTextChanges) { for (const textChange of fileTextChange.textChanges) { const location = this.textSpanToLocation(fileTextChange.fileName, textChange.span); if (location) { edits.push({ range: location.range, newText: textChange.newText, }); } } } return edits; } applyCodeAction(uri, range, diagnostics, formatting = {}, preferences = {}) { let errorCodes = this.filterDiagnosticCodes(diagnostics); let { transformedStart, transformedEnd, transformedFileName } = this.getTransformedOffsetsFromPositions(uri, { line: range.start.line, character: range.start.character, }, { line: range.end.line, character: range.end.character, }); let codeFixes = this.service.getCodeFixesAtPosition(transformedFileName, transformedStart, transformedEnd, errorCodes, formatting, preferences); let codeActions = this.transformCodeFixActionToCodeAction(codeFixes, uri); return codeActions.filter((codeAction) => codeAction.edit?.documentChanges?.every((change) => { if (TextDocumentEdit.is(change)) { return change.edits.length > 0; } })); } filterDiagnosticCodes(diagnostics) { return diagnostics .map((diag) => { if (diag.code && diag.source?.startsWith('glint')) { return typeof diag.code === 'string' ? parseInt(diag.code) : diag.code; } return undefined; }) .filter(onlyNumbers); } transformCodeFixActionToCodeAction(codeFixes, uri) { return codeFixes.map((fix) => { let documentChanges = fix.changes.map((change) => { let filePath = uriToFilePath(uri); let version = parseInt(this.documents.getDocumentVersion(filePath)); let textChanges = change.textChanges.map((edit) => { let { originalEnd, originalFileName, originalStart, mapping } = this.transformManager.getOriginalRange(change.fileName, edit.span.start, edit.span.start + edit.span.length); let contents = this.documents.getDocumentContents(originalFileName); let start = offsetToPosition(contents, originalStart); let end = offsetToPosition(contents, originalEnd); // We need to re-write \@ts-ignore directives for embedded templates // Failing to do so would replace the problematics code with \@ts-ignore // instead of prepending it with \@glint-ignore. if (fix.fixName === 'disableJsDiagnostics' && (this.glintConfig.environment.isTemplate(originalFileName) || mapping?.sourceNode)) { return this.insertGlintIgnore(filePath, edit, start); } return TextEdit.replace({ start, end, }, edit.newText); }); let uriForEdit = uri; let companion = this.documents.getCompanionDocumentPath(filePath); if (companion && this.isFixForTS(filePath, fix.fixName)) { uriForEdit = filePathToUri(companion); } return TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(uriForEdit, version), textChanges); }); return CodeAction.create(fix.description, { documentChanges }, CodeActionKind.QuickFix); }); } // This mimics what happens in TS/JS but for when we are in an embedded template context. // We fix up the indenting because this is the same behavior that occurs what inserting // \@ts-ignore checks insertGlintIgnore(filePath, edit, start) { edit.newText = '{{! @glint-ignore }}\n'; let linesOfNewText = edit.newText.split('\n'); if (/^[ \t]*$/.test(linesOfNewText[linesOfNewText.length - 1])) { let contents = this.documents.getDocumentContents(filePath).split('\n')[start.line]; let indent = /^[ |\t]+/.exec(contents)?.[0] ?? ''; linesOfNewText[linesOfNewText.length - 1] = indent; } return TextEdit.insert(start, linesOfNewText.join('\n')); } isFixForTS(filePath, fixName) { return this.glintConfig.environment.isTemplate(filePath) && fixName !== 'disableJsDiagnostics'; } getTransformedOffsetsFromPositions(uri, startPosition, endPosition) { let start = this.getTransformedOffset(uri, startPosition); let end = this.getTransformedOffset(uri, endPosition); return { transformedStart: start.transformedOffset, transformedEnd: end.transformedOffset, transformedFileName: start.transformedFileName, }; } calculateOriginalLocations(spans) { return spans .map((span) => this.textSpanToLocation(span.fileName, span.textSpan)) .filter((loc) => Boolean(loc)); } textSpanToLocation(fileName, textSpan) { let { originalFileName, originalStart, originalEnd } = this.transformManager.getOriginalRange(fileName, textSpan.start, textSpan.start + textSpan.length); // If our calculated original span is zero-length but the transformed span // does take up space, this corresponds to a synthetic usage we generated // in the transformed output, and we don't want to show it to the user. if (originalStart === originalEnd && textSpan.length > 0) return; let originalContents = this.documents.getDocumentContents(originalFileName); let start = offsetToPosition(originalContents, originalStart); let end = offsetToPosition(originalContents, originalEnd); return Location.create(filePathToUri(originalFileName), { start, end }); } findDiagnosticsSource(fileName) { let scriptPath = this.transformManager.getScriptPathForTS(fileName); if (this.isAnalyzableFile(scriptPath)) { return scriptPath; } } getTransformedOffset(originalURI, originalPosition) { let originalFileName = uriToFilePath(originalURI); let originalFileContents = this.documents.getDocumentContents(originalFileName); let originalOffset = positionToOffset(originalFileContents, originalPosition); let { transformedStart, transformedFileName, mapping } = this.transformManager.getTransformedRange(originalFileName, originalOffset, originalOffset); return { mapping, transformedOffset: transformedStart, transformedFileName: this.glintConfig.getSynthesizedScriptPathForTS(transformedFileName), }; } isAnalyzableFile(synthesizedScriptPath) { if (synthesizedScriptPath.endsWith('.ts')) { return true; } let allowJs = this.service.getProgram()?.getCompilerOptions().allowJs ?? false; if (allowJs && synthesizedScriptPath.endsWith('.js')) { return true; } return false; } *allKnownFileNames() { let { environment } = this.glintConfig; for (let name of this.rootFileNames) { if (environment.isScript(name)) { yield name; } } for (let name of this.openFileNames) { if (environment.isScript(name)) { yield name; } } } parseTsconfig(glintConfig, transformManager) { let { ts } = glintConfig; let contents = ts.readConfigFile(glintConfig.configPath, ts.sys.readFile).config; let host = { ...ts.sys, readDirectory: transformManager.readDirectory }; return ts.parseJsonConfigFileContent(contents, host, glintConfig.rootDir, undefined, glintConfig.configPath); } } function onlyNumbers(entry) { return entry !== undefined; } //# sourceMappingURL=glint-language-server.js.map