diff --git a/packages/language-server/README.md b/packages/language-server/README.md index 7c28effc..44f00fed 100644 --- a/packages/language-server/README.md +++ b/packages/language-server/README.md @@ -16,7 +16,6 @@ A [language server][lsp] for [MDX][]. * [Install](#install) * [Use](#use) * [Language server features](#language-server-features) - * [Initialize Options](#initialize-options) * [Configuration](#configuration) * [TypeScript](#typescript) * [Plugins](#plugins) @@ -127,17 +126,6 @@ It uses the `workspace/applyEdit` command to apply edits. `null` -### Initialize Options - -MDX language server supports the following LSP initialization options: - -* `typescript.enabled` (`boolean`, default: `false`) — - If true, enable TypeScript. -* `typescript.tsdk` (`string`, required) — - The path from which to load TypeScript. -* `locale` (`string`, optional) — - The locale to use for TypeScript error messages. - ### Configuration MDX language server supports the following LSP configuration options: diff --git a/packages/language-server/lib/index.js b/packages/language-server/lib/index.js index 78072451..b0c24ecc 100755 --- a/packages/language-server/lib/index.js +++ b/packages/language-server/lib/index.js @@ -1,11 +1,11 @@ #!/usr/bin/env node /** - * @import {VirtualCodePlugin} from '@mdx-js/language-service' + * @import {LanguageService} from '@volar/language-service' * @import {PluggableList} from 'unified' + * @import {URI} from 'vscode-uri' */ -import assert from 'node:assert' import {createRequire} from 'node:module' import path from 'node:path' import process from 'node:process' @@ -14,16 +14,14 @@ import { createMdxServicePlugin, resolvePlugins } from '@mdx-js/language-service' -import { - createConnection, - createServer, - createTypeScriptProject, - loadTsdkByPath -} from '@volar/language-server/node.js' +import {createLanguage} from '@volar/language-core' +import {createConnection, createServer} from '@volar/language-server/node.js' +import {createLanguageServiceEnvironment} from '@volar/language-server/lib/project/simpleProject.js' +import {createLanguageService, createUriMap} from '@volar/language-service' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' +import typescript from 'typescript' import {create as createMarkdownServicePlugin} from 'volar-service-markdown' -import {create as createTypeScriptServicePlugin} from 'volar-service-typescript' import {create as createTypeScriptSyntacticServicePlugin} from 'volar-service-typescript/lib/plugins/syntactic.js' process.title = 'mdx-language-server' @@ -32,116 +30,259 @@ process.title = 'mdx-language-server' const defaultPlugins = [[remarkFrontmatter, ['toml', 'yaml']], remarkGfm] const connection = createConnection() const server = createServer(connection) -let tsEnabled = false -connection.onInitialize(async (parameters) => { - const tsdk = parameters.initializationOptions?.typescript?.tsdk - tsEnabled = Boolean(parameters.initializationOptions?.typescript?.enabled) - assert.ok( - typeof tsdk === 'string', - 'Missing initialization option typescript.tsdk' - ) +/** @type {Map void>} */ +const tsserverRequestHandlers = new Map() +let tsserverRequestId = 0 +let tsserverBridgeAvailable = false - const {typescript, diagnosticMessages} = loadTsdkByPath( - tsdk, - parameters.locale - ) +// Listen for tsserver responses +connection.onNotification( + 'tsserver/response', + /** + * @param {[number, unknown]} params + */ + ([id, res]) => { + tsserverBridgeAvailable = true + const handler = tsserverRequestHandlers.get(id) + if (handler) { + handler(res) + tsserverRequestHandlers.delete(id) + } + } +) - return server.initialize( - parameters, - createTypeScriptProject( - typescript, - diagnosticMessages, - ({configFileName}) => ({ - languagePlugins: getLanguagePlugins(configFileName) - }) - ), - getLanguageServicePlugins() - ) +/** + * Send a request to tsserver via the client + * + * @template T + * @param {string} command + * @param {unknown} args + * @returns {Promise} + */ +async function sendTsServerRequest(command, args) { + return new Promise((resolve) => { + const requestId = ++tsserverRequestId + tsserverRequestHandlers.set( + requestId, + /** @type {(res: unknown) => void} */ (resolve) + ) + connection.sendNotification('tsserver/request', [requestId, command, args]) - function getLanguageServicePlugins() { - const plugins = [ - createMarkdownServicePlugin({ - getDiagnosticOptions(document, context) { - return context.env.getConfiguration?.('mdx.validate') + // Short timeout - if tsserver bridge is not available, fall back quickly + setTimeout( + () => { + if (tsserverRequestHandlers.has(requestId)) { + tsserverRequestHandlers.delete(requestId) + resolve(null) } - }), - createMdxServicePlugin(connection.workspace) - ] - - if (tsEnabled) { - plugins.push(...createTypeScriptServicePlugin(typescript, {})) - } else { - plugins.push(createTypeScriptSyntacticServicePlugin(typescript)) - } + }, + tsserverBridgeAvailable ? 5000 : 100 + ) + }) +} - return plugins - } +/** + * Find tsconfig.json for a file by walking up the directory tree + * + * @param {string} fileName + * @returns {string | undefined} + */ +function findTsConfig(fileName) { + return typescript.findConfigFile( + path.dirname(fileName), + typescript.sys.fileExists, + 'tsconfig.json' + ) +} + +/** @type {Map} */ +const tsconfigProjects = new Map() + +/** @type {Map>} */ +const file2ConfigPath = new Map() + +/** @type {LanguageService | undefined} */ +let simpleLanguageService + +connection.onInitialize(async (parameters) => { + const languageServicePlugins = [ + createMarkdownServicePlugin({ + getDiagnosticOptions(document, context) { + return context.env.getConfiguration?.('mdx.validate') + } + }), + createMdxServicePlugin(connection.workspace), + createTypeScriptSyntacticServicePlugin(typescript) + ] /** + * Create a language service for a specific tsconfig + * * @param {string | undefined} tsconfig + * @returns {LanguageService} */ - function getLanguagePlugins(tsconfig) { - /** @type {PluggableList | undefined} */ - let remarkPlugins - /** @type {VirtualCodePlugin[] | undefined} */ - let virtualCodePlugins - let checkMdx = false - let jsxImportSource = 'react' - - if (tsconfig) { - const cwd = path.dirname(tsconfig) - const configSourceFile = typescript.readJsonConfigFile( - tsconfig, - typescript.sys.readFile - ) - const commandLine = typescript.parseJsonSourceFileConfigFileContent( - configSourceFile, - typescript.sys, - cwd, - undefined, - tsconfig - ) - - const require = createRequire(tsconfig) - - ;[remarkPlugins, virtualCodePlugins] = resolvePlugins( - commandLine.raw?.mdx, - (name) => require(name).default - ) - checkMdx = Boolean(commandLine.raw?.mdx?.checkMdx) - jsxImportSource = commandLine.options.jsxImportSource || jsxImportSource + function createProjectLanguageService(tsconfig) { + let languagePlugin + + if (tsconfig && !typescript.server.isInferredProjectName(tsconfig)) { + try { + const configFile = typescript.readConfigFile( + tsconfig, + typescript.sys.readFile + ) + const cwd = path.dirname(tsconfig) + const commandLine = typescript.parseJsonConfigFileContent( + configFile.config, + typescript.sys, + cwd, + undefined, + tsconfig + ) + + const mdxConfig = commandLine.raw?.mdx + if (mdxConfig) { + const require = createRequire(tsconfig) + const [remarkPlugins, virtualCodePlugins] = resolvePlugins( + mdxConfig, + (name) => require(name).default + ) + + languagePlugin = createMdxLanguagePlugin( + remarkPlugins || defaultPlugins, + virtualCodePlugins, + Boolean(mdxConfig.checkMdx), + commandLine.options.jsxImportSource + ) + } + } catch { + // Fall through to default + } } - return [ - createMdxLanguagePlugin( - remarkPlugins || defaultPlugins, - virtualCodePlugins, - checkMdx, - jsxImportSource - ) - ] - } -}) + languagePlugin ||= createMdxLanguagePlugin(defaultPlugins) -connection.onInitialized(() => { - const extensions = ['mdx'] - if (tsEnabled) { - extensions.push( - 'cjs', - 'cts', - 'js', - 'jsx', - 'json', - 'mjs', - 'mts', - 'ts', - 'tsx' + /** @type {Map>} */ + const scriptRegistry = createUriMap(false) + + const language = createLanguage( + [ + { + getLanguageId: (/** @type {URI} */ uri) => + server.documents.get(uri)?.languageId + }, + languagePlugin + ], + scriptRegistry, + (/** @type {URI} */ 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, + languageServicePlugins, + createLanguageServiceEnvironment(server, [ + ...server.workspaceFolders.all + ]), + {} ) } + return server.initialize( + parameters, + { + setup() {}, + async getLanguageService(uri) { + if (uri.scheme === 'file') { + const fileName = uri.fsPath.replaceAll('\\', '/') + let configPathPromise = file2ConfigPath.get(fileName) + + if (!configPathPromise) { + configPathPromise = (async () => { + // First try to get config from tsserver (for accurate project info) + /** @type {{configFileName?: string} | null} */ + const projectInfo = await sendTsServerRequest( + '_mdx:projectInfo', + { + file: fileName, + needFileNameList: false + } + ) + + if (projectInfo?.configFileName) { + return projectInfo.configFileName + } + + // Fall back to finding tsconfig manually + return findTsConfig(fileName) || null + })() + file2ConfigPath.set(fileName, configPathPromise) + } + + const configFilePath = await configPathPromise + if (configFilePath) { + let languageService = tsconfigProjects.get(configFilePath) + if (!languageService) { + languageService = createProjectLanguageService(configFilePath) + tsconfigProjects.set(configFilePath, languageService) + } + + return languageService + } + } + + simpleLanguageService ||= createProjectLanguageService(undefined) + return simpleLanguageService + }, + getExistingLanguageServices() { + const services = [...tsconfigProjects.values()] + if (simpleLanguageService) { + services.push(simpleLanguageService) + } + + return services + }, + reload() { + for (const service of tsconfigProjects.values()) { + service.dispose() + } + + tsconfigProjects.clear() + file2ConfigPath.clear() + if (simpleLanguageService) { + simpleLanguageService.dispose() + simpleLanguageService = undefined + } + } + }, + languageServicePlugins + ) +}) + +connection.onInitialized(() => { server.initialized() - server.fileWatcher.watchFiles([`**/*.{${extensions.join(',')}}`]) + server.fileWatcher.watchFiles(['**/*.mdx']) + + // Clear caches when tsconfig changes + server.fileWatcher.onDidChangeWatchedFiles(({changes}) => { + for (const change of changes) { + if (change.uri.endsWith('tsconfig.json')) { + for (const service of tsconfigProjects.values()) { + service.dispose() + } + + tsconfigProjects.clear() + file2ConfigPath.clear() + break + } + } + }) }) connection.listen() diff --git a/packages/language-server/package.json b/packages/language-server/package.json index d3d9ea39..621885ef 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -27,19 +27,26 @@ "unified" ], "scripts": { - "test-api": "node --test", - "test": "npm run test-api" + "test-api": "node --test 'test/*.test.js'", + "test-ts": "node --test 'test/ts/*.test.js'", + "test": "npm run test-api && npm run test-ts" }, "dependencies": { "@mdx-js/language-service": "0.7.3", + "@volar/language-core": "~2.4.0", "@volar/language-server": "~2.4.0", + "@volar/language-service": "~2.4.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "volar-service-markdown": "0.0.65", "volar-service-typescript": "0.0.65" }, + "peerDependencies": { + "typescript": "*" + }, "devDependencies": { "@types/node": "^22.0.0", + "@typescript/server-harness": "^0.3.5", "@volar/test-utils": "~2.4.0", "unified": "^11.0.0", "vscode-uri": "^3.0.0" diff --git a/packages/language-server/test/code-action.test.js b/packages/language-server/test/code-action.test.js index d1de4394..f2ba907b 100644 --- a/packages/language-server/test/code-action.test.js +++ b/packages/language-server/test/code-action.test.js @@ -1,36 +1,36 @@ /** + * @fileoverview Language server code action tests + * + * Note: TypeScript-specific code action tests (like organize imports) have been + * moved to TypeScript plugin testing. This file only tests language server + * specific functionality that doesn't depend on full TypeScript support. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {CodeAction, CodeActionTriggerKind} from '@volar/language-server' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {CodeActionTriggerKind} from '@volar/language-server' +import {createServer, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { serverHandle.connection.dispose() }) -test('organize imports', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/organize-imports.mdx'), - 'mdx' - ) - +test('return empty code actions for non-existent file', async () => { + const uri = fixtureUri('node16/non-existent.mdx') const codeActions = await serverHandle.sendCodeActionsRequest( uri, { - start: {line: 6, character: 0}, - end: {line: 6, character: 0} + start: {line: 0, character: 0}, + end: {line: 0, character: 0} }, { diagnostics: [], @@ -38,60 +38,5 @@ test('organize imports', async () => { triggerKind: CodeActionTriggerKind.Invoked } ) - - assert.ok(codeActions) - const codeAction = codeActions - .filter((c) => CodeAction.is(c)) - .find((c) => c.kind === 'source.organizeImports') - delete codeAction?.data - - assert.deepEqual(codeAction, { - diagnostics: [], - edit: { - documentChanges: [ - { - edits: [ - { - newText: - "import { compile } from '@mdx-js/mdx';\n" + - "import { useState } from 'react';\n" + - "import { createRoot } from 'react-dom/client';\n" + - "import { unified } from 'unified';\n", - range: { - end: {character: 0, line: 5}, - start: {character: 0, line: 4} - } - }, - { - newText: '', - range: { - end: {character: 0, line: 6}, - start: {character: 0, line: 5} - } - }, - { - newText: '', - range: { - end: {character: 0, line: 7}, - start: {character: 0, line: 6} - } - }, - { - newText: '', - range: { - end: {character: 0, line: 8}, - start: {character: 0, line: 7} - } - } - ], - textDocument: { - uri: fixtureUri('node16/organize-imports.mdx'), - version: null - } - } - ] - }, - kind: 'source.organizeImports', - title: 'Organize Imports' - }) + assert.deepEqual(codeActions, []) }) diff --git a/packages/language-server/test/completion.test.js b/packages/language-server/test/completion.test.js index 4e77333d..ef68f629 100644 --- a/packages/language-server/test/completion.test.js +++ b/packages/language-server/test/completion.test.js @@ -1,156 +1,46 @@ /** + * @fileoverview Language server completion tests + * + * Note: TypeScript-specific completion tests have been moved to TypeScript + * plugin testing. This file only tests language server specific functionality + * that doesn't depend on full TypeScript support. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {CompletionItemKind, InsertTextFormat} from '@volar/language-server' -import {URI} from 'vscode-uri' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { serverHandle.connection.dispose() }) -test('support completion in ESM', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/completion.mdx'), - 'mdx' - ) - - const result = await serverHandle.sendCompletionRequest(uri, { - line: 1, - character: 1 - }) - assert.ok(result) - assert.ok('items' in result) - const completion = result.items.find((r) => r.label === 'Boolean') - assert.deepEqual(completion, { - commitCharacters: ['.', ',', ';', '('], - data: { - embeddedDocumentUri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: '/' + encodeURIComponent(fixtureUri('node16/completion.mdx')) - }) - ), - original: { - data: { - fileName: fixturePath('node16/completion.mdx'), - offset: 108, - originalItem: {name: 'Boolean'}, - uri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: - '/' + encodeURIComponent(fixtureUri('node16/completion.mdx')) - }) - ) - } - }, - pluginIndex: 2, - uri: fixtureUri('node16/completion.mdx') - }, - insertTextFormat: InsertTextFormat.PlainText, - kind: CompletionItemKind.Variable, - label: 'Boolean', - sortText: '15' - }) - - const resolved = await serverHandle.sendCompletionResolveRequest(completion) - assert.deepEqual(resolved, { - commitCharacters: ['.', ',', ';', '('], - detail: 'interface Boolean\nvar Boolean: BooleanConstructor', - documentation: {kind: 'markdown', value: ''}, - insertTextFormat: 1, - kind: 6, - label: 'Boolean', - sortText: '15' - }) -}) - -test('support completion in JSX', async () => { +test('ignore completion in markdown content', async () => { const {uri} = await serverHandle.openTextDocument( fixturePath('node16/completion.mdx'), 'mdx' ) - await serverHandle.sendCompletionRequest(uri, { - line: 5, - character: 3 - }) const result = await serverHandle.sendCompletionRequest(uri, { - line: 5, - character: 3 - }) - - assert.ok(result) - assert.ok('items' in result) - const completion = result.items.find((r) => r.label === 'Boolean') - assert.deepEqual(completion, { - commitCharacters: ['.', ',', ';', '('], - data: { - embeddedDocumentUri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: '/' + encodeURIComponent(fixtureUri('node16/completion.mdx')) - }) - ), - original: { - data: { - fileName: fixturePath('node16/completion.mdx'), - offset: 146, - originalItem: {name: 'Boolean'}, - uri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: - '/' + encodeURIComponent(fixtureUri('node16/completion.mdx')) - }) - ) - } - }, - pluginIndex: 2, - uri: fixtureUri('node16/completion.mdx') - }, - insertTextFormat: InsertTextFormat.PlainText, - kind: CompletionItemKind.Variable, - label: 'Boolean', - sortText: '15' + line: 8, + character: 10 }) - const resolved = await serverHandle.sendCompletionResolveRequest(completion) - assert.deepEqual(resolved, { - commitCharacters: ['.', ',', ';', '('], - detail: 'interface Boolean\nvar Boolean: BooleanConstructor', - documentation: {kind: 'markdown', value: ''}, - insertTextFormat: 1, - kind: 6, - label: 'Boolean', - sortText: '15' - }) + assert.deepEqual(result, {isIncomplete: false, items: []}) }) -test('ignore completion in markdown content', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/completion.mdx'), - 'mdx' - ) +test('ignore non-existent mdx files', async () => { + const uri = fixtureUri('node16/non-existent.mdx') const result = await serverHandle.sendCompletionRequest(uri, { - line: 8, - character: 10 + line: 1, + character: 1 }) assert.deepEqual(result, {isIncomplete: false, items: []}) diff --git a/packages/language-server/test/definitions.test.js b/packages/language-server/test/definitions.test.js index 6647df33..2177f07a 100644 --- a/packages/language-server/test/definitions.test.js +++ b/packages/language-server/test/definitions.test.js @@ -1,9 +1,15 @@ /** + * @fileoverview Language server definition tests + * + * Note: TypeScript-specific definition tests have been moved to TypeScript + * plugin testing. This file only tests language server specific functionality + * that doesn't depend on full TypeScript support. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle @@ -12,9 +18,7 @@ beforeEach(async () => { serverHandle = createServer() await serverHandle.initialize( fixtureUri('node16'), - { - typescript: {enabled: true, tsdk} - }, + {}, { textDocument: { definition: { @@ -29,94 +33,6 @@ afterEach(() => { serverHandle.connection.dispose() }) -test('resolve file-local definitions in ESM', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendDefinitionRequest(uri, { - line: 4, - character: 3 - }) - - assert.deepEqual(result, [ - { - originSelectionRange: { - start: {line: 4, character: 2}, - end: {line: 4, character: 3} - }, - targetRange: { - start: {line: 1, character: 0}, - end: {line: 1, character: 22} - }, - targetSelectionRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} - }, - targetUri: fixtureUri('node16/a.mdx') - } - ]) -}) - -test('resolve cross-file definitions in ESM if the other file was previously opened', async () => { - await serverHandle.openTextDocument(fixturePath('node16/a.mdx'), 'mdx') - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/b.mdx'), - 'mdx' - ) - const result = await serverHandle.sendDefinitionRequest(uri, { - line: 0, - character: 10 - }) - - assert.deepEqual(result, [ - { - originSelectionRange: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - }, - targetRange: { - start: {line: 1, character: 0}, - end: {line: 1, character: 22} - }, - targetSelectionRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} - }, - targetUri: fixtureUri('node16/a.mdx') - } - ]) -}) - -test('resolve cross-file definitions in ESM if the other file is unopened', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/b.mdx'), - 'mdx' - ) - const result = await serverHandle.sendDefinitionRequest(uri, { - line: 0, - character: 10 - }) - - assert.deepEqual(result, [ - { - originSelectionRange: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - }, - targetRange: { - start: {line: 1, character: 0}, - end: {line: 1, character: 22} - }, - targetSelectionRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} - }, - targetUri: fixtureUri('node16/a.mdx') - } - ]) -}) - test('does not resolve shadow content', async () => { const {uri} = await serverHandle.openTextDocument( fixturePath('node16/undefined-props.mdx'), @@ -126,7 +42,6 @@ test('does not resolve shadow content', async () => { line: 0, character: 37 }) - assert.deepEqual(result, []) }) @@ -136,6 +51,5 @@ test('ignore non-existent mdx files', async () => { line: 7, character: 15 }) - assert.deepEqual(result, []) }) diff --git a/packages/language-server/test/diagnostics.test.js b/packages/language-server/test/diagnostics.test.js index e39b11e1..e3623013 100644 --- a/packages/language-server/test/diagnostics.test.js +++ b/packages/language-server/test/diagnostics.test.js @@ -1,19 +1,23 @@ /** + * @fileoverview Language server diagnostics tests + * + * Note: TypeScript-specific type error diagnostics are tested through the + * TypeScript plugin. This file only tests MDX syntax error diagnostics that + * don't depend on full TypeScript support. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {URI} from 'vscode-uri' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { @@ -26,7 +30,6 @@ test('parse errors', async () => { 'mdx' ) const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - assert.deepEqual(diagnostics, { kind: 'full', items: [ @@ -68,145 +71,14 @@ test('parse errors', async () => { }) }) -test('type errors', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/type-errors.mdx'), - 'mdx' - ) - const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - - assert.deepEqual(diagnostics, { - kind: 'full', - items: [ - { - code: 2568, - data: { - documentUri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: - '/' + encodeURIComponent(fixtureUri('node16/type-errors.mdx')) - }) - ), - isFormat: false, - original: {}, - pluginIndex: 2, - uri: fixtureUri('node16/type-errors.mdx'), - version: 0 - }, - message: - "Property 'counter' may not exist on type '{ readonly count: number; readonly components?: {}; }'. Did you mean 'count'?", - range: { - start: {line: 14, character: 51}, - end: {line: 14, character: 58} - }, - severity: 4, - source: 'ts' - }, - { - code: 2568, - data: { - documentUri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: - '/' + encodeURIComponent(fixtureUri('node16/type-errors.mdx')) - }) - ), - isFormat: false, - original: {}, - pluginIndex: 2, - uri: fixtureUri('node16/type-errors.mdx'), - version: 0 - }, - message: - "Property 'counts' may not exist on type 'Props'. Did you mean 'count'?", - range: { - start: {line: 6, character: 15}, - end: {line: 6, character: 21} - }, - relatedInformation: [ - { - location: { - range: { - end: {line: 12, character: 2}, - start: {line: 11, character: 4} - }, - uri: fixtureUri('node16/type-errors.mdx') - }, - message: "'count' is declared here." - } - ], - severity: 4, - source: 'ts' - } - ] - }) -}) - test('does not resolve shadow content', async () => { const {uri} = await serverHandle.openTextDocument( fixturePath('node16/link-reference.mdx'), 'mdx' ) const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - assert.deepEqual(diagnostics, { items: [], kind: 'full' }) }) - -test('provided components', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('provide/solar-system.mdx'), - 'mdx' - ) - const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - - assert.deepEqual(diagnostics, { - items: [ - { - code: 2741, - data: { - documentUri: String( - URI.from({ - scheme: 'volar-embedded-content', - authority: 'jsx', - path: - '/' + encodeURIComponent(fixtureUri('provide/solar-system.mdx')) - }) - ), - isFormat: false, - original: {}, - pluginIndex: 2, - uri: fixtureUri('provide/solar-system.mdx'), - version: 0 - }, - message: - "Property 'distanceFromStar' is missing in type '{ name: string; radius: number; }' but required in type 'PlanetProps'.", - range: { - end: {character: 7, line: 2}, - start: {character: 1, line: 2} - }, - relatedInformation: [ - { - location: { - range: { - end: {character: 18, line: 3}, - start: {character: 2, line: 3} - }, - uri: fixtureUri('provide/components.tsx') - }, - message: "'distanceFromStar' is declared here." - } - ], - severity: 1, - source: 'ts' - } - ], - kind: 'full' - }) -}) diff --git a/packages/language-server/test/document-link.test.js b/packages/language-server/test/document-link.test.js index 082d84f1..d787bda9 100644 --- a/packages/language-server/test/document-link.test.js +++ b/packages/language-server/test/document-link.test.js @@ -4,16 +4,14 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {URI} from 'vscode-uri' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { diff --git a/packages/language-server/test/document-symbols.test.js b/packages/language-server/test/document-symbols.test.js index 2169c296..1e185402 100644 --- a/packages/language-server/test/document-symbols.test.js +++ b/packages/language-server/test/document-symbols.test.js @@ -4,16 +4,14 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {SymbolKind} from '@volar/language-server' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { diff --git a/packages/language-server/test/folding-ranges.test.js b/packages/language-server/test/folding-ranges.test.js index 8d633d23..c20c7f89 100644 --- a/packages/language-server/test/folding-ranges.test.js +++ b/packages/language-server/test/folding-ranges.test.js @@ -3,16 +3,14 @@ */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { diff --git a/packages/language-server/test/hover.test.js b/packages/language-server/test/hover.test.js index 60b28afe..84cee65c 100644 --- a/packages/language-server/test/hover.test.js +++ b/packages/language-server/test/hover.test.js @@ -1,160 +1,28 @@ /** + * @fileoverview Language server hover tests + * + * Note: TypeScript-specific hover tests have been moved to TypeScript plugin + * testing. This file only tests language server specific functionality that + * doesn't depend on full TypeScript support. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { serverHandle.connection.dispose() }) -test('resolve hover in ESM', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 4, - character: 3 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: '```typescript\nfunction a(): void\n```\n\nDescription of `a`' - }, - range: { - end: {line: 4, character: 3}, - start: {line: 4, character: 2} - } - }) -}) - -test('resolve import hover in ESM if the other file was previously opened', async () => { - await serverHandle.openTextDocument(fixturePath('node16/a.mdx'), 'mdx') - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/b.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 0, - character: 10 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: - '```typescript\n(alias) function a(): void\nimport a\n```\n\nDescription of `a`' - }, - range: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - } - }) -}) - -test('resolve import hover in ESM if the other file is unopened', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/b.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 0, - character: 10 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: - '```typescript\n(alias) function a(): void\nimport a\n```\n\nDescription of `a`' - }, - range: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - } - }) -}) - -test('resolve import hover in JSX expressions', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 11, - character: 1 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: '```typescript\nfunction a(): void\n```\n\nDescription of `a`' - }, - range: { - start: {line: 11, character: 1}, - end: {line: 11, character: 2} - } - }) -}) - -test('support mdxJsxTextElement', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/mdx-jsx-text-element.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 3, - character: 5 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: - '```typescript\nfunction Component(): void\n```\n\nDescription of `Component`' - }, - range: { - start: {line: 3, character: 1}, - end: {line: 3, character: 10} - } - }) -}) - -test('resolve import hover in JSX elements', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendHoverRequest(uri, { - line: 13, - character: 5 - }) - - assert.deepEqual(result, { - contents: { - kind: 'markdown', - value: '```typescript\nfunction Component(): JSX.Element\n```' - }, - range: { - start: {line: 13, character: 1}, - end: {line: 13, character: 10} - } - }) -}) - test('ignore non-existent mdx files', async () => { const uri = fixtureUri('node16/non-existent.mdx') const result = await serverHandle.sendHoverRequest(uri, { diff --git a/packages/language-server/test/initialize.test.js b/packages/language-server/test/initialize.test.js index 1cdb9fac..2f500615 100644 --- a/packages/language-server/test/initialize.test.js +++ b/packages/language-server/test/initialize.test.js @@ -1,9 +1,16 @@ /** + * @fileoverview Language server initialization tests + * + * Note: The capabilities returned by the language server depend on the + * service plugins configured. Since TypeScript support has been moved to + * the TypeScript plugin, some capabilities are no longer provided by the + * language server directly. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixtureUri, tsdk} from './utils.js' +import {createServer, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle @@ -19,29 +26,20 @@ afterEach(() => { test('initialize', async () => { const {serverInfo, ...initializeResponse} = await serverHandle.initialize( fixtureUri('node16'), - {typescript: {enabled: true, tsdk}} + {} ) assert.deepEqual(initializeResponse, { capabilities: { - callHierarchyProvider: true, codeActionProvider: { codeActionKinds: [ 'source.organizeLinkDefinitions', 'quickfix', - 'refactor', - '', - 'refactor.extract', - 'refactor.inline', - 'refactor.rewrite', - 'source', - 'source.fixAll', - 'source.organizeImports' + 'refactor' ], resolveProvider: true }, completionProvider: { - resolveProvider: true, - triggerCharacters: ['.', '/', '#', '"', "'", '`', '<', '@', ' ', '*'] + triggerCharacters: ['.', '/', '#'] }, definitionProvider: true, documentFormattingProvider: true, @@ -76,47 +74,12 @@ test('initialize', async () => { }, foldingRangeProvider: true, hoverProvider: true, - implementationProvider: true, - inlayHintProvider: {}, referencesProvider: true, renameProvider: { prepareProvider: true }, selectionRangeProvider: true, - semanticTokensProvider: { - full: true, - legend: { - tokenModifiers: [ - 'declaration', - 'readonly', - 'static', - 'async', - 'defaultLibrary', - 'local' - ], - tokenTypes: [ - 'namespace', - 'class', - 'enum', - 'interface', - 'typeParameter', - 'type', - 'parameter', - 'variable', - 'property', - 'enumMember', - 'function', - 'method' - ] - }, - range: true - }, - signatureHelpProvider: { - retriggerCharacters: [')'], - triggerCharacters: ['(', ',', '<'] - }, textDocumentSync: 2, - typeDefinitionProvider: true, workspace: { workspaceFolders: { changeNotifications: true, diff --git a/packages/language-server/test/no-tsconfig.test.js b/packages/language-server/test/no-tsconfig.test.js index 381a15b9..511987f0 100644 --- a/packages/language-server/test/no-tsconfig.test.js +++ b/packages/language-server/test/no-tsconfig.test.js @@ -3,16 +3,14 @@ */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('no-tsconfig'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('no-tsconfig'), {}) }) afterEach(() => { diff --git a/packages/language-server/test/prepare-rename.test.js b/packages/language-server/test/prepare-rename.test.js index f5c85f3e..9de32b74 100644 --- a/packages/language-server/test/prepare-rename.test.js +++ b/packages/language-server/test/prepare-rename.test.js @@ -1,40 +1,28 @@ /** + * @fileoverview Prepare rename tests + * + * Note: TypeScript-specific rename functionality (like variable renaming) is + * now provided by the TypeScript plugin. This file only tests language server + * specific functionality. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { serverHandle.connection.dispose() }) -test('handle prepare rename request of variable', async () => { - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendPrepareRenameRequest(uri, { - line: 4, - character: 3 - }) - - assert.deepEqual(result, { - start: {line: 4, character: 2}, - end: {line: 4, character: 3} - }) -}) - test('ignore non-existent mdx files', async () => { const uri = fixtureUri('node16/non-existent.mdx') const result = await serverHandle.sendPrepareRenameRequest(uri, { diff --git a/packages/language-server/test/rename.test.js b/packages/language-server/test/rename.test.js index 93726d6d..4fa476a9 100644 --- a/packages/language-server/test/rename.test.js +++ b/packages/language-server/test/rename.test.js @@ -1,83 +1,28 @@ /** + * @fileoverview Rename tests + * + * Note: TypeScript-specific rename functionality (like variable renaming) is + * now provided by the TypeScript plugin. This file only tests language server + * specific functionality. + * * @import {LanguageServerHandle} from '@volar/test-utils' */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixturePath, fixtureUri, tsdk} from './utils.js' +import {createServer, fixturePath, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: true, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { serverHandle.connection.dispose() }) -test('handle rename request of variable for opened references', async () => { - await serverHandle.openTextDocument(fixturePath('node16/b.mdx'), 'mdx') - const {uri} = await serverHandle.openTextDocument( - fixturePath('node16/a.mdx'), - 'mdx' - ) - const result = await serverHandle.sendRenameRequest( - uri, - {line: 4, character: 3}, - 'renamed' - ) - - assert.deepEqual(result, { - changes: { - [fixtureUri('node16/a.mdx')]: [ - { - newText: 'renamed', - range: { - start: {line: 11, character: 1}, - end: {line: 11, character: 2} - } - }, - { - newText: 'renamed', - range: { - start: {line: 4, character: 2}, - end: {line: 4, character: 3} - } - }, - { - newText: 'renamed', - range: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} - } - } - ], - [fixtureUri('node16/b.mdx')]: [ - { - newText: 'renamed', - range: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - } - } - ], - [fixtureUri('node16/mixed.mdx')]: [ - { - newText: 'renamed', - range: { - start: {line: 0, character: 9}, - end: {line: 0, character: 10} - } - } - ] - } - }) -}) - test('handle undefined rename request', async () => { const {uri} = await serverHandle.openTextDocument( fixturePath('node16/undefined-props.mdx'), diff --git a/packages/language-server/test/syntax-toggle.test.js b/packages/language-server/test/syntax-toggle.test.js index 7e645762..c99f681b 100644 --- a/packages/language-server/test/syntax-toggle.test.js +++ b/packages/language-server/test/syntax-toggle.test.js @@ -5,16 +5,14 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {createServer, fixtureUri, tsdk} from './utils.js' +import {createServer, fixtureUri} from './utils.js' /** @type {LanguageServerHandle} */ let serverHandle beforeEach(async () => { serverHandle = createServer() - await serverHandle.initialize(fixtureUri('node16'), { - typescript: {enabled: false, tsdk} - }) + await serverHandle.initialize(fixtureUri('node16'), {}) }) afterEach(() => { diff --git a/packages/language-server/test/ts/code-action.test.js b/packages/language-server/test/ts/code-action.test.js new file mode 100644 index 00000000..a5cc78a0 --- /dev/null +++ b/packages/language-server/test/ts/code-action.test.js @@ -0,0 +1,47 @@ +/** + * @fileoverview TypeScript code action tests for MDX files + * + * These tests verify that code actions (like organize imports) + * work correctly in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('organize imports', async () => { + const filePath = fixturePath('node16/organize-imports.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'organizeImports', + arguments: { + scope: { + type: 'file', + args: {file: filePath} + } + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + // Organize imports should return file changes + assert.ok(Array.isArray(res.body), 'Response body should be an array') + } finally { + await server.closeFile(filePath) + } +}) diff --git a/packages/language-server/test/ts/completions.test.js b/packages/language-server/test/ts/completions.test.js new file mode 100644 index 00000000..5179a88e --- /dev/null +++ b/packages/language-server/test/ts/completions.test.js @@ -0,0 +1,81 @@ +/** + * @fileoverview TypeScript completion tests for MDX files + * + * These tests verify that code completion works correctly + * in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('support completion in ESM', async () => { + const filePath = fixturePath('node16/completion.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'completions', + arguments: { + file: filePath, + line: 2, + offset: 1, + includeExternalModuleExports: true + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok(res.body.length > 0, 'Should have completions') + + const booleanCompletion = res.body.find( + (/** @type {{name: string}} */ c) => c.name === 'Boolean' + ) + assert.ok(booleanCompletion, 'Should have Boolean completion') + } finally { + await server.closeFile(filePath) + } +}) + +test('support completion in JSX', async () => { + const filePath = fixturePath('node16/completion.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'completions', + arguments: { + file: filePath, + line: 6, + offset: 3, + includeExternalModuleExports: true + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok(res.body.length > 0, 'Should have completions') + + const booleanCompletion = res.body.find( + (/** @type {{name: string}} */ c) => c.name === 'Boolean' + ) + assert.ok(booleanCompletion, 'Should have Boolean completion') + } finally { + await server.closeFile(filePath) + } +}) diff --git a/packages/language-server/test/ts/definitions.test.js b/packages/language-server/test/ts/definitions.test.js new file mode 100644 index 00000000..e8d41c23 --- /dev/null +++ b/packages/language-server/test/ts/definitions.test.js @@ -0,0 +1,49 @@ +/** + * @fileoverview TypeScript definition tests for MDX files + * + * These tests verify that go-to-definition works correctly + * in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('resolve file-local definitions in ESM', async () => { + const filePath = fixturePath('node16/a.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'definition', + arguments: { + file: filePath, + line: 5, + offset: 3 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok(res.body.length > 0, 'Should have at least one definition') + + const def = res.body[0] + assert.ok(def.file.endsWith('a.mdx'), 'Definition should be in a.mdx') + assert.equal(def.start.line, 2, 'Definition should be on line 2') + } finally { + await server.closeFile(filePath) + } +}) diff --git a/packages/language-server/test/ts/diagnostics.test.js b/packages/language-server/test/ts/diagnostics.test.js new file mode 100644 index 00000000..a2d5bd5e --- /dev/null +++ b/packages/language-server/test/ts/diagnostics.test.js @@ -0,0 +1,44 @@ +/** + * @fileoverview TypeScript diagnostics tests for MDX files + * + * These tests verify that TypeScript type errors are correctly + * reported in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('type errors', async () => { + const filePath = fixturePath('node16/type-errors.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'semanticDiagnosticsSync', + arguments: { + file: filePath + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + // Type errors should be reported + assert.ok(res.body.length > 0, 'Should have type errors') + } finally { + await server.closeFile(filePath) + } +}) diff --git a/packages/language-server/test/ts/hover.test.js b/packages/language-server/test/ts/hover.test.js new file mode 100644 index 00000000..68180955 --- /dev/null +++ b/packages/language-server/test/ts/hover.test.js @@ -0,0 +1,129 @@ +/** + * @fileoverview TypeScript hover tests for MDX files + * + * These tests verify that TypeScript hover information works correctly + * in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('resolve hover in ESM', async () => { + const filePath = fixturePath('node16/a.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'quickinfo', + arguments: { + file: filePath, + line: 5, + offset: 3 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok( + res.body.displayString.includes('function a'), + 'Should show function signature' + ) + } finally { + await server.closeFile(filePath) + } +}) + +test('resolve import hover in JSX expressions', async () => { + const filePath = fixturePath('node16/a.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'quickinfo', + arguments: { + file: filePath, + line: 12, + offset: 2 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok( + res.body.displayString.includes('function a'), + 'Should show function signature' + ) + } finally { + await server.closeFile(filePath) + } +}) + +test('support mdxJsxTextElement', async () => { + const filePath = fixturePath('node16/mdx-jsx-text-element.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'quickinfo', + arguments: { + file: filePath, + line: 4, + offset: 5 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok( + res.body.displayString.includes('Component'), + 'Should show Component' + ) + } finally { + await server.closeFile(filePath) + } +}) + +test('resolve import hover in JSX elements', async () => { + const filePath = fixturePath('node16/a.mdx') + await server.openFile(filePath) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'quickinfo', + arguments: { + file: filePath, + line: 14, + offset: 5 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok( + res.body.displayString.includes('Component'), + 'Should show Component' + ) + } finally { + await server.closeFile(filePath) + } +}) diff --git a/packages/language-server/test/ts/rename.test.js b/packages/language-server/test/ts/rename.test.js new file mode 100644 index 00000000..4bf39f08 --- /dev/null +++ b/packages/language-server/test/ts/rename.test.js @@ -0,0 +1,56 @@ +/** + * @fileoverview TypeScript rename tests for MDX files + * + * These tests verify that rename refactoring works correctly + * in MDX files through the TypeScript plugin. + */ +import assert from 'node:assert/strict' +import {after, before, test} from 'node:test' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' + +/** @type {Awaited>} */ +let server + +before(async () => { + server = await getTsServer() +}) + +after(() => { + server.shutdown() +}) + +test('handle rename request of variable for opened references', async () => { + const filePathA = fixturePath('node16/a.mdx') + const filePathB = fixturePath('node16/b.mdx') + + await server.openFile(filePathA) + await server.openFile(filePathB) + + try { + const res = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: 'rename', + arguments: { + file: filePathA, + line: 5, + offset: 3 + } + }) + + assert.ok(res.success, 'Request should succeed') + assert.ok(res.body, 'Response should have body') + assert.ok(res.body.locs, 'Response should have locations') + assert.ok(res.body.locs.length > 0, 'Should have rename locations') + + // Check that we have locations in multiple files + const files = new Set( + res.body.locs.map((/** @type {{file: string}} */ loc) => loc.file) + ) + assert.ok(files.size > 0, 'Should have locations in at least one file') + } finally { + await server.closeFile(filePathA) + await server.closeFile(filePathB) + } +}) diff --git a/packages/language-server/test/ts/server.js b/packages/language-server/test/ts/server.js new file mode 100644 index 00000000..f73089e4 --- /dev/null +++ b/packages/language-server/test/ts/server.js @@ -0,0 +1,115 @@ +/** + * @fileoverview TypeScript server test utilities for MDX + * + * This module provides utilities for testing TypeScript features in MDX files + * using @typescript/server-harness. It follows the pattern established by + * Vue Language Tools. + * + * @see https://github.com/vuejs/language-tools/blob/master/packages/language-server/tests/server.ts + */ +import {createRequire} from 'node:module' +import path from 'node:path' +import {launchServer} from '@typescript/server-harness' + +const require = createRequire(import.meta.url) + +/** @type {import('@typescript/server-harness').Server | undefined} */ +let tsserver + +/** @type {number} */ +let seq = 1 + +/** + * The path to the fixtures directory. + */ +export const fixturesPath = path.resolve( + new URL('.', import.meta.url).pathname, + '../../../../fixtures' +) + +/** + * Get or create the TypeScript server instance. + * + * @returns {Promise<{ + * tsserver: import('@typescript/server-harness').Server, + * nextSeq: () => number, + * openFile: (filePath: string, content?: string) => Promise, + * closeFile: (filePath: string) => Promise, + * shutdown: () => void + * }>} + */ +export async function getTsServer() { + if (!tsserver) { + const tsserverPath = require.resolve('typescript/lib/tsserver.js') + const pluginPath = require.resolve('@mdx-js/typescript-plugin') + + tsserver = launchServer(tsserverPath, [ + '--disableAutomaticTypingAcquisition', + '--globalPlugins', + pluginPath, + '--suppressDiagnosticEvents' + // Uncomment for debugging: + // '--logVerbosity', 'verbose', + // '--logFile', path.join(fixturesPath, 'tsserver.log'), + ]) + + tsserver.on('exit', (code) => + console.log(code ? `Exited with code ${code}` : 'Terminated') + ) + } + + const server = tsserver + + return { + tsserver: server, + nextSeq: () => seq++, + /** + * Open a file in tsserver. + * + * @param {string} filePath - The absolute path to the file. + * @param {string} [content] - Optional content to use instead of reading from disk. + */ + async openFile(filePath, content) { + /** @type {{file: string, fileContent?: string}} */ + const args = {file: filePath} + if (content !== undefined) { + args.fileContent = content + } + + const res = await server.message({ + seq: seq++, + type: 'request', + command: 'open', + arguments: args + }) + if (!res.success) { + throw new Error(`Failed to open file: ${res.message}`) + } + }, + /** + * Close a file in tsserver. + * + * @param {string} filePath - The absolute path to the file. + */ + async closeFile(filePath) { + const res = await server.message({ + seq: seq++, + type: 'request', + command: 'close', + arguments: {file: filePath} + }) + if (!res.success) { + throw new Error(`Failed to close file: ${res.message}`) + } + }, + /** + * Shutdown the tsserver. + */ + shutdown() { + if (tsserver) { + tsserver.kill() + tsserver = undefined + } + } + } +} diff --git a/packages/language-server/test/utils.js b/packages/language-server/test/utils.js index 5af57b13..044c4ef6 100644 --- a/packages/language-server/test/utils.js +++ b/packages/language-server/test/utils.js @@ -1,10 +1,8 @@ import {createRequire} from 'node:module' -import path from 'node:path' import {URI, Utils} from 'vscode-uri' import {startLanguageServer} from '@volar/test-utils' import pkg from '../package.json' with {type: 'json'} -const require = createRequire(import.meta.url) const pkgPath = new URL('../package.json', import.meta.url) const pkgRequire = createRequire(pkgPath) @@ -15,27 +13,26 @@ const fixturesURI = Utils.joinPath( '../../../../fixtures' ) -/** - * The path to the TypeScript SDK. - */ -export const tsdk = path.dirname(require.resolve('typescript')) - export function createServer() { return startLanguageServer(bin, new URL('..', import.meta.url)) } /** - * @param {string} fileName The name of the fixture to get a fully resolved URI for. - * @returns {string} The uri that matches the fixture file name. + * Get the absolute path for a fixture file. + * + * @param {string} relativePath - The relative path from the fixtures directory. + * @returns {string} The absolute path. */ -export function fixtureUri(fileName) { - return fixturesURI + '/' + fileName +export function fixturePath(relativePath) { + return Utils.joinPath(fixturesURI, relativePath).fsPath } /** - * @param {string} fileName - * @returns {string} + * Get the file URI for a fixture file. + * + * @param {string} relativePath - The relative path from the fixtures directory. + * @returns {string} The file URI. */ -export function fixturePath(fileName) { - return URI.parse(fixtureUri(fileName)).fsPath.replaceAll('\\', '/') +export function fixtureUri(relativePath) { + return Utils.joinPath(fixturesURI, relativePath).toString() } diff --git a/packages/typescript-plugin/lib/index.cjs b/packages/typescript-plugin/lib/index.cjs index 03954edb..0bffde1e 100644 --- a/packages/typescript-plugin/lib/index.cjs +++ b/packages/typescript-plugin/lib/index.cjs @@ -18,7 +18,7 @@ const {default: remarkGfm} = require('remark-gfm') const plugin = createLanguageServicePlugin((ts, info) => { const {getAllProjectErrors} = info.project - // Filter out the message “No inputs were found in config file …” if the + // Filter out the message "No inputs were found in config file …" if the // project contains MDX files. info.project.getAllProjectErrors = () => { const diagnostics = getAllProjectErrors.call(info.project) @@ -33,6 +33,9 @@ const plugin = createLanguageServicePlugin((ts, info) => { return diagnostics } + // Add MDX custom commands for language server communication + addMdxCommands(ts, info) + if (info.project.projectKind !== ts.server.ProjectKind.Configured) { return { languagePlugins: [ @@ -77,4 +80,48 @@ const plugin = createLanguageServicePlugin((ts, info) => { } }) +/** + * Add MDX custom commands for language server communication + * @param {typeof import('typescript')} ts + * @param {import('typescript').server.PluginCreateInfo} info + */ +function addMdxCommands(ts, info) { + const {projectService} = info.project + projectService.logger.info( + 'MDX: called handler processing ' + info.project.projectKind + ) + + if (!info.session) { + projectService.logger.info('MDX: there is no session in info.') + return + } + + const {session} = info + + if (!(/** @type {Function | undefined} */ (session.addProtocolHandler))) { + projectService.logger.info('MDX: there is no addProtocolHandler method.') + return + } + + /** @type {Map import('typescript').server.HandlerResponse> | undefined} */ + // @ts-ignore - handlers is a private property + const {handlers} = session + + if (!handlers || handlers.has('_mdx:projectInfo')) { + return + } + + const projectInfoHandler = handlers.get('projectInfo') + if (!projectInfoHandler) { + return + } + + // Forward projectInfo request to get tsconfig path for a file + session.addProtocolHandler('_mdx:projectInfo', (request) => + projectInfoHandler(request) + ) + + projectService.logger.info('MDX: registered custom commands') +} + module.exports = plugin diff --git a/packages/vscode-mdx/script/build.mjs b/packages/vscode-mdx/script/build.mjs index 2116fd07..4f233bd8 100755 --- a/packages/vscode-mdx/script/build.mjs +++ b/packages/vscode-mdx/script/build.mjs @@ -13,9 +13,8 @@ await build({ entryPoints: { 'out/extension': require.resolve('../src/extension.js'), 'out/language-server': require.resolve('@mdx-js/language-server'), - 'node_modules/@mdx-js/typescript-plugin': require.resolve( - '../../typescript-plugin/lib/index.cjs' - ) + 'node_modules/@mdx-js/typescript-plugin': + require.resolve('../../typescript-plugin/lib/index.cjs') }, external: ['vscode'], logLevel: 'info', diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js index c06f7037..d237723d 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -7,10 +7,10 @@ import * as languageServerProtocol from '@volar/language-server/protocol.js' import { activateAutoInsertion, activateDocumentDropEdit, - createLabsInfo, - getTsdk + createLabsInfo } from '@volar/vscode' import { + commands, extensions, window, workspace, @@ -40,9 +40,10 @@ let disposable * extension. */ export async function activate(context) { - extensions.getExtension('vscode.typescript-language-features')?.activate() - - const {tsdk} = (await getTsdk(context)) ?? {tsdk: ''} + const tsExtension = extensions.getExtension( + 'vscode.typescript-language-features' + ) + await tsExtension?.activate() client = new LanguageClient( 'MDX', @@ -52,9 +53,6 @@ export async function activate(context) { }, { documentSelector: [{language: 'mdx'}], - initializationOptions: { - typescript: {tsdk} - }, markdown: { isTrusted: true, supportHtml: true @@ -116,13 +114,45 @@ async function startServer() { disposable = Disposable.from( activateAutoInsertion('mdx', client), - activateDocumentDropEdit('mdx', client) + activateDocumentDropEdit('mdx', client), + activateTsServerBridge() ) } ) } } +/** + * Activate the TypeScript server bridge for language server communication. + * + * @returns {Disposable} + * A disposable to clean up the bridge. + */ +function activateTsServerBridge() { + // Forward tsserver requests from language server to TypeScript extension + const requestDisposable = client.onNotification( + 'tsserver/request', + /** + * @param {[number, string, unknown]} params + */ + async ([id, command, args]) => { + try { + /** @type {{body?: unknown} | undefined} */ + const response = await commands.executeCommand( + 'typescript.tsserverRequest', + command, + args + ) + client.sendNotification('tsserver/response', [id, response?.body]) + } catch { + client.sendNotification('tsserver/response', [id, null]) + } + } + ) + + return Disposable.from(requestDisposable) +} + /** * Execute a command with correct arguments. * diff --git a/tsconfig.base.json b/tsconfig.base.json index e42ac339..4c0a3deb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,6 +9,7 @@ "moduleDetection": "force", "strict": true, "stripInternal": true, + "skipLibCheck": true, "target": "es2022", "types": ["node"] }