From 943d59642e1ed5efd951a858d4ca1ea3c5dba42d Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Wed, 8 Feb 2023 10:58:01 -0500 Subject: [PATCH] feat: Introduce InlayHints --- .vscode/launch.json | 11 ++ .../__tests__/language-server/inlay.test.ts | 84 ++++++++++++ packages/core/src/language-server/binding.ts | 8 ++ .../language-server/glint-language-server.ts | 74 +++++++++++ packages/vscode/src/extension.ts | 39 ++---- packages/vscode/src/formatting.ts | 64 +++++++++ packages/vscode/src/preferences.ts | 124 ++++++++++++++++++ 7 files changed, 378 insertions(+), 26 deletions(-) create mode 100644 packages/core/__tests__/language-server/inlay.test.ts create mode 100644 packages/vscode/src/formatting.ts create mode 100644 packages/vscode/src/preferences.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 5b5f6112a..8212464c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,17 @@ "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", "${workspaceFolder}/test-packages" ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug Current Test File", + "autoAttachChildProcesses": true, + "skipFiles": ["/**", "**/node_modules/**"], + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run", "${relativeFile}"], + "smartStep": true, + "console": "integratedTerminal" } ] } diff --git a/packages/core/__tests__/language-server/inlay.test.ts b/packages/core/__tests__/language-server/inlay.test.ts new file mode 100644 index 000000000..cac3ff557 --- /dev/null +++ b/packages/core/__tests__/language-server/inlay.test.ts @@ -0,0 +1,84 @@ +import { Project } from 'glint-monorepo-test-utils'; +import { describe, beforeEach, afterEach, test, expect } from 'vitest'; +import { Position, Range } from 'vscode-languageserver'; + +describe('Language Server: iInlays', () => { + let project!: Project; + + beforeEach(async () => { + project = await Project.create(); + }); + + afterEach(async () => { + await project.destroy(); + }); + + test('it provides inlays for return types when preference is set', async () => { + project.setGlintConfig({ environment: 'ember-template-imports' }); + let content = 'const bar = () => true;'; + project.write('foo.gts', content); + let server = project.startLanguageServer(); + + const inlays = server.getInlayHints( + { + textDocument: { + uri: project.fileURI('foo.gts'), + }, + range: Range.create(Position.create(0, 0), Position.create(0, content.length)), + }, + { + includeInlayFunctionLikeReturnTypeHints: true, + } + ); + + expect(inlays.length).toBe(1); + expect(inlays[0].kind).toBe(1); + expect(inlays[0].label).toBe(': boolean'); + }); + + test('it provides inlays for variable types when preference is set', async () => { + project.setGlintConfig({ environment: 'ember-template-imports' }); + let content = 'const bar = globalThis.thing ?? null;'; + project.write('foo.gts', content); + let server = project.startLanguageServer(); + + const inlays = server.getInlayHints( + { + textDocument: { + uri: project.fileURI('foo.gts'), + }, + range: Range.create(Position.create(0, 0), Position.create(0, content.length)), + }, + { + includeInlayVariableTypeHints: true, + } + ); + + expect(inlays.length).toBe(1); + expect(inlays[0].kind).toBe(1); + expect(inlays[0].label).toBe(': any'); + }); + + test('it provides inlays for property types when preference is set', async () => { + project.setGlintConfig({ environment: 'ember-template-imports' }); + let content = 'class Foo { date = Date.now() }'; + project.write('foo.gts', content); + let server = project.startLanguageServer(); + + const inlays = server.getInlayHints( + { + textDocument: { + uri: project.fileURI('foo.gts'), + }, + range: Range.create(Position.create(0, 0), Position.create(0, content.length)), + }, + { + includeInlayPropertyDeclarationTypeHints: true, + } + ); + + expect(inlays.length).toBe(1); + expect(inlays[0].kind).toBe(1); + expect(inlays[0].label).toBe(': number'); + }); +}); diff --git a/packages/core/src/language-server/binding.ts b/packages/core/src/language-server/binding.ts index b6a43f516..90a11b26b 100644 --- a/packages/core/src/language-server/binding.ts +++ b/packages/core/src/language-server/binding.ts @@ -26,6 +26,7 @@ export const capabilities: ServerCapabilities = { }, referencesProvider: true, hoverProvider: true, + inlayHintProvider: true, codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix], }, @@ -113,6 +114,13 @@ export function bindLanguageServerPool({ }); }); + connection.languages.inlayHint.on((hint) => { + return pool.withServerForURI(hint.textDocument.uri, ({ server }) => { + let language = server.getLanguageType(hint.textDocument.uri); + return server.getInlayHints(hint, configManager.getUserSettingsFor(language)); + }); + }); + connection.onCodeAction(({ textDocument, range, context }) => { return pool.withServerForURI(textDocument.uri, ({ server }) => { // The user actually asked for the fix diff --git a/packages/core/src/language-server/glint-language-server.ts b/packages/core/src/language-server/glint-language-server.ts index f14d55cd4..0e6b186a8 100644 --- a/packages/core/src/language-server/glint-language-server.ts +++ b/packages/core/src/language-server/glint-language-server.ts @@ -21,6 +21,10 @@ import { TextDocumentEdit, OptionalVersionedTextDocumentIdentifier, TextEdit, + InlayHint, + InlayHintParams, + Position as LSPPosition, + InlayHintKind, } from 'vscode-languageserver'; import DocumentCache from '../common/document-cache.js'; import { Position, positionToOffset } from './util/position.js'; @@ -381,6 +385,42 @@ export default class GlintLanguageServer { } } + public getInlayHints(hint: InlayHintParams, preferences: ts.UserPreferences = {}): InlayHint[] { + let { uri } = hint.textDocument; + let { range } = hint; + let fileName = uriToFilePath(uri); + + let { transformedStart, transformedEnd, transformedFileName } = + this.getTransformedOffsetsFromPositions( + uri, + { + line: range.start.line, + character: range.start.character, + }, + { + line: range.end.line, + character: range.end.character, + } + ); + + const inlayHints = this.service.provideInlayHints( + transformedFileName, + { + start: transformedStart, + length: transformedEnd - transformedStart, + }, + preferences + ); + + let content = this.documents.getDocumentContents(fileName); + + return inlayHints + .map((tsInlayHint) => { + return this.transformTSInlayToLSPInlay(tsInlayHint, transformedFileName, content); + }) + .filter(isHint); + } + public getCodeActions( uri: string, actionKind: string, @@ -404,6 +444,32 @@ export default class GlintLanguageServer { return this.glintConfig.environment.isTypedScript(file) ? 'typescript' : 'javascript'; } + private transformTSInlayToLSPInlay( + hint: ts.InlayHint, + fileName: string, + contents: string + ): InlayHint | undefined { + let { position, text } = hint; + let { originalStart } = this.transformManager.getOriginalRange( + fileName, + position, + position + text.length + ); + + const { line, character } = offsetToPosition(contents, originalStart); + + let kind = + hint.kind === 'Parameter' + ? InlayHintKind.Parameter + : hint.kind === 'Type' + ? InlayHintKind.Type + : undefined; // enums are not supported by LSP; + + if (isInlayHintKind(kind)) { + return InlayHint.create(LSPPosition.create(line, character), hint.text, kind); + } + } + private applyCodeAction( uri: string, range: Range, @@ -651,3 +717,11 @@ export default class GlintLanguageServer { function onlyNumbers(entry: number | undefined): entry is number { return entry !== undefined; } + +function isHint(hint: InlayHint | undefined): hint is InlayHint { + return hint !== undefined; +} + +function isInlayHintKind(kind: number | undefined): kind is InlayHintKind { + return kind !== undefined; +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 78ecb3e07..1a6a88d0e 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -10,10 +10,11 @@ import { window, commands, workspace, - WorkspaceConfiguration, } from 'vscode'; import { Disposable, LanguageClient, ServerOptions } from 'vscode-languageclient/node.js'; import type { Request, GetIRRequest } from '@glint/core/lsp-messages'; +import { intoFormatting } from './formatting'; +import { intoPreferences } from './preferences'; /////////////////////////////////////////////////////////////////////////////// // Setup and extension lifecycle @@ -109,28 +110,25 @@ async function addWorkspaceFolder( let serverOptions: ServerOptions = { module: serverPath }; - const typescriptFormatOptions = getOptions(workspace.getConfiguration('typescript'), 'format'); - const typescriptUserPreferences = getOptions( - workspace.getConfiguration('typescript'), - 'preferences' - ); - const javascriptFormatOptions = getOptions(workspace.getConfiguration('javascript'), 'format'); - const javascriptUserPreferences = getOptions( - workspace.getConfiguration('javascript'), - 'preferences' - ); + let typescriptConfig = workspace.getConfiguration('typescript'); + let typescriptFormatOptions = workspace.getConfiguration('typescript.format'); + let typescriptUserPreferences = workspace.getConfiguration('typescript.preferences'); + + let javaScriptConfig = workspace.getConfiguration('javascript'); + let javascriptFormatOptions = workspace.getConfiguration('javascript.format'); + let javascriptUserPreferences = workspace.getConfiguration('javascript.preferences'); let client = new LanguageClient('glint', 'Glint', serverOptions, { workspaceFolder, outputChannel, initializationOptions: { javascript: { - format: javascriptFormatOptions, - preferences: javascriptUserPreferences, + format: intoFormatting(javascriptFormatOptions), + preferences: intoPreferences(javaScriptConfig, javascriptUserPreferences), }, typescript: { - format: typescriptFormatOptions, - preferences: typescriptUserPreferences, + format: intoFormatting(typescriptFormatOptions), + preferences: intoPreferences(typescriptConfig, typescriptUserPreferences), }, }, documentSelector: [{ scheme: 'file', pattern: `${folderPath}/${filePattern}` }], @@ -191,14 +189,3 @@ function createConfigWatcher(): Disposable { function requestKey>(name: R['name']): R['type'] { return name as unknown as R['type']; } - -// Loads the TypeScript and JavaScript formating options from the workspace and subsets them to -// pass to the language server. -function getOptions(config: WorkspaceConfiguration, key: string): object { - const formatOptions = config.get(key); - if (formatOptions) { - return formatOptions; - } - - return {}; -} diff --git a/packages/vscode/src/formatting.ts b/packages/vscode/src/formatting.ts new file mode 100644 index 000000000..732d527da --- /dev/null +++ b/packages/vscode/src/formatting.ts @@ -0,0 +1,64 @@ +import { type WorkspaceConfiguration, window } from 'vscode'; +import type * as ts from 'typescript/lib/tsserverlibrary'; + +// vscode does not hold formatting config with the same interface as typescript +// the following maps the vscode formatting options into what typescript expects +// This is heavily borrowed from how the TypeScript works in vscode +// https://github.com/microsoft/vscode/blob/c04c0b43470c3c743468a5e5e51f036123503452/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts#L133 +export function intoFormatting( + config: WorkspaceConfiguration +): ts.server.protocol.FormatCodeSettings { + let editorOptions = window.activeTextEditor?.options; + let tabSize = typeof editorOptions?.tabSize === 'string' ? undefined : editorOptions?.tabSize; + let insertSpaces = + typeof editorOptions?.insertSpaces === 'string' ? undefined : editorOptions?.insertSpaces; + + return { + tabSize, + indentSize: tabSize, + convertTabsToSpaces: insertSpaces, + // We can use \n here since the editor normalizes later on to its line endings. + newLineCharacter: '\n', + insertSpaceAfterCommaDelimiter: config.get('insertSpaceAfterCommaDelimiter'), + insertSpaceAfterConstructor: config.get('insertSpaceAfterConstructor'), + insertSpaceAfterSemicolonInForStatements: config.get( + 'insertSpaceAfterSemicolonInForStatements' + ), + insertSpaceBeforeAndAfterBinaryOperators: config.get( + 'insertSpaceBeforeAndAfterBinaryOperators' + ), + insertSpaceAfterKeywordsInControlFlowStatements: config.get( + 'insertSpaceAfterKeywordsInControlFlowStatements' + ), + insertSpaceAfterFunctionKeywordForAnonymousFunctions: config.get( + 'insertSpaceAfterFunctionKeywordForAnonymousFunctions' + ), + insertSpaceBeforeFunctionParenthesis: config.get( + 'insertSpaceBeforeFunctionParenthesis' + ), + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis' + ), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets' + ), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces' + ), + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingEmptyBraces' + ), + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces' + ), + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: config.get( + 'insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces' + ), + insertSpaceAfterTypeAssertion: config.get('insertSpaceAfterTypeAssertion'), + placeOpenBraceOnNewLineForFunctions: config.get('placeOpenBraceOnNewLineForFunctions'), + placeOpenBraceOnNewLineForControlBlocks: config.get( + 'placeOpenBraceOnNewLineForControlBlocks' + ), + semicolons: config.get('semicolons'), + }; +} diff --git a/packages/vscode/src/preferences.ts b/packages/vscode/src/preferences.ts new file mode 100644 index 000000000..b8076ebe1 --- /dev/null +++ b/packages/vscode/src/preferences.ts @@ -0,0 +1,124 @@ +import type { WorkspaceConfiguration } from 'vscode'; +import type * as ts from 'typescript/lib/tsserverlibrary'; + +// vscode does not hold preferences with the same interface as typescript +// the following maps the vscode typescript preferences into what typescript expects +// This is heavily borrowed from vscode +// https://github.com/microsoft/vscode/blob/c04c0b43470c3c743468a5e5e51f036123503452/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts#L167 +export function intoPreferences( + config: WorkspaceConfiguration, + preferences: WorkspaceConfiguration +): ts.server.protocol.UserPreferences { + return { + quotePreference: getQuoteStylePreference(preferences), + importModuleSpecifierPreference: getImportModuleSpecifierPreference(preferences), + importModuleSpecifierEnding: getImportModuleSpecifierEndingPreference(preferences), + includeCompletionsWithClassMemberSnippets: config.get( + 'suggest.classMemberSnippets.enabled', + true + ), + includeCompletionsWithObjectLiteralMethodSnippets: config.get( + 'suggest.objectLiteralMethodSnippets.enabled', + true + ), + includeCompletionsWithSnippetText: true, + useLabelDetailsInCompletionEntries: true, + allowIncompleteCompletions: true, + displayPartsForJSDoc: true, + ...getInlayHintsPreferences(config), + }; +} + +function getQuoteStylePreference(config: WorkspaceConfiguration): 'single' | 'double' | 'auto' { + switch (config.get('quoteStyle')) { + case 'single': + return 'single'; + case 'double': + return 'double'; + default: + return 'auto'; + } +} + +function getImportModuleSpecifierEndingPreference( + config: WorkspaceConfiguration +): 'minimal' | 'index' | 'js' | 'auto' { + switch (config.get('importModuleSpecifierEnding')) { + case 'minimal': + return 'minimal'; + case 'index': + return 'index'; + case 'js': + return 'js'; + default: + return 'auto'; + } +} + +function getImportModuleSpecifierPreference( + config: WorkspaceConfiguration +): 'project-relative' | 'relative' | 'non-relative' | undefined { + switch (config.get('importModuleSpecifier')) { + case 'project-relative': + return 'project-relative'; + case 'relative': + return 'relative'; + case 'non-relative': + return 'non-relative'; + default: + return undefined; + } +} + +type InlaysPreferences = T extends `includeInlay${string}` ? T : never; + +function getInlayHintsPreferences( + config: WorkspaceConfiguration +): Pick< + ts.server.protocol.UserPreferences, + InlaysPreferences +> { + return { + includeInlayParameterNameHints: getInlayParameterNameHintsPreference(config), + includeInlayParameterNameHintsWhenArgumentMatchesName: !config.get( + 'inlayHints.parameterNames.suppressWhenArgumentMatchesName', + true + ), + includeInlayFunctionParameterTypeHints: config.get( + 'inlayHints.parameterTypes.enabled', + false + ), + includeInlayVariableTypeHints: config.get('inlayHints.variableTypes.enabled', false), + includeInlayVariableTypeHintsWhenTypeMatchesName: !config.get( + 'inlayHints.variableTypes.suppressWhenTypeMatchesName', + true + ), + includeInlayPropertyDeclarationTypeHints: config.get( + 'inlayHints.propertyDeclarationTypes.enabled', + false + ), + includeInlayFunctionLikeReturnTypeHints: config.get( + 'inlayHints.functionLikeReturnTypes.enabled', + false + ), + includeInlayEnumMemberValueHints: config.get( + 'inlayHints.enumMemberValues.enabled', + false + ), + } as const; +} + +function getInlayParameterNameHintsPreference( + config: WorkspaceConfiguration +): 'none' | 'literals' | 'all' | undefined { + switch (config.get('inlayHints.parameterNames.enabled')) { + case 'none': + return 'none'; + case 'literals': + return 'literals'; + case 'all': + return 'all'; + default: + return undefined; + } +}