Skip to content

Commit 821fe6a

Browse files
committed
feat: Introduce InlayHints
1 parent f522c89 commit 821fe6a

File tree

7 files changed

+378
-26
lines changed

7 files changed

+378
-26
lines changed

.vscode/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212
"--extensionDevelopmentPath=${workspaceFolder}/packages/vscode",
1313
"${workspaceFolder}/test-packages"
1414
]
15+
},
16+
{
17+
"type": "node",
18+
"request": "launch",
19+
"name": "Debug Current Test File",
20+
"autoAttachChildProcesses": true,
21+
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
22+
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
23+
"args": ["run", "${relativeFile}"],
24+
"smartStep": true,
25+
"console": "integratedTerminal"
1526
}
1627
]
1728
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Project } from 'glint-monorepo-test-utils';
2+
import { describe, beforeEach, afterEach, test, expect } from 'vitest';
3+
import { Position, Range } from 'vscode-languageserver';
4+
5+
describe('Language Server: iInlays', () => {
6+
let project!: Project;
7+
8+
beforeEach(async () => {
9+
project = await Project.create();
10+
});
11+
12+
afterEach(async () => {
13+
await project.destroy();
14+
});
15+
16+
test('it provides inlays for return types when preference is set', async () => {
17+
project.setGlintConfig({ environment: 'ember-template-imports' });
18+
let content = 'const bar = () => true;';
19+
project.write('foo.gts', content);
20+
let server = project.startLanguageServer();
21+
22+
const inlays = server.getInlayHints(
23+
{
24+
textDocument: {
25+
uri: project.fileURI('foo.gts'),
26+
},
27+
range: Range.create(Position.create(0, 0), Position.create(0, content.length)),
28+
},
29+
{
30+
includeInlayFunctionLikeReturnTypeHints: true,
31+
}
32+
);
33+
34+
expect(inlays.length).toBe(1);
35+
expect(inlays[0].kind).toBe(1);
36+
expect(inlays[0].label).toBe(': boolean');
37+
});
38+
39+
test('it provides inlays for variable types when preference is set', async () => {
40+
project.setGlintConfig({ environment: 'ember-template-imports' });
41+
let content = 'const bar = globalThis.thing ?? null;';
42+
project.write('foo.gts', content);
43+
let server = project.startLanguageServer();
44+
45+
const inlays = server.getInlayHints(
46+
{
47+
textDocument: {
48+
uri: project.fileURI('foo.gts'),
49+
},
50+
range: Range.create(Position.create(0, 0), Position.create(0, content.length)),
51+
},
52+
{
53+
includeInlayVariableTypeHints: true,
54+
}
55+
);
56+
57+
expect(inlays.length).toBe(1);
58+
expect(inlays[0].kind).toBe(1);
59+
expect(inlays[0].label).toBe(': any');
60+
});
61+
62+
test('it provides inlays for property types when preference is set', async () => {
63+
project.setGlintConfig({ environment: 'ember-template-imports' });
64+
let content = 'class Foo { date = Date.now() }';
65+
project.write('foo.gts', content);
66+
let server = project.startLanguageServer();
67+
68+
const inlays = server.getInlayHints(
69+
{
70+
textDocument: {
71+
uri: project.fileURI('foo.gts'),
72+
},
73+
range: Range.create(Position.create(0, 0), Position.create(0, content.length)),
74+
},
75+
{
76+
includeInlayPropertyDeclarationTypeHints: true,
77+
}
78+
);
79+
80+
expect(inlays.length).toBe(1);
81+
expect(inlays[0].kind).toBe(1);
82+
expect(inlays[0].label).toBe(': number');
83+
});
84+
});

packages/core/src/language-server/binding.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const capabilities: ServerCapabilities = {
2626
},
2727
referencesProvider: true,
2828
hoverProvider: true,
29+
inlayHintProvider: true,
2930
codeActionProvider: {
3031
codeActionKinds: [CodeActionKind.QuickFix],
3132
},
@@ -113,6 +114,13 @@ export function bindLanguageServerPool({
113114
});
114115
});
115116

117+
connection.languages.inlayHint.on((hint) => {
118+
return pool.withServerForURI(hint.textDocument.uri, ({ server }) => {
119+
let language = server.getLanguageType(hint.textDocument.uri);
120+
return server.getInlayHints(hint, configManager.getUserSettingsFor(language));
121+
});
122+
});
123+
116124
connection.onCodeAction(({ textDocument, range, context }) => {
117125
return pool.withServerForURI(textDocument.uri, ({ server }) => {
118126
// The user actually asked for the fix

packages/core/src/language-server/glint-language-server.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import {
2121
TextDocumentEdit,
2222
OptionalVersionedTextDocumentIdentifier,
2323
TextEdit,
24+
InlayHint,
25+
InlayHintParams,
26+
Position as LSPPosition,
27+
InlayHintKind,
2428
} from 'vscode-languageserver';
2529
import DocumentCache from '../common/document-cache.js';
2630
import { Position, positionToOffset } from './util/position.js';
@@ -381,6 +385,42 @@ export default class GlintLanguageServer {
381385
}
382386
}
383387

388+
public getInlayHints(hint: InlayHintParams, preferences: ts.UserPreferences = {}): InlayHint[] {
389+
let { uri } = hint.textDocument;
390+
let { range } = hint;
391+
let fileName = uriToFilePath(uri);
392+
393+
let { transformedStart, transformedEnd, transformedFileName } =
394+
this.getTransformedOffsetsFromPositions(
395+
uri,
396+
{
397+
line: range.start.line,
398+
character: range.start.character,
399+
},
400+
{
401+
line: range.end.line,
402+
character: range.end.character,
403+
}
404+
);
405+
406+
const inlayHints = this.service.provideInlayHints(
407+
transformedFileName,
408+
{
409+
start: transformedStart,
410+
length: transformedEnd - transformedStart,
411+
},
412+
preferences
413+
);
414+
415+
let content = this.documents.getDocumentContents(fileName);
416+
417+
return inlayHints
418+
.map((tsInlayHint) => {
419+
return this.transformTSInlayToLSPInlay(tsInlayHint, transformedFileName, content);
420+
})
421+
.filter(isHint);
422+
}
423+
384424
public getCodeActions(
385425
uri: string,
386426
actionKind: string,
@@ -404,6 +444,32 @@ export default class GlintLanguageServer {
404444
return this.glintConfig.environment.isTypedScript(file) ? 'typescript' : 'javascript';
405445
}
406446

447+
private transformTSInlayToLSPInlay(
448+
hint: ts.InlayHint,
449+
fileName: string,
450+
contents: string
451+
): InlayHint | undefined {
452+
let { position, text } = hint;
453+
let { originalStart } = this.transformManager.getOriginalRange(
454+
fileName,
455+
position,
456+
position + text.length
457+
);
458+
459+
const { line, character } = offsetToPosition(contents, originalStart);
460+
461+
let kind =
462+
hint.kind === 'Parameter'
463+
? InlayHintKind.Parameter
464+
: hint.kind === 'Type'
465+
? InlayHintKind.Type
466+
: undefined; // enums are not supported by LSP;
467+
468+
if (isInlayHintKind(kind)) {
469+
return InlayHint.create(LSPPosition.create(line, character), hint.text, kind);
470+
}
471+
}
472+
407473
private applyCodeAction(
408474
uri: string,
409475
range: Range,
@@ -651,3 +717,11 @@ export default class GlintLanguageServer {
651717
function onlyNumbers(entry: number | undefined): entry is number {
652718
return entry !== undefined;
653719
}
720+
721+
function isHint(hint: InlayHint | undefined): hint is InlayHint {
722+
return hint !== undefined;
723+
}
724+
725+
function isInlayHintKind(kind: number | undefined): kind is InlayHintKind {
726+
return kind !== undefined;
727+
}

packages/vscode/src/extension.ts

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
window,
1111
commands,
1212
workspace,
13-
WorkspaceConfiguration,
1413
} from 'vscode';
1514
import { Disposable, LanguageClient, ServerOptions } from 'vscode-languageclient/node.js';
1615
import type { Request, GetIRRequest } from '@glint/core/lsp-messages';
16+
import { intoFormatting } from './formatting';
17+
import { intoPreferences } from './preferences';
1718

1819
///////////////////////////////////////////////////////////////////////////////
1920
// Setup and extension lifecycle
@@ -109,28 +110,25 @@ async function addWorkspaceFolder(
109110

110111
let serverOptions: ServerOptions = { module: serverPath };
111112

112-
const typescriptFormatOptions = getOptions(workspace.getConfiguration('typescript'), 'format');
113-
const typescriptUserPreferences = getOptions(
114-
workspace.getConfiguration('typescript'),
115-
'preferences'
116-
);
117-
const javascriptFormatOptions = getOptions(workspace.getConfiguration('javascript'), 'format');
118-
const javascriptUserPreferences = getOptions(
119-
workspace.getConfiguration('javascript'),
120-
'preferences'
121-
);
113+
let typescriptConfig = workspace.getConfiguration('typescript');
114+
let typescriptFormatOptions = workspace.getConfiguration('typescript.format');
115+
let typescriptUserPreferences = workspace.getConfiguration('typescript.preferences');
116+
117+
let javaScriptConfig = workspace.getConfiguration('javascript');
118+
let javascriptFormatOptions = workspace.getConfiguration('javascript.format');
119+
let javascriptUserPreferences = workspace.getConfiguration('javascript.preferences');
122120

123121
let client = new LanguageClient('glint', 'Glint', serverOptions, {
124122
workspaceFolder,
125123
outputChannel,
126124
initializationOptions: {
127125
javascript: {
128-
format: javascriptFormatOptions,
129-
preferences: javascriptUserPreferences,
126+
format: intoFormatting(javascriptFormatOptions),
127+
preferences: intoPreferences(javaScriptConfig, javascriptUserPreferences),
130128
},
131129
typescript: {
132-
format: typescriptFormatOptions,
133-
preferences: typescriptUserPreferences,
130+
format: intoFormatting(typescriptFormatOptions),
131+
preferences: intoPreferences(typescriptConfig, typescriptUserPreferences),
134132
},
135133
},
136134
documentSelector: [{ scheme: 'file', pattern: `${folderPath}/${filePattern}` }],
@@ -191,14 +189,3 @@ function createConfigWatcher(): Disposable {
191189
function requestKey<R extends Request<string, unknown>>(name: R['name']): R['type'] {
192190
return name as unknown as R['type'];
193191
}
194-
195-
// Loads the TypeScript and JavaScript formating options from the workspace and subsets them to
196-
// pass to the language server.
197-
function getOptions(config: WorkspaceConfiguration, key: string): object {
198-
const formatOptions = config.get<object>(key);
199-
if (formatOptions) {
200-
return formatOptions;
201-
}
202-
203-
return {};
204-
}

packages/vscode/src/formatting.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { type WorkspaceConfiguration, window } from 'vscode';
2+
import type * as ts from 'typescript/lib/tsserverlibrary';
3+
4+
// vscode does not hold formatting config with the same interface as typescript
5+
// the following maps the vscode formatting options into what typescript expects
6+
// This is heavily borrowed from how the TypeScript works in vscode
7+
// https://github.com/microsoft/vscode/blob/c04c0b43470c3c743468a5e5e51f036123503452/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts#L133
8+
export function intoFormatting(
9+
config: WorkspaceConfiguration
10+
): ts.server.protocol.FormatCodeSettings {
11+
let editorOptions = window.activeTextEditor?.options;
12+
let tabSize = typeof editorOptions?.tabSize === 'string' ? undefined : editorOptions?.tabSize;
13+
let insertSpaces =
14+
typeof editorOptions?.insertSpaces === 'string' ? undefined : editorOptions?.insertSpaces;
15+
16+
return {
17+
tabSize,
18+
indentSize: tabSize,
19+
convertTabsToSpaces: insertSpaces,
20+
// We can use \n here since the editor normalizes later on to its line endings.
21+
newLineCharacter: '\n',
22+
insertSpaceAfterCommaDelimiter: config.get<boolean>('insertSpaceAfterCommaDelimiter'),
23+
insertSpaceAfterConstructor: config.get<boolean>('insertSpaceAfterConstructor'),
24+
insertSpaceAfterSemicolonInForStatements: config.get<boolean>(
25+
'insertSpaceAfterSemicolonInForStatements'
26+
),
27+
insertSpaceBeforeAndAfterBinaryOperators: config.get<boolean>(
28+
'insertSpaceBeforeAndAfterBinaryOperators'
29+
),
30+
insertSpaceAfterKeywordsInControlFlowStatements: config.get<boolean>(
31+
'insertSpaceAfterKeywordsInControlFlowStatements'
32+
),
33+
insertSpaceAfterFunctionKeywordForAnonymousFunctions: config.get<boolean>(
34+
'insertSpaceAfterFunctionKeywordForAnonymousFunctions'
35+
),
36+
insertSpaceBeforeFunctionParenthesis: config.get<boolean>(
37+
'insertSpaceBeforeFunctionParenthesis'
38+
),
39+
insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: config.get<boolean>(
40+
'insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis'
41+
),
42+
insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: config.get<boolean>(
43+
'insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets'
44+
),
45+
insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: config.get<boolean>(
46+
'insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces'
47+
),
48+
insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: config.get<boolean>(
49+
'insertSpaceAfterOpeningAndBeforeClosingEmptyBraces'
50+
),
51+
insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: config.get<boolean>(
52+
'insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces'
53+
),
54+
insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: config.get<boolean>(
55+
'insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces'
56+
),
57+
insertSpaceAfterTypeAssertion: config.get<boolean>('insertSpaceAfterTypeAssertion'),
58+
placeOpenBraceOnNewLineForFunctions: config.get<boolean>('placeOpenBraceOnNewLineForFunctions'),
59+
placeOpenBraceOnNewLineForControlBlocks: config.get<boolean>(
60+
'placeOpenBraceOnNewLineForControlBlocks'
61+
),
62+
semicolons: config.get<ts.server.protocol.SemicolonPreference>('semicolons'),
63+
};
64+
}

0 commit comments

Comments
 (0)