From d8a93ce0995ae88a6d8756e941c4548363f76161 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 21 Jun 2025 06:37:34 +0800 Subject: [PATCH 01/11] Remove TypeScript moving parts --- packages/language-server/README.md | 11 --- packages/language-server/lib/index.js | 115 ++------------------------ packages/language-server/package.json | 3 + packages/vscode-mdx/src/extension.js | 8 +- 4 files changed, 12 insertions(+), 125 deletions(-) diff --git a/packages/language-server/README.md b/packages/language-server/README.md index 7c28effc..4ccf9973 100644 --- a/packages/language-server/README.md +++ b/packages/language-server/README.md @@ -127,17 +127,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..19c997f2 100755 --- a/packages/language-server/lib/index.js +++ b/packages/language-server/lib/index.js @@ -1,29 +1,23 @@ #!/usr/bin/env node /** - * @import {VirtualCodePlugin} from '@mdx-js/language-service' * @import {PluggableList} from 'unified' */ -import assert from 'node:assert' -import {createRequire} from 'node:module' -import path from 'node:path' import process from 'node:process' import { createMdxLanguagePlugin, createMdxServicePlugin, - resolvePlugins } from '@mdx-js/language-service' import { createConnection, createServer, - createTypeScriptProject, - loadTsdkByPath + createSimpleProject } from '@volar/language-server/node.js' 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 +26,23 @@ 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' - ) - - const {typescript, diagnosticMessages} = loadTsdkByPath( - tsdk, - parameters.locale - ) - return server.initialize( parameters, - createTypeScriptProject( - typescript, - diagnosticMessages, - ({configFileName}) => ({ - languagePlugins: getLanguagePlugins(configFileName) - }) - ), - getLanguageServicePlugins() - ) - - function getLanguageServicePlugins() { - const plugins = [ + createSimpleProject([createMdxLanguagePlugin(defaultPlugins)]), + [ createMarkdownServicePlugin({ getDiagnosticOptions(document, context) { return context.env.getConfiguration?.('mdx.validate') } }), - createMdxServicePlugin(connection.workspace) + createMdxServicePlugin(connection.workspace), + createTypeScriptSyntacticServicePlugin(typescript) ] - - if (tsEnabled) { - plugins.push(...createTypeScriptServicePlugin(typescript, {})) - } else { - plugins.push(createTypeScriptSyntacticServicePlugin(typescript)) - } - - return plugins - } - - /** - * @param {string | undefined} tsconfig - */ - 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 - } - - return [ - createMdxLanguagePlugin( - remarkPlugins || defaultPlugins, - virtualCodePlugins, - checkMdx, - jsxImportSource - ) - ] - } + ) }) -connection.onInitialized(() => { - const extensions = ['mdx'] - if (tsEnabled) { - extensions.push( - 'cjs', - 'cts', - 'js', - 'jsx', - 'json', - 'mjs', - 'mts', - 'ts', - 'tsx' - ) - } - - server.initialized() - server.fileWatcher.watchFiles([`**/*.{${extensions.join(',')}}`]) -}) +connection.onInitialized(server.initialized) connection.listen() diff --git a/packages/language-server/package.json b/packages/language-server/package.json index e6e15370..f15f43a7 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -38,6 +38,9 @@ "volar-service-markdown": "0.0.64", "volar-service-typescript": "0.0.64" }, + "peerDependencies": { + "typescript": "*" + }, "devDependencies": { "@types/node": "^22.0.0", "@volar/test-utils": "~2.4.0", diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js index c06f7037..34168b3c 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -7,8 +7,7 @@ import * as languageServerProtocol from '@volar/language-server/protocol.js' import { activateAutoInsertion, activateDocumentDropEdit, - createLabsInfo, - getTsdk + createLabsInfo } from '@volar/vscode' import { extensions, @@ -42,8 +41,6 @@ let disposable export async function activate(context) { extensions.getExtension('vscode.typescript-language-features')?.activate() - const {tsdk} = (await getTsdk(context)) ?? {tsdk: ''} - client = new LanguageClient( 'MDX', { @@ -52,9 +49,6 @@ export async function activate(context) { }, { documentSelector: [{language: 'mdx'}], - initializationOptions: { - typescript: {tsdk} - }, markdown: { isTrusted: true, supportHtml: true From bcbd2f7baac0a6861f7b9f99712c6bd94825811b Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:43:15 -0500 Subject: [PATCH 02/11] test: update language server tests for tsdk-free changes - Remove TypeScript-dependent tests from language server tests - Tests for TypeScript features (hover, definitions, completions, etc.) should be tested through the TypeScript plugin instead - Update expected capabilities in initialize test - Update test command to only run test/*.test.js - Keep basic language server functionality tests --- packages/language-server/README.md | 1 - packages/language-server/lib/index.js | 8 +- packages/language-server/package.json | 3 +- .../language-server/test/code-action.test.js | 81 ++-------- .../language-server/test/completion.test.js | 142 ++--------------- .../language-server/test/definitions.test.js | 100 +----------- .../language-server/test/diagnostics.test.js | 142 +---------------- .../test/document-link.test.js | 6 +- .../test/document-symbols.test.js | 6 +- .../test/folding-ranges.test.js | 6 +- packages/language-server/test/hover.test.js | 148 +----------------- .../language-server/test/initialize.test.js | 59 ++----- .../language-server/test/no-tsconfig.test.js | 6 +- .../test/prepare-rename.test.js | 28 +--- packages/language-server/test/rename.test.js | 71 +-------- .../test/syntax-toggle.test.js | 6 +- packages/vscode-mdx/script/build.mjs | 5 +- 17 files changed, 99 insertions(+), 719 deletions(-) diff --git a/packages/language-server/README.md b/packages/language-server/README.md index 4ccf9973..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) diff --git a/packages/language-server/lib/index.js b/packages/language-server/lib/index.js index 19c997f2..64cf7a9e 100755 --- a/packages/language-server/lib/index.js +++ b/packages/language-server/lib/index.js @@ -7,7 +7,7 @@ import process from 'node:process' import { createMdxLanguagePlugin, - createMdxServicePlugin, + createMdxServicePlugin } from '@mdx-js/language-service' import { createConnection, @@ -27,8 +27,8 @@ const defaultPlugins = [[remarkFrontmatter, ['toml', 'yaml']], remarkGfm] const connection = createConnection() const server = createServer(connection) -connection.onInitialize(async (parameters) => { - return server.initialize( +connection.onInitialize(async (parameters) => + server.initialize( parameters, createSimpleProject([createMdxLanguagePlugin(defaultPlugins)]), [ @@ -41,7 +41,7 @@ connection.onInitialize(async (parameters) => { createTypeScriptSyntacticServicePlugin(typescript) ] ) -}) +) connection.onInitialized(server.initialized) diff --git a/packages/language-server/package.json b/packages/language-server/package.json index a61a105e..98cea6be 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -27,7 +27,7 @@ "unified" ], "scripts": { - "test-api": "node --test", + "test-api": "node --test 'test/*.test.js'", "test": "npm run test-api" }, "dependencies": { @@ -43,6 +43,7 @@ }, "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..019b37d0 100644 --- a/packages/language-server/test/code-action.test.js +++ b/packages/language-server/test/code-action.test.js @@ -1,36 +1,37 @@ /** + * @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: [], @@ -39,59 +40,5 @@ test('organize imports', async () => { } ) - 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..fe40f64c 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'), diff --git a/packages/language-server/test/diagnostics.test.js b/packages/language-server/test/diagnostics.test.js index e39b11e1..a7669e2d 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(() => { @@ -68,84 +72,6 @@ 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'), @@ -158,55 +84,3 @@ test('does not resolve shadow content', async () => { 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/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', From 4b75fe97057c70b8c0ae8dfea1d64182c5ceee0c Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:10:42 -0500 Subject: [PATCH 03/11] test: migrate TypeScript tests to @typescript/server-harness - Add new test directory test/ts/ for TypeScript plugin tests - Tests use @typescript/server-harness to test TypeScript features - Migrate hover, definitions, completions, rename, diagnostics, code-action tests - Update language server tests to remove TypeScript-dependent assertions - Keep basic language server functionality tests - Add test-ts script to run TypeScript plugin tests separately --- packages/language-server/package.json | 3 +- .../language-server/test/code-action.test.js | 2 - .../language-server/test/definitions.test.js | 2 - .../language-server/test/diagnostics.test.js | 2 - .../test/ts/code-action.test.js | 46 ++++++ .../test/ts/completions.test.js | 80 +++++++++++ .../test/ts/definitions.test.js | 48 +++++++ .../test/ts/diagnostics.test.js | 43 ++++++ .../language-server/test/ts/hover.test.js | 128 +++++++++++++++++ .../language-server/test/ts/rename.test.js | 55 +++++++ packages/language-server/test/ts/server.js | 136 ++++++++++++++++++ packages/language-server/test/utils.js | 7 - 12 files changed, 538 insertions(+), 14 deletions(-) create mode 100644 packages/language-server/test/ts/code-action.test.js create mode 100644 packages/language-server/test/ts/completions.test.js create mode 100644 packages/language-server/test/ts/definitions.test.js create mode 100644 packages/language-server/test/ts/diagnostics.test.js create mode 100644 packages/language-server/test/ts/hover.test.js create mode 100644 packages/language-server/test/ts/rename.test.js create mode 100644 packages/language-server/test/ts/server.js diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 98cea6be..088bbd39 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -28,7 +28,8 @@ ], "scripts": { "test-api": "node --test 'test/*.test.js'", - "test": "npm run test-api" + "test-ts": "node --test 'test/ts/*.test.js'", + "test": "npm run test-api && npm run test-ts" }, "dependencies": { "@mdx-js/language-service": "0.7.2", diff --git a/packages/language-server/test/code-action.test.js b/packages/language-server/test/code-action.test.js index 019b37d0..f2ba907b 100644 --- a/packages/language-server/test/code-action.test.js +++ b/packages/language-server/test/code-action.test.js @@ -26,7 +26,6 @@ afterEach(() => { test('return empty code actions for non-existent file', async () => { const uri = fixtureUri('node16/non-existent.mdx') - const codeActions = await serverHandle.sendCodeActionsRequest( uri, { @@ -39,6 +38,5 @@ test('return empty code actions for non-existent file', async () => { triggerKind: CodeActionTriggerKind.Invoked } ) - assert.deepEqual(codeActions, []) }) diff --git a/packages/language-server/test/definitions.test.js b/packages/language-server/test/definitions.test.js index fe40f64c..2177f07a 100644 --- a/packages/language-server/test/definitions.test.js +++ b/packages/language-server/test/definitions.test.js @@ -42,7 +42,6 @@ test('does not resolve shadow content', async () => { line: 0, character: 37 }) - assert.deepEqual(result, []) }) @@ -52,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 a7669e2d..e3623013 100644 --- a/packages/language-server/test/diagnostics.test.js +++ b/packages/language-server/test/diagnostics.test.js @@ -30,7 +30,6 @@ test('parse errors', async () => { 'mdx' ) const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - assert.deepEqual(diagnostics, { kind: 'full', items: [ @@ -78,7 +77,6 @@ test('does not resolve shadow content', async () => { 'mdx' ) const diagnostics = await serverHandle.sendDocumentDiagnosticRequest(uri) - assert.deepEqual(diagnostics, { items: [], kind: 'full' 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..febcc04f --- /dev/null +++ b/packages/language-server/test/ts/code-action.test.js @@ -0,0 +1,46 @@ +/** + * @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, 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..e62191f7 --- /dev/null +++ b/packages/language-server/test/ts/completions.test.js @@ -0,0 +1,80 @@ +/** + * @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, 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..b3e84e94 --- /dev/null +++ b/packages/language-server/test/ts/definitions.test.js @@ -0,0 +1,48 @@ +/** + * @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, 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..289df6ff --- /dev/null +++ b/packages/language-server/test/ts/diagnostics.test.js @@ -0,0 +1,43 @@ +/** + * @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, 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..0c74916a --- /dev/null +++ b/packages/language-server/test/ts/hover.test.js @@ -0,0 +1,128 @@ +/** + * @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, 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..5c237084 --- /dev/null +++ b/packages/language-server/test/ts/rename.test.js @@ -0,0 +1,55 @@ +/** + * @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, 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..98140aa4 --- /dev/null +++ b/packages/language-server/test/ts/server.js @@ -0,0 +1,136 @@ +/** + * @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' +import {URI} from 'vscode-uri' + +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 + } + } + } +} + +/** + * 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 fixturePath(relativePath) { + return path.join(fixturesPath, relativePath) +} + +/** + * 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 fixtureUri(relativePath) { + return URI.file(fixturePath(relativePath)).toString() +} diff --git a/packages/language-server/test/utils.js b/packages/language-server/test/utils.js index 5af57b13..3eab11b7 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,11 +13,6 @@ 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)) } From 917545b656b9d6477d0ef582aa3d00f7e11d1a9d Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:51:57 -0500 Subject: [PATCH 04/11] feat: add tsserver bridge for reading MDX config from tsconfig - Language server now communicates with TypeScript plugin via tsserver - TypeScript plugin registers _mdx:projectInfo command for getting tsconfig path - VSCode extension forwards tsserver requests between language server and TS - Language server reads MDX config (remarkPlugins, etc.) from tsconfig.json - Falls back to finding tsconfig manually when tsserver bridge is unavailable - Add @volar/language-core and @volar/language-service dependencies --- packages/language-server/lib/index.js | 279 +++++++++++++++++++++-- packages/language-server/package.json | 2 + packages/typescript-plugin/lib/index.cjs | 49 +++- packages/vscode-mdx/src/extension.js | 46 +++- 4 files changed, 353 insertions(+), 23 deletions(-) diff --git a/packages/language-server/lib/index.js b/packages/language-server/lib/index.js index 64cf7a9e..0e6e5e87 100755 --- a/packages/language-server/lib/index.js +++ b/packages/language-server/lib/index.js @@ -1,19 +1,23 @@ #!/usr/bin/env node /** + * @import {LanguageService} from '@volar/language-service' * @import {PluggableList} from 'unified' + * @import {URI} from 'vscode-uri' */ +import {createRequire} from 'node:module' +import path from 'node:path' import process from 'node:process' import { createMdxLanguagePlugin, - createMdxServicePlugin + createMdxServicePlugin, + resolvePlugins } from '@mdx-js/language-service' -import { - createConnection, - createServer, - createSimpleProject -} 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' @@ -27,22 +31,257 @@ const defaultPlugins = [[remarkFrontmatter, ['toml', 'yaml']], remarkGfm] const connection = createConnection() const server = createServer(connection) -connection.onInitialize(async (parameters) => - server.initialize( +/** @type {Map void>} */ +const tsserverRequestHandlers = new Map() +let tsserverRequestId = 0 +let tsserverBridgeAvailable = false + +// 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) + } + } +) + +/** + * 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]) + + // Short timeout - if tsserver bridge is not available, fall back quickly + setTimeout( + () => { + if (tsserverRequestHandlers.has(requestId)) { + tsserverRequestHandlers.delete(requestId) + resolve(null) + } + }, + tsserverBridgeAvailable ? 5000 : 100 + ) + }) +} + +/** + * 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 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 + } + } + + languagePlugin ||= createMdxLanguagePlugin(defaultPlugins) + + /** @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, - createSimpleProject([createMdxLanguagePlugin(defaultPlugins)]), - [ - createMarkdownServicePlugin({ - getDiagnosticOptions(document, context) { - return context.env.getConfiguration?.('mdx.validate') - } - }), - createMdxServicePlugin(connection.workspace), - createTypeScriptSyntacticServicePlugin(typescript) - ] + { + 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() + + // 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() + } -connection.onInitialized(server.initialized) + tsconfigProjects.clear() + file2ConfigPath.clear() + break + } + } + }) +}) connection.listen() diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 088bbd39..664637ad 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -33,7 +33,9 @@ }, "dependencies": { "@mdx-js/language-service": "0.7.2", + "@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", 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/src/extension.js b/packages/vscode-mdx/src/extension.js index 34168b3c..dffe4e6d 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -39,7 +39,10 @@ let disposable * extension. */ export async function activate(context) { - extensions.getExtension('vscode.typescript-language-features')?.activate() + const tsExtension = extensions.getExtension( + 'vscode.typescript-language-features' + ) + await tsExtension?.activate() client = new LanguageClient( 'MDX', @@ -110,13 +113,52 @@ 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() { + const tsExtension = extensions.getExtension( + 'vscode.typescript-language-features' + ) + if (!tsExtension) { + return Disposable.from() + } + + const api = tsExtension.exports?.getAPI?.(0) + if (!api) { + return Disposable.from() + } + + // 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 { + const response = await api.executeCommand(command, args) + client.sendNotification('tsserver/response', [id, response]) + } catch { + client.sendNotification('tsserver/response', [id, null]) + } + } + ) + + return Disposable.from(requestDisposable) +} + /** * Execute a command with correct arguments. * From c0527fdaed1eb6f4b4c9687231926fd06586c816 Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:05:55 -0500 Subject: [PATCH 05/11] feat: patch TypeScript extension to support MDX files Based on Vue Language Tools' implementation, this patch modifies the TypeScript extension's internal code to: 1. Include 'mdx' in the list of supported language modes (jsTsLanguageModes, isSupportedLanguageMode, isTypeScriptDocument) 2. Ensure the MDX TypeScript plugin is loaded with high priority This enables full TypeScript support for MDX files through the TS plugin, including hover, definitions, completions, rename, and diagnostics. If the TypeScript extension is already active when MDX extension loads, the user will be prompted to restart the extension host. Reference: https://github.com/vuejs/language-tools/blob/master/extensions/vscode/src/extension.ts#L286-L365 --- packages/vscode-mdx/src/extension.js | 139 ++++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 14 deletions(-) diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js index dffe4e6d..b7e7d0d4 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -3,6 +3,9 @@ * @import {ExtensionContext} from 'vscode' */ +/* eslint-disable unicorn/prefer-module, no-import-assign */ + +import * as fs from 'node:fs' import * as languageServerProtocol from '@volar/language-server/protocol.js' import { activateAutoInsertion, @@ -10,6 +13,7 @@ import { createLabsInfo } from '@volar/vscode' import { + commands, extensions, window, workspace, @@ -28,6 +32,9 @@ let client */ let disposable +// Patch TypeScript extension before activation +const neededRestart = !patchTypeScriptExtension() + /** * Activate the extension. * @@ -44,6 +51,20 @@ export async function activate(context) { ) await tsExtension?.activate() + // Prompt user to restart if patching failed because TS extension was already active + if (neededRestart) { + const action = await window.showInformationMessage( + 'Please restart the extension host to activate MDX TypeScript support.', + 'Restart Extension Host', + 'Reload Window' + ) + if (action === 'Restart Extension Host') { + commands.executeCommand('workbench.action.restartExtensionHost') + } else if (action === 'Reload Window') { + commands.executeCommand('workbench.action.reloadWindow') + } + } + client = new LanguageClient( 'MDX', { @@ -128,18 +149,6 @@ async function startServer() { * A disposable to clean up the bridge. */ function activateTsServerBridge() { - const tsExtension = extensions.getExtension( - 'vscode.typescript-language-features' - ) - if (!tsExtension) { - return Disposable.from() - } - - const api = tsExtension.exports?.getAPI?.(0) - if (!api) { - return Disposable.from() - } - // Forward tsserver requests from language server to TypeScript extension const requestDisposable = client.onNotification( 'tsserver/request', @@ -148,8 +157,13 @@ function activateTsServerBridge() { */ async ([id, command, args]) => { try { - const response = await api.executeCommand(command, args) - client.sendNotification('tsserver/response', [id, response]) + /** @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]) } @@ -159,6 +173,103 @@ function activateTsServerBridge() { return Disposable.from(requestDisposable) } +/** + * Patch the TypeScript extension to support MDX files. + * + * This hack modifies the TypeScript extension's internal code to: + * 1. Include 'mdx' in the list of supported language modes + * 2. Ensure the MDX TypeScript plugin is loaded with high priority + * + * This approach is based on Vue Language Tools' implementation: + * https://github.com/vuejs/language-tools/blob/master/extensions/vscode/src/extension.ts + * + * @returns {boolean} + * Whether the patch was successful. Returns false if the TypeScript + * extension was already active (requires restart). + */ +function patchTypeScriptExtension() { + const tsExtension = extensions.getExtension( + 'vscode.typescript-language-features' + ) + if (!tsExtension) { + return true // No TS extension, nothing to patch + } + + if (tsExtension.isActive) { + return false // TS extension already active, needs restart + } + + const {readFileSync} = fs + /** @type {string} */ + let extensionJsPath + try { + extensionJsPath = require.resolve('./dist/extension.js', { + paths: [tsExtension.extensionPath] + }) + } catch { + return true // Could not find extension.js, skip patching + } + + const mdxExtension = extensions.getExtension('unifiedjs.vscode-mdx') + if (!mdxExtension) { + return true // MDX extension not found + } + + const tsPluginName = '@mdx-js/typescript-plugin' + + // Patch fs.readFileSync to modify TypeScript extension's code + // @ts-expect-error - overriding fs.readFileSync + fs.readFileSync = ( + /** @type {fs.PathOrFileDescriptor} */ filePath, + /** @type {any} */ options + ) => { + if (filePath === extensionJsPath) { + let text = String(readFileSync(filePath, options)) + + // Patch jsTsLanguageModes to include 'mdx' + text = text.replace( + 't.jsTsLanguageModes=[t.javascript,t.javascriptreact,t.typescript,t.typescriptreact]', + (s) => s + '.concat("mdx")' + ) + + // Patch isSupportedLanguageMode to include 'mdx' + text = text.replace( + '.languages.match([t.typescript,t.typescriptreact,t.javascript,t.javascriptreact]', + (s) => s + '.concat("mdx")' + ) + + // Patch isTypeScriptDocument to include 'mdx' + text = text.replace( + '.languages.match([t.typescript,t.typescriptreact]', + (s) => s + '.concat("mdx")' + ) + + // Sort plugins to ensure MDX plugin has high priority + // This is needed for compatibility with other TS plugins + text = text.replace( + '"--globalPlugins",i.plugins', + (s) => + s + + `.sort((a,b)=>(b.name==="${tsPluginName}"?-1:0)-(a.name==="${tsPluginName}"?-1:0))` + ) + + return text + } + + return readFileSync(filePath, options) + } + + // Clear require cache and reload the patched module + const loadedModule = require.cache[extensionJsPath] + if (loadedModule) { + delete require.cache[extensionJsPath] + const patchedModule = require(extensionJsPath) + Object.assign(loadedModule.exports, patchedModule) + } + + return true +} + /** * Execute a command with correct arguments. * From 10543a10a374eecc163abb31e2d7c1e2b0dd3180 Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:20:01 -0500 Subject: [PATCH 06/11] fix: enable file watcher for MDX files Re-enable the file watcher to watch MDX files, which is needed for the markdown language service to properly track file changes. --- packages/language-server/lib/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/language-server/lib/index.js b/packages/language-server/lib/index.js index 0e6e5e87..b0c24ecc 100755 --- a/packages/language-server/lib/index.js +++ b/packages/language-server/lib/index.js @@ -267,9 +267,10 @@ connection.onInitialize(async (parameters) => { connection.onInitialized(() => { server.initialized() + server.fileWatcher.watchFiles(['**/*.mdx']) // Clear caches when tsconfig changes - server.fileWatcher?.onDidChangeWatchedFiles(({changes}) => { + server.fileWatcher.onDidChangeWatchedFiles(({changes}) => { for (const change of changes) { if (change.uri.endsWith('tsconfig.json')) { for (const service of tsconfigProjects.values()) { From 995a795ad1318f0957263038607808f81ad0af49 Mon Sep 17 00:00:00 2001 From: johnsoncodehk <16279759+johnsoncodehk@users.noreply.github.com> Date: Sat, 31 Jan 2026 06:06:22 -0500 Subject: [PATCH 07/11] refactor: remove patchTypeScriptExtension Remove the TypeScript extension patching code as it should be done in a separate PR. This keeps the current PR focused on the tsdk-free changes. --- packages/vscode-mdx/src/extension.js | 117 --------------------------- 1 file changed, 117 deletions(-) diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js index b7e7d0d4..d237723d 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -3,9 +3,6 @@ * @import {ExtensionContext} from 'vscode' */ -/* eslint-disable unicorn/prefer-module, no-import-assign */ - -import * as fs from 'node:fs' import * as languageServerProtocol from '@volar/language-server/protocol.js' import { activateAutoInsertion, @@ -32,9 +29,6 @@ let client */ let disposable -// Patch TypeScript extension before activation -const neededRestart = !patchTypeScriptExtension() - /** * Activate the extension. * @@ -51,20 +45,6 @@ export async function activate(context) { ) await tsExtension?.activate() - // Prompt user to restart if patching failed because TS extension was already active - if (neededRestart) { - const action = await window.showInformationMessage( - 'Please restart the extension host to activate MDX TypeScript support.', - 'Restart Extension Host', - 'Reload Window' - ) - if (action === 'Restart Extension Host') { - commands.executeCommand('workbench.action.restartExtensionHost') - } else if (action === 'Reload Window') { - commands.executeCommand('workbench.action.reloadWindow') - } - } - client = new LanguageClient( 'MDX', { @@ -173,103 +153,6 @@ function activateTsServerBridge() { return Disposable.from(requestDisposable) } -/** - * Patch the TypeScript extension to support MDX files. - * - * This hack modifies the TypeScript extension's internal code to: - * 1. Include 'mdx' in the list of supported language modes - * 2. Ensure the MDX TypeScript plugin is loaded with high priority - * - * This approach is based on Vue Language Tools' implementation: - * https://github.com/vuejs/language-tools/blob/master/extensions/vscode/src/extension.ts - * - * @returns {boolean} - * Whether the patch was successful. Returns false if the TypeScript - * extension was already active (requires restart). - */ -function patchTypeScriptExtension() { - const tsExtension = extensions.getExtension( - 'vscode.typescript-language-features' - ) - if (!tsExtension) { - return true // No TS extension, nothing to patch - } - - if (tsExtension.isActive) { - return false // TS extension already active, needs restart - } - - const {readFileSync} = fs - /** @type {string} */ - let extensionJsPath - try { - extensionJsPath = require.resolve('./dist/extension.js', { - paths: [tsExtension.extensionPath] - }) - } catch { - return true // Could not find extension.js, skip patching - } - - const mdxExtension = extensions.getExtension('unifiedjs.vscode-mdx') - if (!mdxExtension) { - return true // MDX extension not found - } - - const tsPluginName = '@mdx-js/typescript-plugin' - - // Patch fs.readFileSync to modify TypeScript extension's code - // @ts-expect-error - overriding fs.readFileSync - fs.readFileSync = ( - /** @type {fs.PathOrFileDescriptor} */ filePath, - /** @type {any} */ options - ) => { - if (filePath === extensionJsPath) { - let text = String(readFileSync(filePath, options)) - - // Patch jsTsLanguageModes to include 'mdx' - text = text.replace( - 't.jsTsLanguageModes=[t.javascript,t.javascriptreact,t.typescript,t.typescriptreact]', - (s) => s + '.concat("mdx")' - ) - - // Patch isSupportedLanguageMode to include 'mdx' - text = text.replace( - '.languages.match([t.typescript,t.typescriptreact,t.javascript,t.javascriptreact]', - (s) => s + '.concat("mdx")' - ) - - // Patch isTypeScriptDocument to include 'mdx' - text = text.replace( - '.languages.match([t.typescript,t.typescriptreact]', - (s) => s + '.concat("mdx")' - ) - - // Sort plugins to ensure MDX plugin has high priority - // This is needed for compatibility with other TS plugins - text = text.replace( - '"--globalPlugins",i.plugins', - (s) => - s + - `.sort((a,b)=>(b.name==="${tsPluginName}"?-1:0)-(a.name==="${tsPluginName}"?-1:0))` - ) - - return text - } - - return readFileSync(filePath, options) - } - - // Clear require cache and reload the patched module - const loadedModule = require.cache[extensionJsPath] - if (loadedModule) { - delete require.cache[extensionJsPath] - const patchedModule = require(extensionJsPath) - Object.assign(loadedModule.exports, patchedModule) - } - - return true -} - /** * Execute a command with correct arguments. * From 0baa80b216cad18f36ee357df8821a6ea7c4660a Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 5 Mar 2026 12:29:55 +0800 Subject: [PATCH 08/11] Update tsconfig.base.json --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) 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"] } From 10601740f419f58998697af1d11222d66963c7bc Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 5 Mar 2026 12:29:56 +0800 Subject: [PATCH 09/11] Update server.js --- packages/language-server/test/ts/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-server/test/ts/server.js b/packages/language-server/test/ts/server.js index 98140aa4..344cd3e1 100644 --- a/packages/language-server/test/ts/server.js +++ b/packages/language-server/test/ts/server.js @@ -122,7 +122,7 @@ export async function getTsServer() { * @returns {string} The absolute path. */ export function fixturePath(relativePath) { - return path.join(fixturesPath, relativePath) + return path.join(fixturesPath, relativePath).replaceAll('\\', '/') } /** From 54cdb81533d77517655b79d95ff571d84ab61d3a Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 5 Mar 2026 13:51:58 +0800 Subject: [PATCH 10/11] Revert "Update server.js" This reverts commit 10601740f419f58998697af1d11222d66963c7bc. --- packages/language-server/test/ts/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-server/test/ts/server.js b/packages/language-server/test/ts/server.js index 344cd3e1..98140aa4 100644 --- a/packages/language-server/test/ts/server.js +++ b/packages/language-server/test/ts/server.js @@ -122,7 +122,7 @@ export async function getTsServer() { * @returns {string} The absolute path. */ export function fixturePath(relativePath) { - return path.join(fixturesPath, relativePath).replaceAll('\\', '/') + return path.join(fixturesPath, relativePath) } /** From cf57c221ff1e261700e25943b1bf899791c0ce09 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 5 Mar 2026 14:00:49 +0800 Subject: [PATCH 11/11] Use URI.fsPath --- .../test/ts/code-action.test.js | 3 ++- .../test/ts/completions.test.js | 3 ++- .../test/ts/definitions.test.js | 3 ++- .../test/ts/diagnostics.test.js | 3 ++- .../language-server/test/ts/hover.test.js | 3 ++- .../language-server/test/ts/rename.test.js | 3 ++- packages/language-server/test/ts/server.js | 21 ------------------- packages/language-server/test/utils.js | 20 +++++++++++------- 8 files changed, 24 insertions(+), 35 deletions(-) diff --git a/packages/language-server/test/ts/code-action.test.js b/packages/language-server/test/ts/code-action.test.js index febcc04f..a5cc78a0 100644 --- a/packages/language-server/test/ts/code-action.test.js +++ b/packages/language-server/test/ts/code-action.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/completions.test.js b/packages/language-server/test/ts/completions.test.js index e62191f7..5179a88e 100644 --- a/packages/language-server/test/ts/completions.test.js +++ b/packages/language-server/test/ts/completions.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/definitions.test.js b/packages/language-server/test/ts/definitions.test.js index b3e84e94..e8d41c23 100644 --- a/packages/language-server/test/ts/definitions.test.js +++ b/packages/language-server/test/ts/definitions.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/diagnostics.test.js b/packages/language-server/test/ts/diagnostics.test.js index 289df6ff..a2d5bd5e 100644 --- a/packages/language-server/test/ts/diagnostics.test.js +++ b/packages/language-server/test/ts/diagnostics.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/hover.test.js b/packages/language-server/test/ts/hover.test.js index 0c74916a..68180955 100644 --- a/packages/language-server/test/ts/hover.test.js +++ b/packages/language-server/test/ts/hover.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/rename.test.js b/packages/language-server/test/ts/rename.test.js index 5c237084..4bf39f08 100644 --- a/packages/language-server/test/ts/rename.test.js +++ b/packages/language-server/test/ts/rename.test.js @@ -6,7 +6,8 @@ */ import assert from 'node:assert/strict' import {after, before, test} from 'node:test' -import {fixturePath, getTsServer} from './server.js' +import {fixturePath} from '../utils.js' +import {getTsServer} from './server.js' /** @type {Awaited>} */ let server diff --git a/packages/language-server/test/ts/server.js b/packages/language-server/test/ts/server.js index 98140aa4..f73089e4 100644 --- a/packages/language-server/test/ts/server.js +++ b/packages/language-server/test/ts/server.js @@ -10,7 +10,6 @@ import {createRequire} from 'node:module' import path from 'node:path' import {launchServer} from '@typescript/server-harness' -import {URI} from 'vscode-uri' const require = createRequire(import.meta.url) @@ -114,23 +113,3 @@ export async function getTsServer() { } } } - -/** - * 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 fixturePath(relativePath) { - return path.join(fixturesPath, relativePath) -} - -/** - * 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 fixtureUri(relativePath) { - return URI.file(fixturePath(relativePath)).toString() -} diff --git a/packages/language-server/test/utils.js b/packages/language-server/test/utils.js index 3eab11b7..044c4ef6 100644 --- a/packages/language-server/test/utils.js +++ b/packages/language-server/test/utils.js @@ -18,17 +18,21 @@ export function createServer() { } /** - * @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() }