From 95059a9531eb3069480e001ca64322aab586d77b Mon Sep 17 00:00:00 2001 From: machty Date: Thu, 6 Mar 2025 13:57:38 -0500 Subject: [PATCH 01/51] wip --- .../language-server/definitions.test.ts | 11 ++++++++ packages/core/package.json | 1 + test-packages/test-utils/src/project.ts | 26 +++++++------------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions.test.ts b/packages/core/__tests__/language-server/definitions.test.ts index d61d07e65..2e67b366b 100644 --- a/packages/core/__tests__/language-server/definitions.test.ts +++ b/packages/core/__tests__/language-server/definitions.test.ts @@ -10,6 +10,17 @@ describe('Language Server: Definitions', () => { }); afterEach(async () => { + // vue takes this opportunity to close the docs on the langauge server. + // why don't they destroy? + + // vue tests are written in such a way to reuse the vue server and tsserver. + // we should follow along with that. + // one way we could make that work is + // for the Server that we return to keep track of all its open files so that + // when we dispose it it's just doing what these other files are doing. + + // QUESTION: does have duplicate teardown fns? + // ANSWER: YES await project.destroy(); }); diff --git a/packages/core/package.json b/packages/core/package.json index da4807093..892618657 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,7 @@ "execa": "^4.0.1", "glint-monorepo-test-utils": "^1.4.0", "strip-ansi": "^6.0.0", + "@typescript/server-harness": "latest", "vitest": "~1.0.0" }, "publishConfig": { diff --git a/test-packages/test-utils/src/project.ts b/test-packages/test-utils/src/project.ts index 1959aae67..5798e011d 100644 --- a/test-packages/test-utils/src/project.ts +++ b/test-packages/test-utils/src/project.ts @@ -6,14 +6,12 @@ import { execaNode, ExecaChildProcess, Options } from 'execa'; import { type GlintConfigInput } from '@glint/core/config-types'; import { pathUtils } from '@glint/core'; import { startLanguageServer, LanguageServerHandle } from '@volar/test-utils'; -import { FullDocumentDiagnosticReport } from '@volar/language-service'; +import { FullDocumentDiagnosticReport, TextDocument } from '@volar/language-service'; import { URI } from 'vscode-uri'; import { Diagnostic } from 'typescript'; import { Position, Range, TextEdit } from '@volar/language-server'; import { WorkspaceSymbolRequest, WorkspaceSymbolParams } from '@volar/language-server/node.js'; -// type GlintLanguageServer = ProjectAnalysis['languageServer']; - const require = createRequire(import.meta.url); const dirname = path.dirname(fileURLToPath(import.meta.url)); const fileUriToTemplatePackage = pathUtils.filePathToUri( @@ -45,8 +43,8 @@ const newWorkingDir = (): string => export class Project { private rootDir: string; - // private projectAnalysis?: ProjectAnalysis; private languageServerHandle?: LanguageServerHandle; + openedDocuments: TextDocument[] = []; private constructor(rootDir: string) { this.rootDir = rootDir; @@ -69,18 +67,7 @@ export class Project { const languageServerHandle = startLanguageServer('../core/bin/glint-language-server.js'); this.languageServerHandle = languageServerHandle; - const initializeParams = { - // TODO: is this necessary to add? - // typescript: { - // tsdk: path.join( - // path.dirname(fileURLToPath(import.meta.url)), - // '../', - // 'node_modules', - // 'typescript', - // 'lib', - // ), - // }, - }; + const initializeParams = {}; // We need to construct a capabilities object that mirrors how VScode + similar editors // will initialize the Language Server. @@ -301,9 +288,14 @@ export class Project { } public async destroy(): Promise { - // this.projectAnalysis?.shutdown(); this.languageServerHandle?.connection.dispose(); + + // rootDir... does that mean this Project instance is tied to a (possibly ephemeral) thing? + // Is it the case that Glint "opens" folders/workspaces whereas Vue does not? + + // Vue reuses `test-workspace`. How does that work??? Does it ever create files? + fs.rmSync(this.rootDir, { recursive: true, force: true }); } From 60e0e19756a9bc1fdf72a3e93498440473ba8ec0 Mon Sep 17 00:00:00 2001 From: machty Date: Thu, 6 Mar 2025 15:19:45 -0500 Subject: [PATCH 02/51] fix comment --- .../ts-plugin-tests/smoketest-ember-app-loose-and-gts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode/__tests__/ts-plugin-tests/smoketest-ember-app-loose-and-gts.test.ts b/packages/vscode/__tests__/ts-plugin-tests/smoketest-ember-app-loose-and-gts.test.ts index b62814170..0a0cfdd68 100644 --- a/packages/vscode/__tests__/ts-plugin-tests/smoketest-ember-app-loose-and-gts.test.ts +++ b/packages/vscode/__tests__/ts-plugin-tests/smoketest-ember-app-loose-and-gts.test.ts @@ -50,7 +50,7 @@ describe('Smoke test: Loose Mode + GTS with TS Plugin Mode', () => { edit.replace(new Range(4, 14, 4, 17), ''); }); - // Wait for a diagnostic to appear in the template + // Wait for the diagnostic to disappear await waitUntil(() => languages.getDiagnostics(scriptURI).length == 0); }); }); From 35fb93744324b2a954c56cfbda25e4482b2ac844 Mon Sep 17 00:00:00 2001 From: machty Date: Thu, 6 Mar 2025 16:40:01 -0500 Subject: [PATCH 03/51] get one new-style definition test running --- .../definitions-ts-plugin.test.ts | 248 ++++++++++++++++++ .../language-server/definitions.test.ts | 11 - packages/core/package.json | 2 +- .../tsconfig.json | 5 +- test-packages/test-utils/src/index.ts | 1 + .../test-utils/src/shared-test-workspace.ts | 143 ++++++++++ .../ts-plugin-test-app/tsconfig.json | 3 +- .../ts-template-imports-app/tsconfig.json | 3 +- yarn.lock | 5 + 9 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 packages/core/__tests__/language-server/definitions-ts-plugin.test.ts create mode 100644 test-packages/test-utils/src/shared-test-workspace.ts diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts new file mode 100644 index 000000000..1c8856098 --- /dev/null +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -0,0 +1,248 @@ +import { + Project, + getSharedTestWorkspaceHelper, + teardownSharedTestWorkspaceAfterEach, + prepareDocument, + testWorkspacePath, +} from 'glint-monorepo-test-utils'; +import { describe, beforeEach, afterEach, test, expect } from 'vitest'; +import { stripIndent } from 'common-tags'; +import { URI } from 'vscode-uri'; + +describe('Language Server: Definitions (ts plugin)', () => { + afterEach(teardownSharedTestWorkspaceAfterEach); + + /* + test.skip('querying a standalone template', async () => { + project.setGlintConfig({ environment: 'ember-loose' }); + project.write('index.hbs', '{{foo}}'); + + let server = await project.startLanguageServer(); + let definitions = await server.sendDefinitionRequest(project.fileURI('index.hbs'), { + line: 0, + character: 17, + }); + + expect(definitions).toMatchObject([ + { + uri: project.fileURI('index.hbs'), + range: { + start: { line: 0, character: 9 }, + end: { line: 0, character: 12 }, + }, + }, + ]); + }); + */ + + test.only('component invocation', async () => { + // TODO: prepareDoucment for Greeting.gts and go from there. + + expect( + await requestDefinition( + 'ts-template-imports-app/src/FakeFileComponent.gts', + 'typescript', + stripIndent` + import Component from '@glimmer/component'; + import Greeting from './Greeting.gts'; + + export default class Application extends Component { + + } + `, + ), + ).toMatchInlineSnapshot(` + [ + { + "contextEnd": { + "line": 2, + "offset": 39, + }, + "contextStart": { + "line": 2, + "offset": 1, + }, + "end": { + "line": 2, + "offset": 16, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/FakeFileComponent.gts", + "start": { + "line": 2, + "offset": 8, + }, + }, + ] + `); + }); + + /* + test('arg passing', async () => { + project.write({ + 'greeting.gts': stripIndent` + import Component from '@glimmer/component'; + + export type GreetingArgs = { + message: string; + }; + + export default class Greeting extends Component<{ Args: GreetingArgs }> { + + } + `, + 'index.gts': stripIndent` + import Component from '@glimmer/component'; + import Greeting from './greeting'; + + export default class Application extends Component { + + } + `, + }); + + let server = await project.startLanguageServer(); + let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { + line: 5, + character: 17, + }); + + expect(definitions).toMatchInlineSnapshot(` + [ + { + "range": { + "end": { + "character": 18, + "line": 3, + }, + "start": { + "character": 2, + "line": 3, + }, + }, + "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", + }, + ] + `); + }); + + test('arg use', async () => { + project.write({ + 'greeting.gts': stripIndent` + import Component from '@glimmer/component'; + + export type GreetingArgs = { + message: string; + }; + + export default class Greeting extends Component<{ Args: GreetingArgs }> { + + } + `, + }); + + let server = await project.startLanguageServer(); + let definitions = await server.sendDefinitionRequest(project.fileURI('greeting.gts'), { + line: 7, + character: 18, + }); + + expect(definitions).toMatchInlineSnapshot(` + [ + { + "range": { + "end": { + "character": 18, + "line": 3, + }, + "start": { + "character": 2, + "line": 3, + }, + }, + "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", + }, + ] + `); + }); + + test('import source', async () => { + project.write({ + 'greeting.gts': stripIndent` + import Component from '@glimmer/component'; + + export type GreetingArgs = { + message: string; + }; + + export default class Greeting extends Component<{ Args: GreetingArgs }> { + + } + `, + 'index.gts': stripIndent` + import Component from '@glimmer/component'; + import Greeting from './greeting'; + + export class Application extends Component { + + } + `, + }); + + let server = await project.startLanguageServer(); + let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { + line: 1, + character: 27, + }); + + expect(definitions).toMatchInlineSnapshot(` + [ + { + "range": { + "end": { + "character": 0, + "line": 0, + }, + "start": { + "character": 0, + "line": 0, + }, + }, + "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", + }, + ] + `); + }); + */ +}); + +async function requestDefinition(fileName: string, languageId: string, content: string) { + const offset = content.indexOf('|'); + expect(offset).toBeGreaterThanOrEqual(0); + content = content.slice(0, offset) + content.slice(offset + 1); + + const workspaceHelper = await getSharedTestWorkspaceHelper(); + let document = await prepareDocument(fileName, languageId, content); + + const res = await workspaceHelper.tsserver.message({ + seq: workspaceHelper.nextSeq(), + command: 'definition', + arguments: { + file: URI.parse(document.uri).fsPath, + position: offset, + }, + }); + expect(res.success).toBe(true); + + for (const ref of res.body) { + console.log(ref.file); + ref.file = '${testWorkspacePath}' + ref.file.slice(testWorkspacePath.length); + } + + return res.body; +} diff --git a/packages/core/__tests__/language-server/definitions.test.ts b/packages/core/__tests__/language-server/definitions.test.ts index 2e67b366b..d61d07e65 100644 --- a/packages/core/__tests__/language-server/definitions.test.ts +++ b/packages/core/__tests__/language-server/definitions.test.ts @@ -10,17 +10,6 @@ describe('Language Server: Definitions', () => { }); afterEach(async () => { - // vue takes this opportunity to close the docs on the langauge server. - // why don't they destroy? - - // vue tests are written in such a way to reuse the vue server and tsserver. - // we should follow along with that. - // one way we could make that work is - // for the Server that we return to keep track of all its open files so that - // when we dispose it it's just doing what these other files are doing. - - // QUESTION: does have duplicate teardown fns? - // ANSWER: YES await project.destroy(); }); diff --git a/packages/core/package.json b/packages/core/package.json index 892618657..86f8821ea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "yargs": "^17.5.1" }, "devDependencies": { + "@typescript/server-harness": "latest", "@types/common-tags": "^1.8.0", "@types/node": "^18.11.5", "@types/semver": "^7.3.13", @@ -64,7 +65,6 @@ "execa": "^4.0.1", "glint-monorepo-test-utils": "^1.4.0", "strip-ansi": "^6.0.0", - "@typescript/server-harness": "latest", "vitest": "~1.0.0" }, "publishConfig": { diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json index 6811f1e23..44ea26fc0 100644 --- a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json @@ -5,9 +5,6 @@ "enableTsPlugin": true }, "compilerOptions": { - "baseUrl": ".", - - // TODO: work out the interplay between this and typescriptServerPlugins in extension's package.json - "plugins": [{ "name": "@glint/typescript-plugin" }] + "baseUrl": "." } } diff --git a/test-packages/test-utils/src/index.ts b/test-packages/test-utils/src/index.ts index 9b10e03d8..3d7422711 100644 --- a/test-packages/test-utils/src/index.ts +++ b/test-packages/test-utils/src/index.ts @@ -1,2 +1,3 @@ export * from './project.js'; export * from './composite-project.js'; +export * from './shared-test-workspace.js'; diff --git a/test-packages/test-utils/src/shared-test-workspace.ts b/test-packages/test-utils/src/shared-test-workspace.ts new file mode 100644 index 000000000..c36193e36 --- /dev/null +++ b/test-packages/test-utils/src/shared-test-workspace.ts @@ -0,0 +1,143 @@ +import { launchServer } from '@typescript/server-harness'; +import { + ConfigurationRequest, + PublishDiagnosticsNotification, + TextDocument, +} from '@volar/language-server'; +import type { LanguageServerHandle } from '@volar/test-utils'; +import { startLanguageServer } from '@volar/test-utils'; +import * as path from 'node:path'; +import { URI } from 'vscode-uri'; +// import { VueInitializationOptions } from '../lib/types'; + +let serverHandle: LanguageServerHandle | undefined; +let tsserver: import('@typescript/server-harness').Server; +let seq = 1; + +export const testWorkspacePath = path.resolve(__dirname, '../..'); + +export async function getSharedTestWorkspaceHelper(): Promise<{ + glintserver: LanguageServerHandle; + tsserver: import('@typescript/server-harness').Server; + nextSeq: () => number; + open: (uri: string, languageId: string, content: string) => Promise; + close: (uri: string) => Promise; +}> { + if (!serverHandle) { + tsserver = launchServer( + path.join(__dirname, '..', '..', '..', 'node_modules', 'typescript', 'lib', 'tsserver.js'), + [ + '--disableAutomaticTypingAcquisition', + '--globalPlugins', + '@glint/typescript-plugin', + '--suppressDiagnosticEvents', + // '--logVerbosity', 'verbose', + // '--logFile', path.join(__dirname, 'tsserver.log'), + ], + ); + + tsserver.on('exit', (code) => console.log(code ? `Exited with code ${code}` : `Terminated`)); + // tsserver.on('event', e => console.log(e)); + + serverHandle = startLanguageServer( + require.resolve('../../../packages/core/bin/glint-language-server.js'), + testWorkspacePath, + ); + serverHandle.connection.onNotification(PublishDiagnosticsNotification.type, () => {}); + serverHandle.connection.onRequest(ConfigurationRequest.type, ({ items }) => { + return items.map(({ section }) => { + // TODO: copied this from Vue... do we have inlay hints? + if (section?.startsWith('glint.inlayHints.')) { + return true; + } + return null; + }); + }); + serverHandle.connection.onRequest('forwardingTsRequest', async ([command, args]) => { + const res = await tsserver.message({ + seq: seq++, + command: command, + arguments: args, + }); + return res.body; + }); + + await serverHandle.initialize( + URI.file(testWorkspacePath).toString(), + { + typescript: { + tsdk: path.dirname(require.resolve('typescript/lib/typescript.js')), + // requestForwardingCommand: 'forwardingTsRequest', + }, + // } satisfies VueInitializationOptions, + }, + { + workspace: { + configuration: true, + }, + }, + ); + } + return { + glintserver: serverHandle, + tsserver: tsserver, + nextSeq: () => seq++, + open: async (uri: string, languageId: string, content: string) => { + const res = await tsserver.message({ + seq: seq++, + type: 'request', + command: 'updateOpen', + arguments: { + changedFiles: [], + closedFiles: [], + openFiles: [ + { + file: URI.parse(uri).fsPath, + fileContent: content, + }, + ], + }, + }); + if (!res.success) { + throw new Error(res.body); + } + return await serverHandle!.openInMemoryDocument(uri, languageId, content); + }, + close: async (uri: string) => { + const res = await tsserver.message({ + seq: seq++, + type: 'request', + command: 'updateOpen', + arguments: { + changedFiles: [], + closedFiles: [URI.parse(uri).fsPath], + openFiles: [], + }, + }); + if (!res.success) { + throw new Error(res.body); + } + await serverHandle!.closeTextDocument(uri); + }, + }; +} + +const openedDocuments: TextDocument[] = []; + +export async function teardownSharedTestWorkspaceAfterEach() { + const server = await getSharedTestWorkspaceHelper(); + for (const document of openedDocuments) { + await server.close(document.uri); + } + openedDocuments.length = 0; +} + +export async function prepareDocument(fileName: string, languageId: string, content: string) { + const server = await getSharedTestWorkspaceHelper(); + const uri = URI.file(`${testWorkspacePath}/${fileName}`); + const document = await server.open(uri.toString(), languageId, content); + if (openedDocuments.every((d) => d.uri !== document.uri)) { + openedDocuments.push(document); + } + return document; +} diff --git a/test-packages/ts-plugin-test-app/tsconfig.json b/test-packages/ts-plugin-test-app/tsconfig.json index 60208a4c0..3dab470db 100644 --- a/test-packages/ts-plugin-test-app/tsconfig.json +++ b/test-packages/ts-plugin-test-app/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.compileroptions.json", "compilerOptions": { - "baseUrl": ".", - "plugins": [{ "name": "@glint/typescript-plugin" }] + "baseUrl": "." }, "include": ["src", "types"], "glint": { diff --git a/test-packages/ts-template-imports-app/tsconfig.json b/test-packages/ts-template-imports-app/tsconfig.json index 9c4caf689..cbbfc8311 100644 --- a/test-packages/ts-template-imports-app/tsconfig.json +++ b/test-packages/ts-template-imports-app/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.compileroptions.json", "compilerOptions": { - "baseUrl": ".", - "plugins": [{ "name": "@glint/typescript-plugin" }] + "baseUrl": "." }, "include": ["src", "types"], "glint": { diff --git a/yarn.lock b/yarn.lock index d5d2f0e36..1e54e7d2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3499,6 +3499,11 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript/server-harness@latest": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@typescript/server-harness/-/server-harness-0.3.5.tgz#4dcb26f5436da5a129e961fb749130f60dd713ac" + integrity sha512-YT9oe27zm7HdGXYad5SZrdJzVe9eavG3F6YplsWvAraowGtuDeY7FHPVuQPtQj6GxG097Us4JDkA8n5I4iQovQ== + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" From f141748fa37693b6589bcc6526cc64fe43bd00ad Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 04:13:55 -0500 Subject: [PATCH 04/51] single passing TS Plugin test --- .gitignore | 1 + .../definitions-ts-plugin.test.ts | 18 +++-- packages/core/vitest.config.ts | 12 ++++ .../src/typescript-server-plugin.ts | 71 +++++++++++-------- .../test-utils/src/shared-test-workspace.ts | 10 ++- 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 7767a3391..513243340 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ test-packages/ephemeral .eslintcache *.tsbuildinfo *.js.map +tsserver.log diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index 1c8856098..751a28d48 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -36,8 +36,6 @@ describe('Language Server: Definitions (ts plugin)', () => { */ test.only('component invocation', async () => { - // TODO: prepareDoucment for Greeting.gts and go from there. - expect( await requestDefinition( 'ts-template-imports-app/src/FakeFileComponent.gts', @@ -57,21 +55,21 @@ describe('Language Server: Definitions (ts plugin)', () => { [ { "contextEnd": { - "line": 2, - "offset": 39, + "line": 14, + "offset": 2, }, "contextStart": { - "line": 2, + "line": 8, "offset": 1, }, "end": { - "line": 2, - "offset": 16, + "line": 8, + "offset": 30, }, - "file": "\${testWorkspacePath}/ts-template-imports-app/src/FakeFileComponent.gts", + "file": "\${testWorkspacePath}/ts-template-imports-app/src/Greeting.gts", "start": { - "line": 2, - "offset": 8, + "line": 8, + "offset": 22, }, }, ] diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index fe2e11955..a32b1fef7 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -10,5 +10,17 @@ export default defineConfig({ // the built executable script. watchExclude: ['src/cli/**'], testTimeout: 30_000, + + poolOptions: { + // Copied these from Vue; anecdotally (according to Vue + // maintainers) they actually speed things up for the + // kinds of Language Server / tsserver tests we run, but more importantly + // our reuse of the shared tsserver instance (via tsserver harness) means + // we can't be running tests in parallel. + forks: { + singleFork: true, + isolate: false, + } + }, }, }); diff --git a/packages/typescript-plugin/src/typescript-server-plugin.ts b/packages/typescript-plugin/src/typescript-server-plugin.ts index a9b6deaab..af00b0b3d 100644 --- a/packages/typescript-plugin/src/typescript-server-plugin.ts +++ b/packages/typescript-plugin/src/typescript-server-plugin.ts @@ -1,37 +1,52 @@ const { createJiti } = require('jiti'); const jiti = createJiti(__filename); +import type * as ts from 'typescript'; + const { createLanguageServicePlugin, } = require('@volar/typescript/lib/quickstart/createLanguageServicePlugin.js'); -const plugin = createLanguageServicePlugin((_ts: typeof import('typescript'), info: any) => { - /** - * we use the jiti (https://github.com/unjs/jiti) runtime to make it possible to - * synchronously load the ESM glint libaries from the current CommonJS context. It is a requirement - * that TypeScript plugins are written in CommonJS, which poses issues with - * having Glint be authored in ESM due to the requirement that typically `await import` - * is required to load ESM modules from CJS. But with jiti we can synchronously load the ESM - * modules from CJS which lets us avoid a ton of hacks and complexity we (or Volar) - * would otherwise have to write to bridge the sync/async APIs. - */ - const glintCore = jiti('@glint/core'); - - const { findConfig, createEmberLanguagePlugin } = glintCore; - - const cwd = info.languageServiceHost.getCurrentDirectory(); - const glintConfig = findConfig(cwd); - - if (glintConfig && glintConfig.enableTsPlugin) { - const gtsLanguagePlugin = createEmberLanguagePlugin(glintConfig); - return { - languagePlugins: [gtsLanguagePlugin], - }; - } else { - return { - languagePlugins: [], - }; - } -}); +const plugin = createLanguageServicePlugin( + (_ts: typeof import('typescript'), info: ts.server.PluginCreateInfo) => { + /** + * we use the jiti (https://github.com/unjs/jiti) runtime to make it possible to + * synchronously load the ESM glint libaries from the current CommonJS context. It is a requirement + * that TypeScript plugins are written in CommonJS, which poses issues with + * having Glint be authored in ESM due to the requirement that typically `await import` + * is required to load ESM modules from CJS. But with jiti we can synchronously load the ESM + * modules from CJS which lets us avoid a ton of hacks and complexity we (or Volar) + * would otherwise have to write to bridge the sync/async APIs. + */ + const glintCore = jiti('@glint/core'); + + const { findConfig, createEmberLanguagePlugin } = glintCore; + + const cwd = info.languageServiceHost.getCurrentDirectory(); + const glintConfig = findConfig(cwd); + + // Uncomment as a smoke test to see if the plugin is running + const enableLogging = false; + + if (glintConfig) { + if (enableLogging) { + info.project.projectService.logger.info('Glint TS Plugin is running!'); + } + + const gtsLanguagePlugin = createEmberLanguagePlugin(glintConfig); + return { + languagePlugins: [gtsLanguagePlugin], + }; + } else { + if (enableLogging) { + info.project.projectService.logger.info('Glint TS Plugin is NOT running!'); + } + + return { + languagePlugins: [], + }; + } + }, +); export = plugin; diff --git a/test-packages/test-utils/src/shared-test-workspace.ts b/test-packages/test-utils/src/shared-test-workspace.ts index c36193e36..559d49a20 100644 --- a/test-packages/test-utils/src/shared-test-workspace.ts +++ b/test-packages/test-utils/src/shared-test-workspace.ts @@ -31,13 +31,17 @@ export async function getSharedTestWorkspaceHelper(): Promise<{ '--globalPlugins', '@glint/typescript-plugin', '--suppressDiagnosticEvents', - // '--logVerbosity', 'verbose', - // '--logFile', path.join(__dirname, 'tsserver.log'), + // '--logVerbosity', + // 'verbose', + // '--logFile', + // path.join(__dirname, '..', '..', '..', 'tsserver.log'), ], ); tsserver.on('exit', (code) => console.log(code ? `Exited with code ${code}` : `Terminated`)); - // tsserver.on('event', e => console.log(e)); + + // Uncomment to show additional event logging (less verbose than tsserver.log) + // tsserver.on('event', (e) => console.log(e)); serverHandle = startLanguageServer( require.resolve('../../../packages/core/bin/glint-language-server.js'), From 7770a65462cab23610babfc159b2222d6f3a49fe Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:27:51 -0500 Subject: [PATCH 05/51] get ts hbs test passing --- .../definitions-ts-plugin.test.ts | 124 ++++++++++++++---- packages/core/src/config/config.ts | 2 - packages/core/src/config/types.cts | 1 - .../template/map-template-contents.ts | 2 - .../core/src/volar/ember-language-plugin.ts | 2 - packages/core/src/volar/language-server.ts | 12 +- .../ember-app-loose-and-gts/tsconfig.json | 3 +- .../tsconfig.json | 3 +- .../ts-plugin-test-app/tsconfig.json | 1 - 9 files changed, 108 insertions(+), 42 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index 751a28d48..14c169078 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -8,37 +8,84 @@ import { import { describe, beforeEach, afterEach, test, expect } from 'vitest'; import { stripIndent } from 'common-tags'; import { URI } from 'vscode-uri'; +import { TextDocument } from 'vscode-languageserver-textdocument'; describe('Language Server: Definitions (ts plugin)', () => { afterEach(teardownSharedTestWorkspaceAfterEach); - /* - test.skip('querying a standalone template', async () => { - project.setGlintConfig({ environment: 'ember-loose' }); - project.write('index.hbs', '{{foo}}'); + // not possible in Glint 2. + // test('querying a standalone template'); - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.hbs'), { - line: 0, - character: 17, - }); + test.only('querying a template with a simple backing component', async () => { + const [[blockParamOffset, valueOffset], templateContent] = extractCursors( + stripIndent` + {{foo}}{{this.val%ue}} + `, + ); + + const templateDoc = await prepareDocument( + 'ts-ember-app/app/components/ephemeral.hbs', + 'handlebars', + templateContent, + ); - expect(definitions).toMatchObject([ - { - uri: project.fileURI('index.hbs'), - range: { - start: { line: 0, character: 9 }, - end: { line: 0, character: 12 }, + await prepareDocument( + 'ts-ember-app/app/components/ephemeral.ts', + 'typescript', + stripIndent` + import Component from '@glimmer/component'; + + export default class Foo extends Component { + value = 123; + } + `, + ); + + expect(await performDefinitionRequest(templateDoc, blockParamOffset)).toMatchInlineSnapshot(` + [ + { + "end": { + "line": 1, + "offset": 13, + }, + "file": "\${testWorkspacePath}/ts-ember-app/app/components/ephemeral.hbs", + "start": { + "line": 1, + "offset": 10, + }, + }, + ] + `); + + expect(await performDefinitionRequest(templateDoc, valueOffset)).toMatchInlineSnapshot(` + [ + { + "contextEnd": { + "line": 4, + "offset": 15, + }, + "contextStart": { + "line": 4, + "offset": 3, + }, + "end": { + "line": 4, + "offset": 8, + }, + "file": "\${testWorkspacePath}/ts-ember-app/app/components/ephemeral.ts", + "start": { + "line": 4, + "offset": 3, + }, }, - }, - ]); + ] + `); }); - */ - test.only('component invocation', async () => { + test('component invocation', async () => { expect( await requestDefinition( - 'ts-template-imports-app/src/FakeFileComponent.gts', + 'ts-template-imports-app/src/ephemeral.gts', 'typescript', stripIndent` import Component from '@glimmer/component'; @@ -46,7 +93,7 @@ describe('Language Server: Definitions (ts plugin)', () => { export default class Application extends Component { } `, @@ -219,14 +266,19 @@ describe('Language Server: Definitions (ts plugin)', () => { */ }); -async function requestDefinition(fileName: string, languageId: string, content: string) { - const offset = content.indexOf('|'); - expect(offset).toBeGreaterThanOrEqual(0); - content = content.slice(0, offset) + content.slice(offset + 1); +async function requestDefinition(fileName: string, languageId: string, contentWithCursor: string) { + const [offset, content] = extractCursor(contentWithCursor); - const workspaceHelper = await getSharedTestWorkspaceHelper(); let document = await prepareDocument(fileName, languageId, content); + const res = await performDefinitionRequest(document, offset); + + return res; +} + +async function performDefinitionRequest(document: TextDocument, offset: number) { + const workspaceHelper = await getSharedTestWorkspaceHelper(); + const res = await workspaceHelper.tsserver.message({ seq: workspaceHelper.nextSeq(), command: 'definition', @@ -238,9 +290,25 @@ async function requestDefinition(fileName: string, languageId: string, content: expect(res.success).toBe(true); for (const ref of res.body) { - console.log(ref.file); ref.file = '${testWorkspacePath}' + ref.file.slice(testWorkspacePath.length); } - return res.body; } + +function extractCursor(contentWithCursors: string): [number, string] { + const [offsets, content] = extractCursors(contentWithCursors); + expect(offsets.length).toEqual(1); + const offset = offsets[0]; + return [offset, content]; +} + +function extractCursors(content: string): [number[], string] { + const offsets = []; + while (true) { + const offset = content.indexOf('%'); + if (offset === -1) break; + offsets.push(offset); + content = content.slice(0, offset) + content.slice(offset + 1); + } + return [offsets, content]; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 86213a36c..c3545102a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -12,7 +12,6 @@ export class GlintConfig { public readonly configPath: string; public readonly environment: GlintEnvironment; public readonly checkStandaloneTemplates: boolean; - public readonly enableTsPlugin: boolean; public constructor( ts: typeof import('typescript'), @@ -24,7 +23,6 @@ export class GlintConfig { this.rootDir = path.dirname(configPath); this.environment = GlintEnvironment.load(config.environment, { rootDir: this.rootDir }); this.checkStandaloneTemplates = config.checkStandaloneTemplates ?? true; - this.enableTsPlugin = config.enableTsPlugin ?? false; } } diff --git a/packages/core/src/config/types.cts b/packages/core/src/config/types.cts index 691a611dc..4cb6a4b49 100644 --- a/packages/core/src/config/types.cts +++ b/packages/core/src/config/types.cts @@ -8,7 +8,6 @@ type TSLib = typeof ts; export type GlintConfigInput = { environment: string | Array | Record; checkStandaloneTemplates?: boolean; - enableTsPlugin?: boolean; }; export type GlintEnvironmentConfig = { diff --git a/packages/core/src/transform/template/map-template-contents.ts b/packages/core/src/transform/template/map-template-contents.ts index 76abd35ea..5d44fea57 100644 --- a/packages/core/src/transform/template/map-template-contents.ts +++ b/packages/core/src/transform/template/map-template-contents.ts @@ -323,8 +323,6 @@ export function mapTemplateContents( if (!expectErrorToken) { mapper.text(`// @glint-expect-error BEGIN AREA_OF_EFFECT`); mapper.newline(); - // } else { - // let a = 'wat'; } expectErrorToken = { diff --git a/packages/core/src/volar/ember-language-plugin.ts b/packages/core/src/volar/ember-language-plugin.ts index a05faf6f1..de5a99b42 100644 --- a/packages/core/src/volar/ember-language-plugin.ts +++ b/packages/core/src/volar/ember-language-plugin.ts @@ -38,13 +38,11 @@ export function createEmberLanguagePlugin( } }, - // When does this get called? createVirtualCode(scriptId: URI | string, languageId, snapshot, codegenContext) { const scriptIdStr = String(scriptId); // See: https://github.com/JetBrains/intellij-plugins/blob/11a9149e20f4d4ba2c1600da9f2b81ff88bd7c97/Angular/src/angular-service/src/index.ts#L31 if ( - glintConfig.enableTsPlugin && // Loose mode not supported for classic "takeover" mode, only TS Plugin languageId === 'typescript' && !scriptIdStr.endsWith('.d.ts') && scriptIdStr.indexOf('/node_modules/') < 0 diff --git a/packages/core/src/volar/language-server.ts b/packages/core/src/volar/language-server.ts index 95e9054cc..15907926e 100644 --- a/packages/core/src/volar/language-server.ts +++ b/packages/core/src/volar/language-server.ts @@ -8,7 +8,7 @@ import { import { createEmberLanguagePlugin } from './ember-language-plugin.js'; import { ConfigLoader } from '../config/loader.js'; import ts from 'typescript'; -import { Disposable } from '@volar/language-service'; +import { Disposable, LanguagePlugin, URI, VirtualCode } from '@volar/language-service'; import { createTypescriptLanguageServicePlugin } from './typescript-language-service-plugin.js'; const connection = createConnection(); @@ -48,7 +48,15 @@ connection.onInitialize((parameters) => { // assert(glintConfig, 'Glint config is missing'); if (glintConfig) { - if (!glintConfig.enableTsPlugin) { + // TODO: this is where we used to initialize the Language Server + // with our Ember Language Plugin so that we can teach the LS how to perform + // TS diagnostics, but this functionality has been moved to TS Plugin. + // There will still be commands and other tooling that is useful to have + // in the Language Server so we should identify what those are and reinstate, + // while continu + + const disableLanguagePlugin = true; + if (!disableLanguagePlugin) { // When TS Plugin is enabled, we want the TS Plugin to perform all type-checking/diagnostics/etc, // rather than the Language Server. languagePlugins.unshift(createEmberLanguagePlugin(glintConfig)); diff --git a/packages/vscode/__fixtures__/ember-app-loose-and-gts/tsconfig.json b/packages/vscode/__fixtures__/ember-app-loose-and-gts/tsconfig.json index 787fc5937..ebe684ff2 100644 --- a/packages/vscode/__fixtures__/ember-app-loose-and-gts/tsconfig.json +++ b/packages/vscode/__fixtures__/ember-app-loose-and-gts/tsconfig.json @@ -7,7 +7,6 @@ "skipLibCheck": true }, "glint": { - "environment": ["ember-loose", "ember-template-imports"], - "enableTsPlugin": true + "environment": ["ember-loose", "ember-template-imports"] } } diff --git a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json index 44ea26fc0..f20ee6fa3 100644 --- a/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json +++ b/packages/vscode/__fixtures__/template-imports-app-ts-plugin/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../../../tsconfig.compileroptions.json", "glint": { - "environment": ["ember-loose", "ember-template-imports"], - "enableTsPlugin": true + "environment": ["ember-loose", "ember-template-imports"] }, "compilerOptions": { "baseUrl": "." diff --git a/test-packages/ts-plugin-test-app/tsconfig.json b/test-packages/ts-plugin-test-app/tsconfig.json index 3dab470db..cbbfc8311 100644 --- a/test-packages/ts-plugin-test-app/tsconfig.json +++ b/test-packages/ts-plugin-test-app/tsconfig.json @@ -5,7 +5,6 @@ }, "include": ["src", "types"], "glint": { - "enableTsPlugin": true, "environment": { "ember-loose": {}, "ember-template-imports": { From cf251b06456d38ca6bc2d931010f0b5d09e7f6c7 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:37:26 -0500 Subject: [PATCH 06/51] one more passing --- .../definitions-ts-plugin.test.ts | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index 14c169078..3929e9689 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -13,10 +13,10 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; describe('Language Server: Definitions (ts plugin)', () => { afterEach(teardownSharedTestWorkspaceAfterEach); - // not possible in Glint 2. + // not possible in Glint 2: // test('querying a standalone template'); - test.only('querying a template with a simple backing component', async () => { + test('querying a template with a simple backing component', async () => { const [[blockParamOffset, valueOffset], templateContent] = extractCursors( stripIndent` {{foo}}{{this.val%ue}} @@ -123,57 +123,49 @@ describe('Language Server: Definitions (ts plugin)', () => { `); }); - /* test('arg passing', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - message: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - 'index.gts': stripIndent` + expect( + await requestDefinition( + 'ts-template-imports-app/src/ephemeral.gts', + 'typescript', + stripIndent` import Component from '@glimmer/component'; - import Greeting from './greeting'; + import Greeting from './Greeting.gts'; export default class Application extends Component { } `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { - line: 5, - character: 17, - }); - - expect(definitions).toMatchInlineSnapshot(` + ), + ).toMatchInlineSnapshot(` [ { - "range": { - "end": { - "character": 18, - "line": 3, - }, - "start": { - "character": 2, - "line": 3, - }, + "contextEnd": { + "line": 5, + "offset": 25, + }, + "contextStart": { + "line": 5, + "offset": 11, + }, + "end": { + "line": 5, + "offset": 17, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/Greeting.gts", + "start": { + "line": 5, + "offset": 11, }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", }, ] `); }); + /* + test('arg use', async () => { project.write({ 'greeting.gts': stripIndent` From d400de7db10fd3e593599ba46ccb919e719f7a45 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:40:55 -0500 Subject: [PATCH 07/51] more --- .../definitions-ts-plugin.test.ts | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index 3929e9689..ccc52dc2e 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -86,7 +86,7 @@ describe('Language Server: Definitions (ts plugin)', () => { expect( await requestDefinition( 'ts-template-imports-app/src/ephemeral.gts', - 'typescript', + 'glimmer-ts', stripIndent` import Component from '@glimmer/component'; import Greeting from './Greeting.gts'; @@ -127,7 +127,7 @@ describe('Language Server: Definitions (ts plugin)', () => { expect( await requestDefinition( 'ts-template-imports-app/src/ephemeral.gts', - 'typescript', + 'glimmer-ts', stripIndent` import Component from '@glimmer/component'; import Greeting from './Greeting.gts'; @@ -164,11 +164,12 @@ describe('Language Server: Definitions (ts plugin)', () => { `); }); - /* - test('arg use', async () => { - project.write({ - 'greeting.gts': stripIndent` + expect( + await requestDefinition( + 'ts-template-imports-app/src/ephemeral.gts', + 'glimmer-ts', + stripIndent` import Component from '@glimmer/component'; export type GreetingArgs = { @@ -176,36 +177,38 @@ describe('Language Server: Definitions (ts plugin)', () => { }; export default class Greeting extends Component<{ Args: GreetingArgs }> { - + } `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('greeting.gts'), { - line: 7, - character: 18, - }); - - expect(definitions).toMatchInlineSnapshot(` + ), + ).toMatchInlineSnapshot(` [ { - "range": { - "end": { - "character": 18, - "line": 3, - }, - "start": { - "character": 2, - "line": 3, - }, + "contextEnd": { + "line": 4, + "offset": 19, + }, + "contextStart": { + "line": 4, + "offset": 3, + }, + "end": { + "line": 4, + "offset": 10, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral.gts", + "start": { + "line": 4, + "offset": 3, }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", }, ] `); }); + /* + + test('import source', async () => { project.write({ 'greeting.gts': stripIndent` From e8c3581dcaf7edd27eeb705c90fd99d292a4bdf6 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:42:10 -0500 Subject: [PATCH 08/51] done --- .../definitions-ts-plugin.test.ts | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index ccc52dc2e..7913fd407 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -1,5 +1,4 @@ import { - Project, getSharedTestWorkspaceHelper, teardownSharedTestWorkspaceAfterEach, prepareDocument, @@ -205,60 +204,6 @@ describe('Language Server: Definitions (ts plugin)', () => { ] `); }); - - /* - - - test('import source', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - message: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { - line: 1, - character: 27, - }); - - expect(definitions).toMatchInlineSnapshot(` - [ - { - "range": { - "end": { - "character": 0, - "line": 0, - }, - "start": { - "character": 0, - "line": 0, - }, - }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", - }, - ] - `); - }); - */ }); async function requestDefinition(fileName: string, languageId: string, contentWithCursor: string) { From 577ec632d3ea48c7ab13f91c8a3c52a7d1626837 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:42:29 -0500 Subject: [PATCH 09/51] rm old --- .../language-server/definitions.test.ts | 221 ------------------ 1 file changed, 221 deletions(-) delete mode 100644 packages/core/__tests__/language-server/definitions.test.ts diff --git a/packages/core/__tests__/language-server/definitions.test.ts b/packages/core/__tests__/language-server/definitions.test.ts deleted file mode 100644 index d61d07e65..000000000 --- a/packages/core/__tests__/language-server/definitions.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Project } from 'glint-monorepo-test-utils'; -import { describe, beforeEach, afterEach, test, expect } from 'vitest'; -import { stripIndent } from 'common-tags'; - -describe('Language Server: Definitions', () => { - let project!: Project; - - beforeEach(async () => { - project = await Project.create(); - }); - - afterEach(async () => { - await project.destroy(); - }); - - test.skip('querying a standalone template', async () => { - project.setGlintConfig({ environment: 'ember-loose' }); - project.write('index.hbs', '{{foo}}'); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.hbs'), { - line: 0, - character: 17, - }); - - expect(definitions).toMatchObject([ - { - uri: project.fileURI('index.hbs'), - range: { - start: { line: 0, character: 9 }, - end: { line: 0, character: 12 }, - }, - }, - ]); - }); - - test('component invocation', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - export default class Greeting extends Component<{ Args: { message: string } }> { - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export default class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { - line: 5, - character: 7, - }); - - expect(definitions).toMatchInlineSnapshot(` - [ - { - "range": { - "end": { - "character": 1, - "line": 3, - }, - "start": { - "character": 0, - "line": 1, - }, - }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", - }, - ] - `); - }); - - test('arg passing', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - message: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export default class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { - line: 5, - character: 17, - }); - - expect(definitions).toMatchInlineSnapshot(` - [ - { - "range": { - "end": { - "character": 18, - "line": 3, - }, - "start": { - "character": 2, - "line": 3, - }, - }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", - }, - ] - `); - }); - - test('arg use', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - message: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('greeting.gts'), { - line: 7, - character: 18, - }); - - expect(definitions).toMatchInlineSnapshot(` - [ - { - "range": { - "end": { - "character": 18, - "line": 3, - }, - "start": { - "character": 2, - "line": 3, - }, - }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", - }, - ] - `); - }); - - test('import source', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - message: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let definitions = await server.sendDefinitionRequest(project.fileURI('index.gts'), { - line: 1, - character: 27, - }); - - expect(definitions).toMatchInlineSnapshot(` - [ - { - "range": { - "end": { - "character": 0, - "line": 0, - }, - "start": { - "character": 0, - "line": 0, - }, - }, - "uri": "file:///path/to/EPHEMERAL_TEST_PROJECT/greeting.gts", - }, - ] - `); - }); -}); From cb54094644a1fc134ff0482b63ef272700057d31 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 05:56:36 -0500 Subject: [PATCH 10/51] extract helpers --- .../definitions-ts-plugin.test.ts | 20 ++----------------- .../test-utils/src/shared-test-workspace.ts | 17 ++++++++++++++++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts index 7913fd407..ec7e2ff55 100644 --- a/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/definitions-ts-plugin.test.ts @@ -3,6 +3,8 @@ import { teardownSharedTestWorkspaceAfterEach, prepareDocument, testWorkspacePath, + extractCursor, + extractCursors, } from 'glint-monorepo-test-utils'; import { describe, beforeEach, afterEach, test, expect } from 'vitest'; import { stripIndent } from 'common-tags'; @@ -234,21 +236,3 @@ async function performDefinitionRequest(document: TextDocument, offset: number) } return res.body; } - -function extractCursor(contentWithCursors: string): [number, string] { - const [offsets, content] = extractCursors(contentWithCursors); - expect(offsets.length).toEqual(1); - const offset = offsets[0]; - return [offset, content]; -} - -function extractCursors(content: string): [number[], string] { - const offsets = []; - while (true) { - const offset = content.indexOf('%'); - if (offset === -1) break; - offsets.push(offset); - content = content.slice(0, offset) + content.slice(offset + 1); - } - return [offsets, content]; -} diff --git a/test-packages/test-utils/src/shared-test-workspace.ts b/test-packages/test-utils/src/shared-test-workspace.ts index 559d49a20..c386ff498 100644 --- a/test-packages/test-utils/src/shared-test-workspace.ts +++ b/test-packages/test-utils/src/shared-test-workspace.ts @@ -145,3 +145,20 @@ export async function prepareDocument(fileName: string, languageId: string, cont } return document; } + +export function extractCursor(contentWithCursors: string): [number, string] { + const [offsets, content] = extractCursors(contentWithCursors); + const offset = offsets[0]; + return [offset, content]; +} + +export function extractCursors(content: string): [number[], string] { + const offsets = []; + while (true) { + const offset = content.indexOf('%'); + if (offset === -1) break; + offsets.push(offset); + content = content.slice(0, offset) + content.slice(offset + 1); + } + return [offsets, content]; +} From a0420a4fa2a5edba4c440c511701e6ad82480deb Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 09:51:26 -0500 Subject: [PATCH 11/51] start references --- .../references-ts-plugin.test.ts | 230 ++++++++++++++++++ .../language-server/references.test.ts | 34 --- .../core/src/volar/ember-language-plugin.ts | 21 +- .../app/components/Other.gts | 13 + .../test-utils/src/shared-test-workspace.ts | 10 +- 5 files changed, 261 insertions(+), 47 deletions(-) create mode 100644 packages/core/__tests__/language-server/references-ts-plugin.test.ts create mode 100644 packages/vscode/__fixtures__/ember-app-loose-and-gts/app/components/Other.gts diff --git a/packages/core/__tests__/language-server/references-ts-plugin.test.ts b/packages/core/__tests__/language-server/references-ts-plugin.test.ts new file mode 100644 index 000000000..ce9bd0e5c --- /dev/null +++ b/packages/core/__tests__/language-server/references-ts-plugin.test.ts @@ -0,0 +1,230 @@ +import { + getSharedTestWorkspaceHelper, + teardownSharedTestWorkspaceAfterEach, + prepareDocument, + testWorkspacePath, + extractCursor, + extractCursors, +} from 'glint-monorepo-test-utils'; +import { describe, afterEach, test, expect } from 'vitest'; +import { stripIndent } from 'common-tags'; +import { URI } from 'vscode-uri'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +describe('Language Server: References (ts plugin)', () => { + afterEach(teardownSharedTestWorkspaceAfterEach); + + test.only('component references', async () => { + const [offset, content] = extractCursor(stripIndent` + import Component from '@glimmer/component'; + + export default class Gre%eting extends Component { + private nested = Math.random() > 0.5; + + + } + `); + + const greetingDoc = await prepareDocument( + 'ts-template-imports-app/src/ephemeral-greeting.gts', + 'glimmer-ts', + content, + ) + + await prepareDocument( + 'ts-template-imports-app/src/ephemeral-app.gts', + 'glimmer-ts', + stripIndent` + import Component from '@glimmer/component'; + import Greeting from './ephemeral-greeting.gts'; + + export default class Application extends Component { + + } + `, + ); + + expect( + await performReferencesRequest( + greetingDoc, + offset, + ), + ).toMatchInlineSnapshot(` + [ + { + "contextEnd": { + "line": 13, + "offset": 2, + }, + "contextStart": { + "line": 3, + "offset": 1, + }, + "end": { + "line": 3, + "offset": 30, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-greeting.gts", + "isDefinition": true, + "isWriteAccess": true, + "lineText": "export default class Greeting extends Component {", + "start": { + "line": 3, + "offset": 22, + }, + }, + { + "end": { + "line": 8, + "offset": 16, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-greeting.gts", + "isDefinition": false, + "isWriteAccess": false, + "lineText": " !", + "start": { + "line": 8, + "offset": 8, + }, + }, + ] + `); + }); + + test('arg references', async () => { + await prepareDocument( + 'ts-template-imports-app/src/ephemeral-greeting.gts', + 'glimmer-ts', + stripIndent` + import Component from '@glimmer/component'; + + export type GreetingArgs = { + /** Who to greet */ + target: string; + }; + + export default class Greeting extends Component<{ Args: GreetingArgs }> { + + } + `, + ); + + expect( + await requestReferences( + 'ts-template-imports-app/src/ephemeral-app.gts', + 'glimmer-ts', + stripIndent` + import Component from '@glimmer/component'; + import Greeting from './ephemeral-greeting.gts'; + + export default class Application extends Component { + + } + `, + ), + ).toMatchInlineSnapshot(` + [ + { + "contextEnd": { + "line": 5, + "offset": 18, + }, + "contextStart": { + "line": 5, + "offset": 3, + }, + "end": { + "line": 5, + "offset": 9, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-greeting.gts", + "isDefinition": false, + "isWriteAccess": false, + "lineText": " target: string;", + "start": { + "line": 5, + "offset": 3, + }, + }, + { + "end": { + "line": 10, + "offset": 21, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-greeting.gts", + "isDefinition": false, + "isWriteAccess": false, + "lineText": " Hello, {{@target}}", + "start": { + "line": 10, + "offset": 15, + }, + }, + { + "contextEnd": { + "line": 6, + "offset": 30, + }, + "contextStart": { + "line": 6, + "offset": 16, + }, + "end": { + "line": 6, + "offset": 22, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-app.gts", + "isDefinition": true, + "isWriteAccess": true, + "lineText": " ", + "start": { + "line": 6, + "offset": 16, + }, + }, + ] + `); + }); +}); + +async function requestReferences(fileName: string, languageId: string, contentWithCursor: string) { + const [offset, content] = extractCursor(contentWithCursor); + + let document = await prepareDocument(fileName, languageId, content); + + const res = await performReferencesRequest(document, offset); + + return res; +} + +async function performReferencesRequest(document: TextDocument, offset: number) { + const workspaceHelper = await getSharedTestWorkspaceHelper(); + + const res = await workspaceHelper.tsserver.message({ + seq: workspaceHelper.nextSeq(), + command: 'references', + arguments: { + file: URI.parse(document.uri).fsPath, + position: offset, + includeDeclaration: false, + }, + }); + expect(res.success).toBe(true); + + for (const ref of res.body.refs) { + ref.file = '${testWorkspacePath}' + ref.file.slice(testWorkspacePath.length); + } + return res.body.refs; +} diff --git a/packages/core/__tests__/language-server/references.test.ts b/packages/core/__tests__/language-server/references.test.ts index e826ff9fe..0b09b7ad2 100644 --- a/packages/core/__tests__/language-server/references.test.ts +++ b/packages/core/__tests__/language-server/references.test.ts @@ -13,40 +13,6 @@ describe('Language Server: References', () => { await project.destroy(); }); - test.skip('querying a standalone template', async () => { - project.setGlintConfig({ environment: 'ember-loose' }); - project.write('index.hbs', '{{foo}}'); - - let server = await project.startLanguageServer(); - let references = await server.sendReferencesRequest( - project.fileURI('index.hbs'), - { - line: 0, - character: 11, - }, - { - includeDeclaration: true, - }, - ); - - expect(references).toEqual([ - { - uri: project.fileURI('index.hbs'), - range: { - start: { line: 0, character: 9 }, - end: { line: 0, character: 12 }, - }, - }, - { - uri: project.fileURI('index.hbs'), - range: { - start: { line: 0, character: 16 }, - end: { line: 0, character: 19 }, - }, - }, - ]); - }); - test('component references', async () => { project.write({ 'greeting.gts': stripIndent` diff --git a/packages/core/src/volar/ember-language-plugin.ts b/packages/core/src/volar/ember-language-plugin.ts index de5a99b42..667f646b5 100644 --- a/packages/core/src/volar/ember-language-plugin.ts +++ b/packages/core/src/volar/ember-language-plugin.ts @@ -62,14 +62,14 @@ export function createEmberLanguagePlugin( } }, - // - // This hook is only called in TS Plugin mode (not classic "takeover" mode), because Volar's - // support for two-file components only exists for TS Plugin. - // - // Because we declare handlebars files to be associated with "root" .ts files, we - // need to mark them here as "associated file only" so that TS doesn't attempt - // to type-check them directly, but rather indirectly via the .ts file. - // + /** + * This hook is only called in TS Plugin mode (not classic "takeover" mode), because Volar's + * support for two-file components only exists for TS Plugin. + * + * Because we declare handlebars files to be associated with "root" .ts files, we + * need to mark them here as "associated file only" so that TS doesn't attempt + * to type-check them directly, but rather indirectly via the .ts file. + **/ isAssociatedFileOnly(_scriptId: string | URI, languageId: string): boolean { return languageId === 'handlebars'; }, @@ -84,6 +84,11 @@ export function createEmberLanguagePlugin( // Allow extension-less imports, e.g. `import Foo from './Foo`. // Upstream Volar support for our extension-less use case was added here: // https://github.com/volarjs/volar.js/pull/190 + // + // NOTE: as of Mar 7, 2025, TS Plugin mode does not support extension-less imports. + // It's possible this could be fixed upstream but we should maybe not count on it. + // + // Tracking here: https://github.com/typed-ember/glint/issues/806 resolveHiddenExtensions: true, // This is called when TS requests the file that we'll be typechecking, which in our case diff --git a/packages/vscode/__fixtures__/ember-app-loose-and-gts/app/components/Other.gts b/packages/vscode/__fixtures__/ember-app-loose-and-gts/app/components/Other.gts new file mode 100644 index 000000000..adabdd17f --- /dev/null +++ b/packages/vscode/__fixtures__/ember-app-loose-and-gts/app/components/Other.gts @@ -0,0 +1,13 @@ +import Component from '@glimmer/component'; +import Greeting from './Greeting.gts'; +export interface OtherSignature { + Args: { target: string }; +} + +export default class Other extends Component { + private message = 'Hello'; + + +} diff --git a/test-packages/test-utils/src/shared-test-workspace.ts b/test-packages/test-utils/src/shared-test-workspace.ts index c386ff498..cf2fac34f 100644 --- a/test-packages/test-utils/src/shared-test-workspace.ts +++ b/test-packages/test-utils/src/shared-test-workspace.ts @@ -31,17 +31,17 @@ export async function getSharedTestWorkspaceHelper(): Promise<{ '--globalPlugins', '@glint/typescript-plugin', '--suppressDiagnosticEvents', - // '--logVerbosity', - // 'verbose', - // '--logFile', - // path.join(__dirname, '..', '..', '..', 'tsserver.log'), + '--logVerbosity', + 'verbose', + '--logFile', + path.join(__dirname, '..', '..', '..', 'tsserver.log'), ], ); tsserver.on('exit', (code) => console.log(code ? `Exited with code ${code}` : `Terminated`)); // Uncomment to show additional event logging (less verbose than tsserver.log) - // tsserver.on('event', (e) => console.log(e)); + tsserver.on('event', (e) => console.log(e)); serverHandle = startLanguageServer( require.resolve('../../../packages/core/bin/glint-language-server.js'), From 10abaff1690ec9eda4e4f3f4b5d8d363640027fa Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:11:20 -0500 Subject: [PATCH 12/51] fix reference test cases with on-disk fixture hack --- .../references-ts-plugin.test.ts | 42 +++++++++++++++++-- .../src/empty-fixture.gts | 6 +++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 test-packages/ts-template-imports-app/src/empty-fixture.gts diff --git a/packages/core/__tests__/language-server/references-ts-plugin.test.ts b/packages/core/__tests__/language-server/references-ts-plugin.test.ts index ce9bd0e5c..73be47a9c 100644 --- a/packages/core/__tests__/language-server/references-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/references-ts-plugin.test.ts @@ -38,7 +38,7 @@ describe('Language Server: References (ts plugin)', () => { ) await prepareDocument( - 'ts-template-imports-app/src/ephemeral-app.gts', + 'ts-template-imports-app/src/empty-fixture.gts', 'glimmer-ts', stripIndent` import Component from '@glimmer/component'; @@ -95,6 +95,42 @@ describe('Language Server: References (ts plugin)', () => { "offset": 8, }, }, + { + "contextEnd": { + "line": 2, + "offset": 49, + }, + "contextStart": { + "line": 2, + "offset": 1, + }, + "end": { + "line": 2, + "offset": 16, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/empty-fixture.gts", + "isDefinition": false, + "isWriteAccess": true, + "lineText": "import Greeting from './ephemeral-greeting.gts';", + "start": { + "line": 2, + "offset": 8, + }, + }, + { + "end": { + "line": 6, + "offset": 14, + }, + "file": "\${testWorkspacePath}/ts-template-imports-app/src/empty-fixture.gts", + "isDefinition": false, + "isWriteAccess": false, + "lineText": " ", + "start": { + "line": 6, + "offset": 6, + }, + }, ] `); }); @@ -121,7 +157,7 @@ describe('Language Server: References (ts plugin)', () => { expect( await requestReferences( - 'ts-template-imports-app/src/ephemeral-app.gts', + 'ts-template-imports-app/src/empty-fixture.gts', 'glimmer-ts', stripIndent` import Component from '@glimmer/component'; @@ -185,7 +221,7 @@ describe('Language Server: References (ts plugin)', () => { "line": 6, "offset": 22, }, - "file": "\${testWorkspacePath}/ts-template-imports-app/src/ephemeral-app.gts", + "file": "\${testWorkspacePath}/ts-template-imports-app/src/empty-fixture.gts", "isDefinition": true, "isWriteAccess": true, "lineText": " ", diff --git a/test-packages/ts-template-imports-app/src/empty-fixture.gts b/test-packages/ts-template-imports-app/src/empty-fixture.gts new file mode 100644 index 000000000..b36cfee1f --- /dev/null +++ b/test-packages/ts-template-imports-app/src/empty-fixture.gts @@ -0,0 +1,6 @@ +// This file is intentionally empty. +// It exists as a hack to get around some issues when testing our TS Plugin +// within tsserver harness where, even though many of our tests are only +// updating tsserver's in-memory content for a file, tsserver still needs +// the file to exist in the file system in order for things like References +// and other language features to work properly. From c19d02433283a158fed53ae1dc9fb1eb623ffc31 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:15:50 -0500 Subject: [PATCH 13/51] rm references --- .../references-ts-plugin.test.ts | 2 +- .../language-server/references.test.ts | 196 ------------------ 2 files changed, 1 insertion(+), 197 deletions(-) delete mode 100644 packages/core/__tests__/language-server/references.test.ts diff --git a/packages/core/__tests__/language-server/references-ts-plugin.test.ts b/packages/core/__tests__/language-server/references-ts-plugin.test.ts index 73be47a9c..4a39f99f9 100644 --- a/packages/core/__tests__/language-server/references-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/references-ts-plugin.test.ts @@ -14,7 +14,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; describe('Language Server: References (ts plugin)', () => { afterEach(teardownSharedTestWorkspaceAfterEach); - test.only('component references', async () => { + test('component references', async () => { const [offset, content] = extractCursor(stripIndent` import Component from '@glimmer/component'; diff --git a/packages/core/__tests__/language-server/references.test.ts b/packages/core/__tests__/language-server/references.test.ts deleted file mode 100644 index 0b09b7ad2..000000000 --- a/packages/core/__tests__/language-server/references.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Project } from 'glint-monorepo-test-utils'; -import { describe, beforeEach, afterEach, test, expect } from 'vitest'; -import { stripIndent } from 'common-tags'; - -describe('Language Server: References', () => { - let project!: Project; - - beforeEach(async () => { - project = await Project.create(); - }); - - afterEach(async () => { - await project.destroy(); - }); - - test('component references', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export default class Greeting extends Component { - private nested = Math.random() > 0.5; - - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export default class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let expectedReferences = new Set([ - { - uri: project.fileURI('greeting.gts'), - range: { - start: { line: 2, character: 21 }, - end: { line: 2, character: 29 }, - }, - }, - { - uri: project.fileURI('greeting.gts'), - range: { - start: { line: 7, character: 7 }, - end: { line: 7, character: 15 }, - }, - }, - { - uri: project.fileURI('index.gts'), - range: { - start: { line: 5, character: 5 }, - end: { line: 5, character: 13 }, - }, - }, - { - uri: project.fileURI('index.gts'), - range: { - start: { line: 1, character: 7 }, - end: { line: 1, character: 15 }, - }, - }, - ]); - - let referencesFromClassDeclaration = await server.sendReferencesRequest( - project.fileURI('greeting.gts'), - { - line: 2, - character: 24, - }, - { - includeDeclaration: true, - }, - ); - - expect(new Set(referencesFromClassDeclaration)).toEqual(expectedReferences); - - let referencesFromComponentInvocation = await server.sendReferencesRequest( - project.fileURI('index.gts'), - { - line: 5, - character: 7, - }, - { - includeDeclaration: true, - }, - ); - - expect(new Set(referencesFromComponentInvocation)).toEqual(expectedReferences); - }); - - test('arg references', async () => { - project.write({ - 'greeting.gts': stripIndent` - import Component from '@glimmer/component'; - - export type GreetingArgs = { - /** Who to greet */ - target: string; - }; - - export default class Greeting extends Component<{ Args: GreetingArgs }> { - - } - `, - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - import Greeting from './greeting'; - - export default class Application extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let expectedReferences = new Set([ - { - uri: project.fileURI('index.gts'), - range: { - start: { line: 5, character: 15 }, - end: { line: 5, character: 21 }, - }, - }, - { - uri: project.fileURI('greeting.gts'), - range: { - start: { line: 9, character: 14 }, - end: { line: 9, character: 20 }, - }, - }, - { - uri: project.fileURI('greeting.gts'), - range: { - start: { line: 4, character: 2 }, - end: { line: 4, character: 8 }, - }, - }, - ]); - - let referencesFromDefinition = await server.sendReferencesRequest( - project.fileURI('greeting.gts'), - { - line: 4, - character: 4, - }, - { - includeDeclaration: true, - }, - ); - - expect(new Set(referencesFromDefinition)).toEqual(expectedReferences); - - let referencesFromInvocation = await server.sendReferencesRequest( - project.fileURI('index.gts'), - { - line: 5, - character: 17, - }, - { - includeDeclaration: true, - }, - ); - - expect(new Set(referencesFromInvocation)).toEqual(expectedReferences); - - let referencesFromUsage = await server.sendReferencesRequest( - project.fileURI('greeting.gts'), - { - line: 9, - character: 16, - }, - { - includeDeclaration: true, - }, - ); - - expect(new Set(referencesFromUsage)).toEqual(expectedReferences); - }); -}); From f939b24bf665e29f96cd62ed9878706c3e429c99 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:21:48 -0500 Subject: [PATCH 14/51] wip hover --- .../language-server/hover-ts-plugin.test.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 packages/core/__tests__/language-server/hover-ts-plugin.test.ts diff --git a/packages/core/__tests__/language-server/hover-ts-plugin.test.ts b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts new file mode 100644 index 000000000..9012353b7 --- /dev/null +++ b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts @@ -0,0 +1,210 @@ +import { + getSharedTestWorkspaceHelper, + teardownSharedTestWorkspaceAfterEach, + prepareDocument, + testWorkspacePath, + extractCursor, + extractCursors, +} from 'glint-monorepo-test-utils'; +import { describe, afterEach, test, expect } from 'vitest'; +import { stripIndent } from 'common-tags'; +import { URI } from 'vscode-uri'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +describe('Language Server: Hover (ts plugin)', () => { + afterEach(teardownSharedTestWorkspaceAfterEach); + + test.skip('querying a standalone template', async () => { + const [offset, content] = extractCursor(stripIndent` + {{f%oo}} + `); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/index.hbs', + 'handlebars', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + + test('using private properties', async () => { + const [offset, content] = extractCursor(stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + /** A message. */ + private message = 'hi'; + + + } + `); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/ephemeral.gts', + 'glimmer-ts', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(` + { + "displayString": "(property) MyComponent.message: string", + "documentation": "A message.", + "end": { + "line": 8, + "offset": 19, + }, + "kind": "property", + "kindModifiers": "private", + "start": { + "line": 8, + "offset": 12, + }, + "tags": [], + } + `); + }); + + test('using args', async () => { + const [offset, content] = extractCursor(stripIndent` + import Component from '@glimmer/component'; + + interface MyComponentArgs { + /** Some string */ + str: string; + } + + export default class MyComponent extends Component<{ Args: MyComponentArgs }> { + + } + `); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/ephemeral.gts', + 'glimmer-ts', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + + test('curly block params', async () => { + const [offset, content] = extractCursor(stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + `); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/ephemeral.gts', + 'glimmer-ts', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + + test('module details', async () => { + const [offset, content] = extractCursor(stripIndent` + import { foo } from './f%oo'; + + console.log(foo); + `); + + await prepareDocument( + 'ts-template-imports-app/src/foo.ts', + 'typescript', + stripIndent` + export const foo = 'hi'; + ` + ); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/index.ts', + 'typescript', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + + describe.skip('JS in a TS project', () => { + test('with allowJs: true', async () => { + const [offset, content] = extractCursor(stripIndent` + {{this.mes%sage}} + `); + + await prepareDocument( + 'ts-template-imports-app/src/index.js', + 'javascript', + stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + message = 'hi'; + } + ` + ); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/index.hbs', + 'handlebars', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + + test('allowJs: false', async () => { + const [offset, content] = extractCursor(stripIndent` + {{this.mes%sage}} + `); + + await prepareDocument( + 'ts-template-imports-app/src/index.js', + 'javascript', + stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + message = 'hi'; + } + ` + ); + + const doc = await prepareDocument( + 'ts-template-imports-app/src/index.hbs', + 'handlebars', + content + ); + + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + }); + }); +}); + +async function performHoverRequest(document: TextDocument, offset: number) { + const workspaceHelper = await getSharedTestWorkspaceHelper(); + + const res = await workspaceHelper.tsserver.message({ + seq: workspaceHelper.nextSeq(), + command: 'quickinfo', + arguments: { + file: URI.parse(document.uri).fsPath, + position: offset, + }, + }); + expect(res.success).toBe(true); + + return res.body; +} From dd6a49314b39f6a8f7dfde2e83bd5bfbef4b8bfb Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:22:58 -0500 Subject: [PATCH 15/51] args --- .../language-server/hover-ts-plugin.test.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/core/__tests__/language-server/hover-ts-plugin.test.ts b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts index 9012353b7..8dfbeb67f 100644 --- a/packages/core/__tests__/language-server/hover-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts @@ -14,20 +14,6 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; describe('Language Server: Hover (ts plugin)', () => { afterEach(teardownSharedTestWorkspaceAfterEach); - test.skip('querying a standalone template', async () => { - const [offset, content] = extractCursor(stripIndent` - {{f%oo}} - `); - - const doc = await prepareDocument( - 'ts-template-imports-app/src/index.hbs', - 'handlebars', - content - ); - - expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); - }); - test('using private properties', async () => { const [offset, content] = extractCursor(stripIndent` import Component from '@glimmer/component'; @@ -78,7 +64,7 @@ describe('Language Server: Hover (ts plugin)', () => { export default class MyComponent extends Component<{ Args: MyComponentArgs }> { } `); @@ -89,7 +75,23 @@ describe('Language Server: Hover (ts plugin)', () => { content ); - expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(` + { + "displayString": "(property) MyComponentArgs.str: string", + "documentation": "Some string", + "end": { + "line": 10, + "offset": 11, + }, + "kind": "property", + "kindModifiers": "", + "start": { + "line": 10, + "offset": 8, + }, + "tags": [], + } + `); }); test('curly block params', async () => { From 42cf87e163e5057177fffc1e88672e450be9d100 Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:26:51 -0500 Subject: [PATCH 16/51] finish hover --- .../language-server/hover-ts-plugin.test.ts | 56 ++- .../__tests__/language-server/hover.test.ts | 318 ------------------ 2 files changed, 23 insertions(+), 351 deletions(-) delete mode 100644 packages/core/__tests__/language-server/hover.test.ts diff --git a/packages/core/__tests__/language-server/hover-ts-plugin.test.ts b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts index 8dfbeb67f..aadebcf21 100644 --- a/packages/core/__tests__/language-server/hover-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/hover-ts-plugin.test.ts @@ -2,9 +2,7 @@ import { getSharedTestWorkspaceHelper, teardownSharedTestWorkspaceAfterEach, prepareDocument, - testWorkspacePath, extractCursor, - extractCursors, } from 'glint-monorepo-test-utils'; import { describe, afterEach, test, expect } from 'vitest'; import { stripIndent } from 'common-tags'; @@ -31,7 +29,7 @@ describe('Language Server: Hover (ts plugin)', () => { const doc = await prepareDocument( 'ts-template-imports-app/src/ephemeral.gts', 'glimmer-ts', - content + content, ); expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(` @@ -72,7 +70,7 @@ describe('Language Server: Hover (ts plugin)', () => { const doc = await prepareDocument( 'ts-template-imports-app/src/ephemeral.gts', 'glimmer-ts', - content + content, ); expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(` @@ -110,34 +108,26 @@ describe('Language Server: Hover (ts plugin)', () => { const doc = await prepareDocument( 'ts-template-imports-app/src/ephemeral.gts', 'glimmer-ts', - content + content, ); - expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); - }); - - test('module details', async () => { - const [offset, content] = extractCursor(stripIndent` - import { foo } from './f%oo'; - - console.log(foo); + expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(` + { + "displayString": "const index: number", + "documentation": "", + "end": { + "line": 6, + "offset": 20, + }, + "kind": "const", + "kindModifiers": "", + "start": { + "line": 6, + "offset": 15, + }, + "tags": [], + } `); - - await prepareDocument( - 'ts-template-imports-app/src/foo.ts', - 'typescript', - stripIndent` - export const foo = 'hi'; - ` - ); - - const doc = await prepareDocument( - 'ts-template-imports-app/src/index.ts', - 'typescript', - content - ); - - expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); }); describe.skip('JS in a TS project', () => { @@ -155,13 +145,13 @@ describe('Language Server: Hover (ts plugin)', () => { export default class MyComponent extends Component { message = 'hi'; } - ` + `, ); const doc = await prepareDocument( 'ts-template-imports-app/src/index.hbs', 'handlebars', - content + content, ); expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); @@ -181,13 +171,13 @@ describe('Language Server: Hover (ts plugin)', () => { export default class MyComponent extends Component { message = 'hi'; } - ` + `, ); const doc = await prepareDocument( 'ts-template-imports-app/src/index.hbs', 'handlebars', - content + content, ); expect(await performHoverRequest(doc, offset)).toMatchInlineSnapshot(); diff --git a/packages/core/__tests__/language-server/hover.test.ts b/packages/core/__tests__/language-server/hover.test.ts deleted file mode 100644 index 9dca2bc92..000000000 --- a/packages/core/__tests__/language-server/hover.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Project } from 'glint-monorepo-test-utils'; -import { describe, beforeEach, afterEach, test, expect } from 'vitest'; -import { stripIndent } from 'common-tags'; - -describe('Language Server: Hover', () => { - let project!: Project; - - beforeEach(async () => { - project = await Project.create(); - }); - - afterEach(async () => { - await project.destroy(); - }); - - test.skip('querying a standalone template', async () => { - project.setGlintConfig({ environment: 'ember-loose' }); - project.write('index.hbs', '{{foo}}'); - - let server = await project.startLanguageServer(); - let info = await server.sendHoverRequest(project.fileURI('index.hbs'), { - line: 0, - character: 17, - }); - - expect(info).toEqual({ - contents: [{ language: 'ts', value: 'const foo: any' }], - range: { - start: { line: 0, character: 16 }, - end: { line: 0, character: 19 }, - }, - }); - }); - - test('using private properties', async () => { - project.write({ - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - - export default class MyComponent extends Component { - /** A message. */ - private message = 'hi'; - - - } - `, - }); - - let server = await project.startLanguageServer(); - let messageInfo = await server.sendHoverRequest(project.fileURI('index.gts'), { - line: 7, - character: 12, - }); - - expect(messageInfo).toMatchInlineSnapshot(` - { - "contents": { - "kind": "markdown", - "value": "\`\`\`typescript - (property) MyComponent.message: string - \`\`\` - - A message.", - }, - "range": { - "end": { - "character": 18, - "line": 7, - }, - "start": { - "character": 11, - "line": 7, - }, - }, - } - `); - }); - - test('using args', async () => { - project.write({ - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - - interface MyComponentArgs { - /** Some string */ - str: string; - } - - export default class MyComponent extends Component<{ Args: MyComponentArgs }> { - - } - `, - }); - - let server = await project.startLanguageServer(); - let strInfo = await server.sendHoverRequest(project.fileURI('index.gts'), { - line: 9, - character: 7, - }); - - // {{@str}} in the template matches back to the arg definition - expect(strInfo).toMatchInlineSnapshot(` - { - "contents": { - "kind": "markdown", - "value": "\`\`\`typescript - (property) MyComponentArgs.str: string - \`\`\` - - Some string", - }, - "range": { - "end": { - "character": 10, - "line": 9, - }, - "start": { - "character": 7, - "line": 9, - }, - }, - } - `); - }); - - test('curly block params', async () => { - project.write({ - 'index.gts': stripIndent` - import Component from '@glimmer/component'; - - export default class MyComponent extends Component { - - } - `, - }); - - let server = await project.startLanguageServer(); - let indexInfo = await server.sendHoverRequest(project.fileURI('index.gts'), { - line: 5, - character: 14, - }); - - // {{index}} in the template matches back to the block param - expect(indexInfo).toMatchInlineSnapshot(` - { - "contents": { - "kind": "markdown", - "value": "\`\`\`typescript - const index: number - \`\`\` - - --- - - \`\`\`typescript - const index: number - \`\`\`", - }, - "range": { - "end": { - "character": 19, - "line": 5, - }, - "start": { - "character": 14, - "line": 5, - }, - }, - } - `); - - let itemInfo = await server.sendHoverRequest(project.fileURI('index.gts'), { - line: 5, - character: 25, - }); - - // {{item}} in the template matches back to the block param - expect(itemInfo).toMatchInlineSnapshot(` - { - "contents": { - "kind": "markdown", - "value": "\`\`\`typescript - const item: string - \`\`\` - - --- - - \`\`\`typescript - const item: string - \`\`\`", - }, - "range": { - "end": { - "character": 29, - "line": 5, - }, - "start": { - "character": 25, - "line": 5, - }, - }, - } - `); - }); - - test('module details', async () => { - project.write({ - 'foo.ts': stripIndent` - export const foo = 'hi'; - `, - 'index.ts': stripIndent` - import { foo } from './foo'; - - console.log(foo); - `, - }); - - let server = await project.startLanguageServer(); - let info = await server.sendHoverRequest(project.fileURI('index.ts'), { - line: 0, - character: 24, - }); - - expect(info).toMatchInlineSnapshot(` - { - "contents": { - "kind": "markdown", - "value": "\`\`\`typescript - module "/path/to/EPHEMERAL_TEST_PROJECT/foo" - \`\`\`", - }, - "range": { - "end": { - "character": 27, - "line": 0, - }, - "start": { - "character": 20, - "line": 0, - }, - }, - } - `); - }); - - describe.skip('JS in a TS project', () => { - test('with allowJs: true', async () => { - let tsconfig = JSON.parse(project.read('tsconfig.json')); - tsconfig.glint = { environment: 'ember-loose' }; - tsconfig.compilerOptions.allowJs = true; - project.write('tsconfig.json', JSON.stringify(tsconfig)); - - project.write({ - 'index.hbs': '{{this.message}}', - 'index.js': stripIndent` - import Component from '@glimmer/component'; - - export default class MyComponent extends Component { - message = 'hi'; - } - `, - }); - - let server = await project.startLanguageServer(); - let info = await server.sendHoverRequest(project.fileURI('index.hbs'), { - line: 0, - character: 10, - }); - - expect(server.getDiagnostics(project.fileURI('index.hbs'))).toEqual([]); - expect(server.getDiagnostics(project.fileURI('index.js'))).toEqual([]); - - expect(info).toEqual({ - contents: [{ language: 'ts', value: '(property) MyComponent.message: string' }], - range: { - start: { line: 0, character: 7 }, - end: { line: 0, character: 14 }, - }, - }); - }); - - test('allowJs: false', async () => { - let tsconfig = JSON.parse(project.read('tsconfig.json')); - tsconfig.glint = { environment: 'ember-loose' }; - tsconfig.compilerOptions.allowJs = false; - project.write('tsconfig.json', JSON.stringify(tsconfig)); - - project.write({ - 'index.hbs': '{{this.message}}', - 'index.js': stripIndent` - import Component from '@glimmer/component'; - - export default class MyComponent extends Component { - message = 'hi'; - } - `, - }); - - let server = await project.startLanguageServer(); - let info = await server.sendHoverRequest(project.fileURI('index.hbs'), { - line: 0, - character: 10, - }); - - expect(server.getDiagnostics(project.fileURI('index.hbs'))).toEqual([]); - expect(server.getDiagnostics(project.fileURI('index.js'))).toEqual([]); - - expect(info).toEqual(undefined); - }); - }); -}); From 4576f5cc1d971997cb7ac4376799a78a6a85018e Mon Sep 17 00:00:00 2001 From: machty Date: Fri, 7 Mar 2025 10:45:35 -0500 Subject: [PATCH 17/51] completions progress --- .../completions-ts-plugin.test.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 packages/core/__tests__/language-server/completions-ts-plugin.test.ts diff --git a/packages/core/__tests__/language-server/completions-ts-plugin.test.ts b/packages/core/__tests__/language-server/completions-ts-plugin.test.ts new file mode 100644 index 000000000..d1f2e111e --- /dev/null +++ b/packages/core/__tests__/language-server/completions-ts-plugin.test.ts @@ -0,0 +1,343 @@ +import { + getSharedTestWorkspaceHelper, + teardownSharedTestWorkspaceAfterEach, + prepareDocument, + testWorkspacePath, + extractCursor, + extractCursors, +} from 'glint-monorepo-test-utils'; +import { describe, afterEach, test, expect } from 'vitest'; +import { stripIndent } from 'common-tags'; +import { URI } from 'vscode-uri'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { CompletionItemKind, Position } from '@volar/language-server'; + +describe('Language Server: Completions (ts plugin)', () => { + afterEach(teardownSharedTestWorkspaceAfterEach); + + test.skip('querying a standalone template', async () => { + await prepareDocument( + 'ts-ember-app/app/components/index.hbs', + 'handlebars', + '' + ); + + expect( + await requestCompletion( + 'ts-ember-app/app/components/index.hbs', + 'handlebars', + '' + ) + ).toMatchInlineSnapshot(); + }); + + test.skip('in unstructured text', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(); + }); + + test.skip('in a companion template with syntax errors', async () => { + const code = stripIndent` + Hello, {{this.target.%}}! + `; + + expect( + await requestCompletion( + 'ts-ember-app/app/components/index.hbs', + 'handlebars', + code + ) + ).toMatchInlineSnapshot(); + }); + + // Fails with "No content available", but maybe that's a perfectly fine response in this case? + test.skip('in an embedded template with syntax errors', async () => { + const code = stripIndent` + + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/ephemeral-index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(); + }); + + test('passing component args', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + + class Inner extends Component<{ Args: { foo?: string; 'bar-baz'?: number | undefined } }> {} + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(` + [ + { + "kind": "property", + "kindModifiers": "optional", + "name": "bar-baz", + "replacementSpan": { + "end": { + "line": 5, + "offset": 13, + }, + "start": { + "line": 5, + "offset": 13, + }, + }, + "sortText": "11", + }, + { + "kind": "property", + "kindModifiers": "optional", + "name": "foo", + "replacementSpan": { + "end": { + "line": 5, + "offset": 13, + }, + "start": { + "line": 5, + "offset": 13, + }, + }, + "sortText": "11", + }, + ] + `); + }); + + test('referencing class properties', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + private message = 'hello'; + + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(); + }); + + test('auto imports', async () => { + await prepareDocument( + 'ts-template-imports-app/src/other.ts', + 'typescript', + stripIndent` + export let foobar = 123; + ` + ); + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.ts', + 'typescript', + stripIndent` + import { thing } from 'nonexistent'; + + let a = foo + ` + ) + ).toMatchInlineSnapshot(); + }); + + test('auto imports with documentation and tags', async () => { + await prepareDocument( + 'ts-template-imports-app/src/other.ts', + 'typescript', + stripIndent` + /** + * This is a doc comment + * @param foo + */ + export let foobar = 123; + ` + ); + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.ts', + 'typescript', + stripIndent` + import { thing } from 'nonexistent'; + + let a = foo + ` + ) + ).toMatchInlineSnapshot(); + }); + + test('auto import - import statements - ensure all completions are resolvable', async () => { + await prepareDocument( + 'ts-template-imports-app/src/other.ts', + 'typescript', + stripIndent` + export let foobar = 123; + ` + ); + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.ts', + 'typescript', + stripIndent` + import foo + ` + ) + ).toMatchInlineSnapshot(); + }); + + test('referencing own args', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + type MyComponentArgs = { + items: Set; + }; + + export default class MyComponent extends Component<{ Args: MyComponentArgs }> { + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(); + }); + + test('referencing block params', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'glimmer-ts', + code + ) + ).toMatchInlineSnapshot(); + }); + + test('referencing module-scope identifiers', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + const greeting: string = 'hello'; + + export default class MyComponent extends Component { + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.ts', + 'typescript', + code + ) + ).toMatchInlineSnapshot(); + }); + + test.skip('immediately after a change', async () => { + const code = stripIndent` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + `; + + expect( + await requestCompletion( + 'ts-template-imports-app/src/index.gts', + 'typescript', + code + ) + ).toMatchInlineSnapshot(); + }); +}); + +async function requestCompletion(fileName: string, languageId: string, contentWithCursor: string) { + const [offset, content] = extractCursor(contentWithCursor); + const document = await prepareDocument(fileName, languageId, content); + const res = await performCompletionRequest(document, offset); + return res; +} + +async function performCompletionRequest(document: TextDocument, offset: number) { + const workspaceHelper = await getSharedTestWorkspaceHelper(); + const res = await workspaceHelper.tsserver.message({ + seq: workspaceHelper.nextSeq(), + command: 'completions', + arguments: { + file: URI.parse(document.uri).fsPath, + position: offset, + }, + }); + + expect(res.success).toBe(true); + return res.body; +} From 4fd9d317839f9d9c7a4e27c2516fdf91978ef82a Mon Sep 17 00:00:00 2001 From: machty Date: Sun, 9 Mar 2025 06:16:59 -0400 Subject: [PATCH 18/51] wip rmoving old LS code --- .../completions-ts-plugin.test.ts | 144 ++++---- packages/core/src/volar/language-server.ts | 337 +++++++++++++----- .../typescript-language-service-plugin.ts | 2 +- packages/vscode/src/extension.ts | 13 + .../src/auto-import-test-exporter.gts | 1 + .../src/auto-import-test-importer.gts | 1 + .../ts-plugin-test-app/src/foo-glimmer.gts | 9 +- .../ts-plugin-test-app/src/glimmer.gts | 3 + test-packages/ts-plugin-test-app/src/test.ts | 2 + test-packages/ts-plugin-test-app/src/test2.ts | 3 + .../src/GreetingAutoImportTest.gts | 3 + .../src/empty-fixture2.gts | 1 + 12 files changed, 362 insertions(+), 157 deletions(-) create mode 100644 test-packages/ts-plugin-test-app/src/auto-import-test-exporter.gts create mode 100644 test-packages/ts-plugin-test-app/src/auto-import-test-importer.gts create mode 100644 test-packages/ts-plugin-test-app/src/test2.ts create mode 100644 test-packages/ts-template-imports-app/src/GreetingAutoImportTest.gts create mode 100644 test-packages/ts-template-imports-app/src/empty-fixture2.gts diff --git a/packages/core/__tests__/language-server/completions-ts-plugin.test.ts b/packages/core/__tests__/language-server/completions-ts-plugin.test.ts index d1f2e111e..fa1af32fe 100644 --- a/packages/core/__tests__/language-server/completions-ts-plugin.test.ts +++ b/packages/core/__tests__/language-server/completions-ts-plugin.test.ts @@ -16,18 +16,10 @@ describe('Language Server: Completions (ts plugin)', () => { afterEach(teardownSharedTestWorkspaceAfterEach); test.skip('querying a standalone template', async () => { - await prepareDocument( - 'ts-ember-app/app/components/index.hbs', - 'handlebars', - '' - ); + await prepareDocument('ts-ember-app/app/components/index.hbs', 'handlebars', ''); expect( - await requestCompletion( - 'ts-ember-app/app/components/index.hbs', - 'handlebars', - '' - ) + await requestCompletion('ts-ember-app/app/components/index.hbs', 'handlebars', ''), ).toMatchInlineSnapshot(); }); @@ -45,11 +37,7 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-template-imports-app/src/index.gts', - 'glimmer-ts', - code - ) + await requestCompletion('ts-template-imports-app/src/index.gts', 'glimmer-ts', code), ).toMatchInlineSnapshot(); }); @@ -59,11 +47,7 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-ember-app/app/components/index.hbs', - 'handlebars', - code - ) + await requestCompletion('ts-ember-app/app/components/index.hbs', 'handlebars', code), ).toMatchInlineSnapshot(); }); @@ -77,8 +61,8 @@ describe('Language Server: Completions (ts plugin)', () => { await requestCompletion( 'ts-template-imports-app/src/ephemeral-index.gts', 'glimmer-ts', - code - ) + code, + ), ).toMatchInlineSnapshot(); }); @@ -95,13 +79,8 @@ describe('Language Server: Completions (ts plugin)', () => { class Inner extends Component<{ Args: { foo?: string; 'bar-baz'?: number | undefined } }> {} `; - expect( - await requestCompletion( - 'ts-template-imports-app/src/index.gts', - 'glimmer-ts', - code - ) - ).toMatchInlineSnapshot(` + expect(await requestCompletion('ts-template-imports-app/src/index.gts', 'glimmer-ts', code)) + .toMatchInlineSnapshot(` [ { "kind": "property", @@ -147,40 +126,59 @@ describe('Language Server: Completions (ts plugin)', () => { private message = 'hello'; } `; expect( - await requestCompletion( + await requestCompletionItem( 'ts-template-imports-app/src/index.gts', 'glimmer-ts', - code - ) - ).toMatchInlineSnapshot(); + code, + 'message', + ), + ).toMatchInlineSnapshot(` + { + "kind": "property", + "kindModifiers": "private", + "name": "message", + "sortText": "11", + } + `); }); - test('auto imports', async () => { + // TODO: reinstate this... seems broken in the IDE as well. + test.skip('auto imports', async () => { await prepareDocument( - 'ts-template-imports-app/src/other.ts', - 'typescript', + 'ts-template-imports-app/src/empty-fixture.gts', + 'glimmer-ts', stripIndent` - export let foobar = 123; - ` + import Component from '@glimmer/component'; + + export default class MyComponent extends Component { + + } + `, ); - expect( - await requestCompletion( - 'ts-template-imports-app/src/index.ts', - 'typescript', - stripIndent` - import { thing } from 'nonexistent'; + const completions = await requestCompletion( + 'ts-template-imports-app/src/empty-fixture2.gts', + 'glimmer-ts', + stripIndent` + let a = My% + `, + ); - let a = foo - ` - ) - ).toMatchInlineSnapshot(); + let importCompletion = completions.find( + (k: any) => k.kind == CompletionItemKind.Variable && k.name == 'foobar', + ); + + expect(importCompletion).toMatchInlineSnapshot(); }); test('auto imports with documentation and tags', async () => { @@ -193,7 +191,7 @@ describe('Language Server: Completions (ts plugin)', () => { * @param foo */ export let foobar = 123; - ` + `, ); expect( @@ -204,8 +202,8 @@ describe('Language Server: Completions (ts plugin)', () => { import { thing } from 'nonexistent'; let a = foo - ` - ) + `, + ), ).toMatchInlineSnapshot(); }); @@ -215,7 +213,7 @@ describe('Language Server: Completions (ts plugin)', () => { 'typescript', stripIndent` export let foobar = 123; - ` + `, ); expect( @@ -224,8 +222,8 @@ describe('Language Server: Completions (ts plugin)', () => { 'typescript', stripIndent` import foo - ` - ) + `, + ), ).toMatchInlineSnapshot(); }); @@ -245,11 +243,7 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-template-imports-app/src/index.gts', - 'glimmer-ts', - code - ) + await requestCompletion('ts-template-imports-app/src/index.gts', 'glimmer-ts', code), ).toMatchInlineSnapshot(); }); @@ -267,11 +261,7 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-template-imports-app/src/index.gts', - 'glimmer-ts', - code - ) + await requestCompletion('ts-template-imports-app/src/index.gts', 'glimmer-ts', code), ).toMatchInlineSnapshot(); }); @@ -289,11 +279,7 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-template-imports-app/src/index.ts', - 'typescript', - code - ) + await requestCompletion('ts-template-imports-app/src/index.ts', 'typescript', code), ).toMatchInlineSnapshot(); }); @@ -311,15 +297,23 @@ describe('Language Server: Completions (ts plugin)', () => { `; expect( - await requestCompletion( - 'ts-template-imports-app/src/index.gts', - 'typescript', - code - ) + await requestCompletion('ts-template-imports-app/src/index.gts', 'typescript', code), ).toMatchInlineSnapshot(); }); }); +async function requestCompletionItem( + fileName: string, + languageId: string, + content: string, + itemLabel: string, +) { + const completions = await requestCompletion(fileName, languageId, content); + let completion = completions.find((item: any) => item.name === itemLabel); + expect(completion).toBeDefined(); + return completion!; +} + async function requestCompletion(fileName: string, languageId: string, contentWithCursor: string) { const [offset, content] = extractCursor(contentWithCursor); const document = await prepareDocument(fileName, languageId, content); diff --git a/packages/core/src/volar/language-server.ts b/packages/core/src/volar/language-server.ts index 15907926e..89f4a5421 100644 --- a/packages/core/src/volar/language-server.ts +++ b/packages/core/src/volar/language-server.ts @@ -1,101 +1,278 @@ -#!/usr/bin/env node - +import type { LanguageServer } from '@volar/language-server'; +import { createLanguageServiceEnvironment } from '@volar/language-server/lib/project/simpleProject.js'; +import { createConnection, createServer, loadTsdkByPath } from '@volar/language-server/node'; +import { + createLanguage, + createParsedCommandLine, + createVueLanguagePlugin, + getDefaultCompilerOptions, +} from '@volar/language-core'; import { - createConnection, - createServer, - createTypeScriptProject, -} from '@volar/language-server/node.js'; -import { createEmberLanguagePlugin } from './ember-language-plugin.js'; -import { ConfigLoader } from '../config/loader.js'; -import ts from 'typescript'; -import { Disposable, LanguagePlugin, URI, VirtualCode } from '@volar/language-service'; -import { createTypescriptLanguageServicePlugin } from './typescript-language-service-plugin.js'; + createLanguageService, + createUriMap, + LanguageService, +} from '@volar/language-service'; +import type * as ts from 'typescript'; +import { URI } from 'vscode-uri'; + +type GlintInitializationOptions = any; // TODO rm hackiness const connection = createConnection(); const server = createServer(connection); -const EXTENSIONS = ['js', 'ts', 'gjs', 'gts', 'hbs']; +connection.listen(); /** * Handle the `initialize` request from the client. This is the first request sent by the client to * the server. It includes the set of capabilities supported by the client as well as * other initialization params needed by the server. */ -connection.onInitialize((parameters) => { - // Not sure how tsLocalized is used. - const tsLocalized = undefined; - const watchingExtensions = new Set(); - let fileWatcher: Promise | undefined; - - const project = createTypeScriptProject(ts, tsLocalized, (projectContext) => { - const configFileName = projectContext.configFileName; - const languagePlugins = []; - - updateFileWatcher(); - - // I don't remember why but there are some contexts where a configFileName is not known, - // in which case we cannot fully activate all of the language plugins. - if (configFileName) { - // TODO: Maybe move ConfigLoader higher up so we can reuse it between calls to `getLanguagePlugins`? That said, - // Volar takes care of a lot of the same group-by-tsconfig caching that ConfigLoader does, - // so it might not buy us much value any more. - const configLoader = new ConfigLoader(); - const glintConfig = configLoader.configForFile(configFileName); - - // TODO: this causes breakage if/when Glint activates for a non-Glint project. - // But if we don't assert, then we activate TS and Glint for non TS projects, - // which doubles diagnostics... how to disable the LS entirely if no Glint? - // assert(glintConfig, 'Glint config is missing'); - - if (glintConfig) { - // TODO: this is where we used to initialize the Language Server - // with our Ember Language Plugin so that we can teach the LS how to perform - // TS diagnostics, but this functionality has been moved to TS Plugin. - // There will still be commands and other tooling that is useful to have - // in the Language Server so we should identify what those are and reinstate, - // while continu - - const disableLanguagePlugin = true; - if (!disableLanguagePlugin) { - // When TS Plugin is enabled, we want the TS Plugin to perform all type-checking/diagnostics/etc, - // rather than the Language Server. - languagePlugins.unshift(createEmberLanguagePlugin(glintConfig)); - } - } - } +connection.onInitialize((params) => { + const options: GlintInitializationOptions = params.initializationOptions; - return { - languagePlugins, - setup(_language) { - // Vue tooling takes this opportunity to stash compilerOptions on `language.vue`; - // do we need to be doing something here? - }, - }; - }); + if (!options.typescript?.tsdk) { + throw new Error('typescript.tsdk is required'); + } - const languageServicePlugins = createTypescriptLanguageServicePlugin(ts); + // TODO: Perform Vue-style proxying to tsserver instance + // See: https://github.com/vuejs/language-tools/pull/5252 + // if (!options.typescript?.requestForwardingCommand) { + // connection.console.warn( + // 'typescript.requestForwardingCommand is required since >= 3.0 for complete TS features', + // ); + // } - return server.initialize(parameters, project, languageServicePlugins); + const { typescript: ts } = loadTsdkByPath(options.typescript.tsdk, params.locale); + const tsconfigProjects = createUriMap(); - function updateFileWatcher(): void { - const newExtensions = EXTENSIONS.filter((ext) => !watchingExtensions.has(ext)); - if (newExtensions.length) { - for (const ext of newExtensions) { - watchingExtensions.add(ext); + server.fileWatcher.onDidChangeWatchedFiles((obj: any) => { + for (const change of obj.changes) { + const changeUri = URI.parse(change.uri); + if (tsconfigProjects.has(changeUri)) { + tsconfigProjects.get(changeUri)!.dispose(); + tsconfigProjects.delete(changeUri); } - fileWatcher?.then((dispose) => dispose.dispose()); - fileWatcher = server.fileWatcher.watchFiles([ - '**/*.{' + [...watchingExtensions].join(',') + '}', - ]); } + }); + + let simpleLs: LanguageService | undefined; + + return server.initialize( + params, + { + setup() {}, + async getLanguageService(uri) { + if (uri.scheme === 'file' && options.typescript.requestForwardingCommand) { + const fileName = uri.fsPath.replace(/\\/g, '/'); + const projectInfo = await sendTsRequest( + ts.server.protocol.CommandTypes.ProjectInfo, + { + file: fileName, + needFileNameList: false, + } satisfies ts.server.protocol.ProjectInfoRequestArgs, + ); + if (projectInfo) { + const { configFileName } = projectInfo; + let ls = tsconfigProjects.get(URI.file(configFileName)); + if (!ls) { + ls = createLs(server, configFileName); + tsconfigProjects.set(URI.file(configFileName), ls); + } + return ls; + } + } + return (simpleLs ??= createLs(server, undefined)); + }, + getExistingLanguageServices() { + return Promise.all([...tsconfigProjects.values(), simpleLs].filter((promise) => !!promise)); + }, + reload() { + for (const ls of [...tsconfigProjects.values(), simpleLs]) { + ls?.dispose(); + } + tsconfigProjects.clear(); + simpleLs = undefined; + }, + }, + getHybridModeLanguageServicePluginsForLanguageServer( + ts, + options.typescript.requestForwardingCommand + ? { + // TODO: Perform Vue-style proxying to tsserver instance + // See: https://github.com/vuejs/language-tools/pull/5252 + // + // collectExtractProps(...args) { + // return sendTsRequest('glint:collectExtractProps', args); + // }, + // getComponentDirectives(...args) { + // return sendTsRequest('glint:getComponentDirectives', args); + // }, + // getComponentEvents(...args) { + // return sendTsRequest('glint:getComponentEvents', args); + // }, + // getComponentNames(...args) { + // return sendTsRequest('glint:getComponentNames', args); + // }, + // getComponentProps(...args) { + // return sendTsRequest('glint:getComponentProps', args); + // }, + // getElementAttrs(...args) { + // return sendTsRequest('glint:getElementAttrs', args); + // }, + // getElementNames(...args) { + // return sendTsRequest('glint:getElementNames', args); + // }, + // getImportPathForFile(...args) { + // return sendTsRequest('glint:getImportPathForFile', args); + // }, + // getPropertiesAtLocation(...args) { + // return sendTsRequest('glint:getPropertiesAtLocation', args); + // }, + // getQuickInfoAtPosition(...args) { + // return sendTsRequest('glint:getQuickInfoAtPosition', args); + // }, + } + : undefined, + ), + ); + + function sendTsRequest(command: string, args: any): Promise { + return connection.sendRequest(options.typescript.requestForwardingCommand!, [command, args]); } -}); -/** - * Invoked when client has sent `initialized` notification. - */ -connection.onInitialized(() => { - server.initialized(); + function createLs(server: LanguageServer, tsconfig: string | undefined) { + const commonLine = tsconfig + ? createParsedCommandLine(ts, ts.sys, tsconfig) + : { + options: ts.getDefaultCompilerOptions(), + vueOptions: getDefaultCompilerOptions(), + }; + const language = createLanguage( + [ + { + getLanguageId: (uri) => server.documents.get(uri)?.languageId, + }, + createVueLanguagePlugin(ts, commonLine.options, commonLine.vueOptions, (uri) => + uri.fsPath.replace(/\\/g, '/'), + ), + ], + createUriMap(), + (uri) => { + const document = server.documents.get(uri); + if (document) { + language.scripts.set(uri, document.getSnapshot(), document.languageId); + } else { + language.scripts.delete(uri); + } + }, + ); + return createLanguageService( + language, + server.languageServicePlugins, + createLanguageServiceEnvironment(server, [...server.workspaceFolders.all]), + { vue: { compilerOptions: commonLine.vueOptions } }, + ); + } }); -connection.listen(); +connection.onInitialized(server.initialized); + +connection.onShutdown(server.shutdown); + +function getHybridModeLanguageServicePluginsForLanguageServer( + ts: typeof import('typescript'), + getTsPluginClient: import('@vue/typescript-plugin/lib/requests').Requests | undefined +) { + const plugins = [ + createTypeScriptSyntacticPlugin(ts), + createTypeScriptDocCommentTemplatePlugin(ts), + ...getCommonLanguageServicePluginsForLanguageServer(ts, () => getTsPluginClient) + ]; + for (const plugin of plugins) { + // avoid affecting TS plugin + delete plugin.capabilities.semanticTokensProvider; + } + return plugins; +} + +function getCommonLanguageServicePluginsForLanguageServer( + ts: typeof import('typescript'), + getTsPluginClient: (context: LanguageServiceContext) => import('@vue/typescript-plugin/lib/requests').Requests | undefined +): LanguageServicePlugin[] { + return [ + // createTypeScriptTwoslashQueriesPlugin(ts), + // createCssPlugin(), + // createPugFormatPlugin(), + // createJsonPlugin(), + // createVueTemplatePlugin('html', getTsPluginClient), + // createVueTemplatePlugin('pug', getTsPluginClient), + // createVueMissingPropsHintsPlugin(getTsPluginClient), + // createVueCompilerDomErrorsPlugin(), + // createVueSfcPlugin(), + // createVueTwoslashQueriesPlugin(getTsPluginClient), + // createVueDocumentLinksPlugin(), + // createVueDocumentDropPlugin(ts, getTsPluginClient), + // createVueCompleteDefineAssignmentPlugin(), + // createVueAutoDotValuePlugin(ts, getTsPluginClient), + // createVueAutoAddSpacePlugin(), + // createVueInlayHintsPlugin(ts), + // createVueDirectiveCommentsPlugin(), + // createVueExtractFilePlugin(ts, getTsPluginClient), + // createEmmetPlugin({ + // mappedLanguages: { + // 'vue-root-tags': 'html', + // 'postcss': 'scss', + // }, + // }), + // { + // name: 'vue-parse-sfc', + // capabilities: { + // executeCommandProvider: { + // commands: [commands.parseSfc], + // }, + // }, + // create() { + // return { + // executeCommand(_command, [source]) { + // return parse(source); + // }, + // }; + // }, + // }, + // { + // name: 'vue-name-casing', + // capabilities: { + // executeCommandProvider: { + // commands: [ + // commands.detectNameCasing, + // commands.convertTagsToKebabCase, + // commands.convertTagsToPascalCase, + // commands.convertPropsToKebabCase, + // commands.convertPropsToCamelCase, + // ], + // } + // }, + // create(context) { + // return { + // executeCommand(command, [uri]) { + // if (command === commands.detectNameCasing) { + // return detect(context, URI.parse(uri)); + // } + // else if (command === commands.convertTagsToKebabCase) { + // return convertTagName(context, URI.parse(uri), TagNameCasing.Kebab, getTsPluginClient(context)); + // } + // else if (command === commands.convertTagsToPascalCase) { + // return convertTagName(context, URI.parse(uri), TagNameCasing.Pascal, getTsPluginClient(context)); + // } + // else if (command === commands.convertPropsToKebabCase) { + // return convertAttrName(context, URI.parse(uri), AttrNameCasing.Kebab, getTsPluginClient(context)); + // } + // else if (command === commands.convertPropsToCamelCase) { + // return convertAttrName(context, URI.parse(uri), AttrNameCasing.Camel, getTsPluginClient(context)); + // } + // }, + // }; + // }, + // } + ]; +} diff --git a/packages/core/src/volar/typescript-language-service-plugin.ts b/packages/core/src/volar/typescript-language-service-plugin.ts index 4054dbb77..a1acad675 100644 --- a/packages/core/src/volar/typescript-language-service-plugin.ts +++ b/packages/core/src/volar/typescript-language-service-plugin.ts @@ -17,7 +17,7 @@ import { VirtualGtsCode } from './gts-virtual-code.js'; // (TS + Handlebars) combined into one, but we can use the TS language service because the only // scripts we pass to the TS service for type-checking is transformed Intermediate Representation (IR) // TypeScript code with all