diff --git a/.vscode/launch.json b/.vscode/launch.json index 3acfbbf52..63e46f36b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Debug Extension", + "name": "Debug Extension (Glint + TS)", "type": "extensionHost", "request": "launch", "preLaunchTask": "npm: build", @@ -16,6 +16,24 @@ "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", "${workspaceFolder}/test-packages" ] + }, + { + "name": "Debug Extension (Glint Only, No TS)", + "type": "extensionHost", + "request": "launch", + "preLaunchTask": "npm: build", + "autoAttachChildProcesses": true, + "runtimeExecutable": "${execPath}", + "outFiles": [ + "${workspaceFolder}/**/*.js", + "!**/node_modules/**" + ], + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", + "--disable-extension", + "vscode.typescript-language-features", + "${workspaceFolder}/test-packages" + ] } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a20c2e9..215879285 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,3 +17,69 @@ Glint is a family of packages which all live in this repo as a Yarn workspace. T - Read the project’s [ARCHITECTURE.md](./ARCHITECTURE.md) to understand the basics of how the code base works. Once you have made changes and added tests to confirm they work correctly, you can then open a PR and we'll work with you to polish it up and get it landed! + +# Common Debugging Scenarios + +## How to run glint-language-server locally? + +If you would like to connect your editor to a locally running version of the Glint language server, first start the `tsc` compiler in watch mode from the root folder of the Glint repo: + +``` +tsc --build --watch +``` + +Then you can configure your editor to point to the absolute path of the `./packages/core` folder within this repo when launching the language server. For VSCode users, this means opening your user preferences (after having already installed the Glint VSCode extension) and setting "Glint: Library Path" (aka `glint.libraryPath`) to the absolute path of the `packages/core` folder, e.g. `/Users/machty/code/glint/packages/core`. + +With the running `tsc --build --watch` command, the language server will rebuild when any source code files change. _Important_: after any source code file change and subsequent rebuild, you will need to restart the language server from within your editor in order for the changes to be reflected in your editor. In VSCode, this means running "Glint: Restart Glint Server" from the Command Palette. + +## How to run glint-language-server locally in debug mode? + +There are a few VSCode Launch Configurations within `./vscode/launch.json` that are handy for debugging: + +- Both will enable the TS/JS debugger on both the language server and the client-side VSCode extension code, meaning the debug breakpoints will pause execution to allow you to debug things like text completions +- Debug Extension (Glint + TS) + - This spins up a VSCode window with both Glint and the built-in TS language server running. + - In this mode, both language servers will provide duplicate completions and suggestions, which can be useful for testing out feature parity between Glint and TS +- Debug Extension (Glint Only) + - This is useful for testing out the "takeover" mode of running Glint, where Glint is responsible for providing all of the language features (debugging, diagnostics, etc); this is the ideal way to run Glint, but at the time of writing we have not yet achieved feature parity with built-in TS +- By default these extensions will launch the VSCode Extension Host in the `test-packages` subfolder, which have Ember and Glimmerx apps that you can do some basic testing on +- _TIP_: you can open any workspace with the Extension Host, meaning you can even debug the language server with breakpoints on a totally separate Ember repo, for example. +- _NOTE_: debugging takes place within the `glint` workspace, i.e. if you are debugging completions, you'd trigger a completion within the Extension Host, and the breakpoint would pause within the Glint workspace VSCode instance. + +These launch configurations can be run via the Run and Debug tab in VSCode. + +## How to test out the VSCode extension locally? + +Firstly, there is an entire suite of integration tests that will spin up instances of VSCode and test out the VSCode Glint extension. These can be run from the `packages/vscode` directory via: + +``` +yarn run test +``` + +Secondly, the Launch Configurations described above (I believe) will run your client-side extension code in debug mode, along with the language server. + +## How to see the transformed Intermediate Representation (IR) code of a template + +Glint transforms template code into TypeScript code so that it can be type-checked by the vanilla TS compiler. Transformed IR code looks something like: + +```ts +static { +({} as typeof import("@glint/environment-ember-loose/-private/dsl") + .templateForBackingValue(this, function(πšͺ, Ο‡: typeof import("@glint/environment-ember-loose/-private/dsl")) { + { + const 𝛄 = Ο‡.emitComponent(Ο‡.resolve(Ο‡.Globals["FooComponent"]) + ({ desc: "notchHeight _ footer", ...Ο‡.NamedArgsMarker })); + 𝛄; + { + const [] = 𝛄.blockParams["default"]; + { + // ... +``` + +Sometimes it is useful to see the IR of any .hbs template or .gts/.gjs file for debugging purposes; if you're using VSCode you can see the IR by: + +1. Enable "Glint: Debug" in your VSCode user preferences +2. Run the "Glint: Show IR for Debugging" command + +Your template code will be replaced by the IR directly in your editor window. + diff --git a/README.md b/README.md index 2eedebb0a..327cc5ba6 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,7 @@ Similarly, `glint-language-server` can be used by editor integrations to expose The language server can also enable your editor to provide other richer help, such as type information on hover, automated refactoring, and more. See [the VS Code extension README](packages/vscode) for further examples. [using-glint]: https://typed-ember.gitbook.io/glint/getting-started#using-glint + +## Debugging Glint + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for instructions on how to run and debug the Glint language server and VSCode extension locally. diff --git a/packages/core/__tests__/language-server/completions.test.ts b/packages/core/__tests__/language-server/completions.test.ts index 13298e5e3..3e92964f2 100644 --- a/packages/core/__tests__/language-server/completions.test.ts +++ b/packages/core/__tests__/language-server/completions.test.ts @@ -155,6 +155,141 @@ describe('Language Server: Completions', () => { expect(details.detail).toEqual('(property) MyComponent.message: string'); }); + test('auto imports', () => { + project.write({ + 'other.ts': stripIndent` + export let foobar = 123; + `, + 'index.ts': stripIndent` + import { thing } from 'nonexistent'; + + let a = foo + `, + }); + + const preferences = { + includeCompletionsForModuleExports: true, + allowIncompleteCompletions: true, + }; + + let server = project.startLanguageServer(); + let completions = server.getCompletions( + project.fileURI('index.ts'), + { + line: 2, + character: 11, + }, + {}, + preferences + ); + + let importCompletion = completions?.find( + (k) => k.kind == CompletionItemKind.Variable && k.label == 'foobar' + ); + + let details = server.getCompletionDetails(importCompletion!, {}, preferences); + + expect(details.detail).toEqual('Add import from "./other"\n\nlet foobar: number'); + + expect(details.additionalTextEdits?.length).toEqual(1); + expect(details.additionalTextEdits?.[0].newText).toMatch("import { foobar } from './other';"); + expect(details.additionalTextEdits?.[0].range).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }); + expect(details?.documentation).toEqual({ + kind: 'markdown', + value: '', + }); + expect(details?.labelDetails?.description).toEqual('./other'); + }); + + test('auto imports with documentation and tags', () => { + project.write({ + 'other.ts': stripIndent` + /** + * This is a doc comment + * @param foo + */ + export let foobar = 123; + `, + 'index.ts': stripIndent` + import { thing } from 'nonexistent'; + + let a = foo + `, + }); + + const preferences = { + includeCompletionsForModuleExports: true, + allowIncompleteCompletions: true, + }; + + let server = project.startLanguageServer(); + let completions = server.getCompletions( + project.fileURI('index.ts'), + { + line: 2, + character: 11, + }, + {}, + preferences + ); + + let importCompletion = completions?.find( + (k) => k.kind == CompletionItemKind.Variable && k.label == 'foobar' + ); + + let details = server.getCompletionDetails(importCompletion!, {}, preferences); + + expect(details.detail).toEqual('Add import from "./other"\n\nlet foobar: number'); + + expect(details.additionalTextEdits?.length).toEqual(1); + expect(details.additionalTextEdits?.[0].newText).toMatch("import { foobar } from './other';"); + expect(details.additionalTextEdits?.[0].range).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }); + expect(details?.documentation).toEqual({ + kind: 'markdown', + value: 'This is a doc comment\n\n*@param* `foo`', + }); + }); + + test('auto import - import statements - ensure all completions are resolvable', () => { + project.write({ + 'other.ts': stripIndent` + export let foobar = 123; + `, + 'index.ts': stripIndent` + import foo + `, + }); + + const preferences = { + includeCompletionsForModuleExports: true, + allowIncompleteCompletions: true, + includeCompletionsForImportStatements: true, + includeCompletionsWithInsertText: true, // needs to be present for `includeCompletionsForImportStatements` to work + }; + + let server = project.startLanguageServer(); + let completions = server.getCompletions( + project.fileURI('index.ts'), + { + line: 0, + character: 10, + }, + {}, + preferences + ); + + completions?.forEach((completion) => { + let details = server.getCompletionDetails(completion, {}, preferences); + expect(details).toBeTruthy(); + }); + }); + test('referencing own args', async () => { let code = stripIndent` import Component, { hbs } from '@glimmerx/component'; diff --git a/packages/core/src/language-server/binding.ts b/packages/core/src/language-server/binding.ts index 8e03fb976..c35f8376b 100644 --- a/packages/core/src/language-server/binding.ts +++ b/packages/core/src/language-server/binding.ts @@ -5,7 +5,6 @@ import { SymbolInformation, TextDocuments, TextDocumentSyncKind, - InitializeParams as BaseInitializeParams, CodeActionTriggerKind, CodeActionKind, } from 'vscode-languageserver'; @@ -13,7 +12,6 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { GlintCompletionItem } from './glint-language-server.js'; import { LanguageServerPool } from './pool.js'; import { GetIRRequest, SortImportsRequest } from './messages.cjs'; -import { ConfigManager } from './config-manager.js'; import type * as ts from 'typescript'; export const capabilities: ServerCapabilities = { @@ -23,6 +21,9 @@ export const capabilities: ServerCapabilities = { // By default `@` won't trigger autocompletion, but it's an important character // for us since it signifies the beginning of an arg name. triggerCharacters: ['.', '@'], + completionItem: { + labelDetailsSupport: true, + }, }, referencesProvider: true, hoverProvider: true, @@ -36,60 +37,44 @@ export const capabilities: ServerCapabilities = { }, }; +const PREFERENCES: ts.UserPreferences = { + includeCompletionsForImportStatements: true, + includeCompletionsForModuleExports: true, + includeCompletionsWithSnippetText: true, + includeAutomaticOptionalChainCompletions: true, + includeCompletionsWithInsertText: true, + includeCompletionsWithClassMemberSnippets: true, + includeCompletionsWithObjectLiteralMethodSnippets: true, + useLabelDetailsInCompletionEntries: true, + importModuleSpecifierPreference: 'shortest', + importModuleSpecifierEnding: 'auto', + allowTextChangesInNewFiles: true, + providePrefixAndSuffixTextForRename: true, + includePackageJsonAutoImports: 'auto', + provideRefactorNotApplicableReason: true, + jsxAttributeCompletionStyle: 'auto', + includeInlayParameterNameHints: 'all', + includeInlayParameterNameHintsWhenArgumentMatchesName: true, + includeInlayFunctionParameterTypeHints: true, + includeInlayVariableTypeHints: true, + includeInlayVariableTypeHintsWhenTypeMatchesName: true, + includeInlayPropertyDeclarationTypeHints: true, + includeInlayFunctionLikeReturnTypeHints: true, + includeInlayEnumMemberValueHints: true, + allowRenameOfImportPath: true, + autoImportFileExcludePatterns: [], +}; + +const FORMATTING_OPTIONS: ts.FormatCodeSettings = {}; + export type BindingArgs = { openDocuments: TextDocuments; connection: Connection; pool: LanguageServerPool; - configManager: ConfigManager; }; -interface FormattingAndPreferences { - format?: ts.FormatCodeSettings; - preferences?: ts.UserPreferences; -} - -interface InitializeParams extends BaseInitializeParams { - initializationOptions?: { - typescript?: FormattingAndPreferences; - javascript?: FormattingAndPreferences; - }; -} - -export function bindLanguageServerPool({ - connection, - pool, - openDocuments, - configManager, -}: BindingArgs): void { - connection.onInitialize((config: InitializeParams) => { - if (config.initializationOptions?.typescript?.format) { - configManager.updateTsJsFormatConfig( - 'typescript', - config.initializationOptions.typescript.format - ); - } - - if (config.initializationOptions?.typescript?.preferences) { - configManager.updateTsJsUserPreferences( - 'typescript', - config.initializationOptions.typescript.preferences - ); - } - - if (config.initializationOptions?.javascript?.format) { - configManager.updateTsJsFormatConfig( - 'javascript', - config.initializationOptions.javascript.format - ); - } - - if (config.initializationOptions?.javascript?.preferences) { - configManager.updateTsJsUserPreferences( - 'javascript', - config.initializationOptions.javascript.preferences - ); - } - +export function bindLanguageServerPool({ connection, pool, openDocuments }: BindingArgs): void { + connection.onInitialize(() => { return { capabilities }; }); @@ -118,9 +103,6 @@ export function bindLanguageServerPool({ // The user actually asked for the fix // @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionTriggerKind if (context.triggerKind === CodeActionTriggerKind.Invoked) { - let language = server.getLanguageType(textDocument.uri); - let formating = configManager.getFormatCodeSettingsFor(language); - let preferences = configManager.getUserSettingsFor(language); let diagnostics = context.diagnostics; let kind = ''; @@ -141,8 +123,8 @@ export function bindLanguageServerPool({ kind, range, diagnostics, - formating, - preferences + FORMATTING_OPTIONS, + PREFERENCES ); } @@ -169,9 +151,7 @@ export function bindLanguageServerPool({ await new Promise((r) => setTimeout(r, 25)); return pool.withServerForURI(textDocument.uri, ({ server }) => { - let language = server.getLanguageType(textDocument.uri); - let formatting = configManager.getFormatCodeSettingsFor(language); - return server.getCompletions(textDocument.uri, position, formatting); + return server.getCompletions(textDocument.uri, position, FORMATTING_OPTIONS, PREFERENCES); }); }); @@ -181,10 +161,7 @@ export function bindLanguageServerPool({ return ( pool.withServerForURI(glintItem.data.uri, ({ server }) => { - let language = server.getLanguageType(glintItem.data.uri); - let formatting = configManager.getFormatCodeSettingsFor(language); - let preferences = configManager.getUserSettingsFor(language); - return server.getCompletionDetails(glintItem, formatting, preferences); + return server.getCompletionDetails(glintItem, FORMATTING_OPTIONS, PREFERENCES); }) ?? item ); }); @@ -221,10 +198,7 @@ export function bindLanguageServerPool({ connection.onRequest(SortImportsRequest.type, ({ uri }) => { return pool.withServerForURI(uri, ({ server }) => { - const language = server.getLanguageType(uri); - const formatting = configManager.getFormatCodeSettingsFor(language); - const preferences = configManager.getUserSettingsFor(language); - return server.organizeImports(uri, formatting, preferences); + return server.organizeImports(uri, FORMATTING_OPTIONS, PREFERENCES); }); }); diff --git a/packages/core/src/language-server/config-manager.ts b/packages/core/src/language-server/config-manager.ts deleted file mode 100644 index 9d6c45c7d..000000000 --- a/packages/core/src/language-server/config-manager.ts +++ /dev/null @@ -1,39 +0,0 @@ -import ts from 'typescript'; - -export type TsUserConfigLang = 'typescript' | 'javascript'; - -// The ConfigManager holds TypeScript/JS formating and user preferences. -// It is only needed for the vscode binding -export class ConfigManager { - private formatCodeOptions: Record = { - javascript: ts.getDefaultFormatCodeSettings(), - typescript: ts.getDefaultFormatCodeSettings(), - }; - - private userPreferences: Record = { - typescript: {}, - javascript: {}, - }; - - public updateTsJsFormatConfig(lang: TsUserConfigLang, config: ts.FormatCodeSettings): void { - this.formatCodeOptions[lang] = { - ...this.formatCodeOptions[lang], - ...config, - }; - } - - public updateTsJsUserPreferences(lang: TsUserConfigLang, config: ts.UserPreferences): void { - this.userPreferences[lang] = { - ...this.userPreferences[lang], - ...config, - }; - } - - public getUserSettingsFor(lang: TsUserConfigLang): ts.UserPreferences { - return this.userPreferences[lang]; - } - - public getFormatCodeSettingsFor(lang: TsUserConfigLang): ts.FormatCodeSettings { - return this.formatCodeOptions[lang]; - } -} diff --git a/packages/core/src/language-server/glint-language-server.ts b/packages/core/src/language-server/glint-language-server.ts index fd2e72e0f..e6a91ec2b 100644 --- a/packages/core/src/language-server/glint-language-server.ts +++ b/packages/core/src/language-server/glint-language-server.ts @@ -21,6 +21,7 @@ import { TextDocumentEdit, OptionalVersionedTextDocumentIdentifier, TextEdit, + MarkupContent, } from 'vscode-languageserver'; import DocumentCache from '../common/document-cache.js'; import { Position, positionToOffset } from './util/position.js'; @@ -30,8 +31,8 @@ import { tagsForDiagnostic, } from './util/protocol.js'; import { GetIRResult } from './messages.cjs'; -import type { TsUserConfigLang } from './config-manager.js'; import MappingTree from '../transform/template/mapping-tree.js'; +import { getTagDocumentation, plain } from './util/previewer.js'; export interface GlintCompletionItem extends CompletionItem { data: { @@ -39,6 +40,7 @@ export interface GlintCompletionItem extends CompletionItem { transformedFileName: string; transformedOffset: number; source: string | undefined; + tsData: ts.CompletionEntryData | undefined; }; } @@ -65,6 +67,12 @@ export default class GlintLanguageServer { this.openFileNames = new Set(); this.rootFileNames = new Set(parsedConfig.fileNames); + let exportMapCache = null; + + const ts = this.glintConfig.ts; + + let program: ts.Program | undefined; + let serviceHost: ts.LanguageServiceHost = { getScriptFileNames: () => [...new Set(this.allKnownFileNames())], getScriptVersion: (fileName) => this.documents.getDocumentVersion(fileName), @@ -88,12 +96,39 @@ export default class GlintLanguageServer { 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 ||= 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 - this.service.getProgram(); + program = this.service.getProgram(); } public dispose(): void { @@ -193,7 +228,8 @@ export default class GlintLanguageServer { public getCompletions( uri: string, position: Position, - formatting: ts.FormatCodeSettings = {} + formatting: ts.FormatCodeSettings = {}, + preferences: ts.UserPreferences = {} ): GlintCompletionItem[] | undefined { let { transformedFileName, transformedOffset, mapping } = this.getTransformedOffset( uri, @@ -215,16 +251,34 @@ export default class GlintLanguageServer { let completions = this.service.getCompletionsAtPosition( transformedFileName, transformedOffset, - {}, + preferences, formatting ); - return completions?.entries.map((completionEntry) => ({ - label: completionEntry.name, - kind: scriptElementKindToCompletionItemKind(this.ts, completionEntry.kind), - data: { uri, transformedFileName, transformedOffset, source: completionEntry.source }, - sortText: completionEntry.sortText, - })); + return completions?.entries.map((completionEntry) => { + const glintCompletionItem: 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; + }); } public getCompletionDetails( @@ -237,7 +291,7 @@ export default class GlintLanguageServer { return item; } - let { transformedFileName, transformedOffset, source } = data; + let { transformedFileName, transformedOffset, source, tsData } = data; let details = this.service.getCompletionEntryDetails( transformedFileName, transformedOffset, @@ -245,24 +299,81 @@ export default class GlintLanguageServer { formatting, source, preferences, - // @ts-ignore: 4.3 adds a new not-optional-but-can-be-undefined `data` arg - undefined + tsData ); if (!details) { return item; } + item.detail = plain(this.ts.displayPartsToString(details.displayParts)); + const documentation: MarkupContent = { + 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, - detail: this.ts.displayPartsToString(details.displayParts), - documentation: { - kind: 'markdown', - value: this.ts.displayPartsToString(details.documentation), - }, + documentation, }; } + private convertCodeActionToTextEdit(uri: string, codeActions: ts.CodeAction[]): TextEdit[] { + const textEdits: TextEdit[] = []; + + 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; + } + public prepareRename(uri: string, position: Position): Range | undefined { let { transformedFileName, transformedOffset } = this.getTransformedOffset(uri, position); if (!this.isAnalyzableFile(transformedFileName)) return; @@ -411,11 +522,6 @@ export default class GlintLanguageServer { return []; } - public getLanguageType(uri: string): TsUserConfigLang { - let file = uriToFilePath(uri); - return this.glintConfig.environment.isTypedScript(file) ? 'typescript' : 'javascript'; - } - public organizeImports( uri: string, formatOptions: ts.FormatCodeSettings = {}, diff --git a/packages/core/src/language-server/index.ts b/packages/core/src/language-server/index.ts index 69470d3e7..b0315209b 100644 --- a/packages/core/src/language-server/index.ts +++ b/packages/core/src/language-server/index.ts @@ -2,14 +2,12 @@ import { TextDocuments, createConnection } from 'vscode-languageserver/node.js'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { bindLanguageServerPool } from './binding.js'; import { LanguageServerPool } from './pool.js'; -import { ConfigManager } from './config-manager.js'; const connection = createConnection(process.stdin, process.stdout); const openDocuments = new TextDocuments(TextDocument); -const configManager = new ConfigManager(); const pool = new LanguageServerPool(connection, openDocuments); -bindLanguageServerPool({ connection, openDocuments, pool, configManager }); +bindLanguageServerPool({ connection, openDocuments, pool }); openDocuments.listen(connection); connection.listen(); diff --git a/packages/core/src/language-server/util/previewer.ts b/packages/core/src/language-server/util/previewer.ts new file mode 100644 index 000000000..65ac26d6e --- /dev/null +++ b/packages/core/src/language-server/util/previewer.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Adapted from https://github.com/microsoft/vscode/blob/5347c21ecfd26378e0153b6e2e212a8fbc28fa52/extensions/typescript-language-features/src/utils/previewer.ts + */ + +import type * as ts from 'typescript'; + +function replaceLinks(text: string): string { + return ( + text + // Http(s) links + .replace( + /\{@(link|linkplain|linkcode) (https?:\/\/[^ |}]+?)(?:[| ]([^{}\n]+?))?\}/gi, + (_, tag: string, link: string, text?: string) => { + switch (tag) { + case 'linkcode': + return `[\`${text ? text.trim() : link}\`](${link})`; + + default: + return `[${text ? text.trim() : link}](${link})`; + } + } + ) + ); +} + +function processInlineTags(text: string): string { + return replaceLinks(text); +} + +function getTagBodyText(tag: ts.JSDocTagInfo): string | undefined { + if (!tag.text) { + return undefined; + } + + // Convert to markdown code block if it is not already one + function makeCodeblock(text: string): string { + if (text.match(/^\s*[~`]{3}/g)) { + return text; + } + return '```\n' + text + '\n```'; + } + + if (tag.name === 'example') { + // check for caption tags, fix for #79704 + const captionTagMatches = plain(tag.text).match(/(.*?)<\/caption>\s*(\r\n|\n)/); + if (captionTagMatches && captionTagMatches.index === 0) { + return ( + captionTagMatches[1] + + '\n\n' + + makeCodeblock(plain(tag.text).slice(captionTagMatches[0].length)) + ); + } else { + return makeCodeblock(plain(tag.text)); + } + } else if (tag.name === 'author') { + // fix obsucated email address, #80898 + const emailMatch = plain(tag.text).match(/(.+)\s<([-.\w]+@[-.\w]+)>/); + + if (emailMatch === null) { + return plain(tag.text); + } else { + return `${emailMatch[1]} ${emailMatch[2]}`; + } + } else if (tag.name === 'default') { + return makeCodeblock(plain(tag.text)); + } + + return processInlineTags(plain(tag.text)); +} + +export function getTagDocumentation(tag: ts.JSDocTagInfo): string | undefined { + if ( + tag.name === 'augments' || + tag.name === 'extends' || + tag.name === 'param' || + tag.name === 'template' + ) { + const body = plain(tag.text || '').split(/^(\S+)\s*-?\s*/); + if (body?.length === 3) { + const param = body[1]; + const doc = body[2]; + const label = `*@${tag.name}* \`${param}\``; + if (!doc) { + return label; + } + return ( + label + + (doc.match(/\r\n|\n/g) ? ' \n' + processInlineTags(doc) : ` β€” ${processInlineTags(doc)}`) + ); + } + } + + // Generic tag + const label = `*@${tag.name}*`; + const text = getTagBodyText(tag); + if (!text) { + return label; + } + return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` β€” ${text}`); +} + +export function plain(parts: ts.SymbolDisplayPart[] | string): string { + return processInlineTags( + typeof parts === 'string' ? parts : parts.map((part) => part.text).join('') + ); +} diff --git a/test-packages/ts-template-imports-app/tsconfig.json b/test-packages/ts-template-imports-app/tsconfig.json index b26b43339..cbbfc8311 100644 --- a/test-packages/ts-template-imports-app/tsconfig.json +++ b/test-packages/ts-template-imports-app/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../tsconfig.compileroptions.json", + "compilerOptions": { + "baseUrl": "." + }, "include": ["src", "types"], "glint": { "environment": {