From e2363685110d7e0fe6f819ad43303f6bcab7e592 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 23 Jul 2023 14:21:27 +0200 Subject: [PATCH] Integrate Volar - Volar handles the mapping between TypeScript and LSP. - Volar handles the mapping between TypeScript and Monaco editor. - Volar manages virtual files. - Volar imports TypeScript directly. As a result, the Monaco editor integration now requires `path` to be polyfilled. - It is no longer possible to pass compiler options in the Monaco editor integration. - This adds editor features for YAML based on `yaml-language-server`. This has been contributed upstream to Volar and is pending review. - Markdown features are now handled by `vscode-markdown-languageservice`. This will be contributed upstream to Volar soon. - Markdown definitions are broken due to a bug in Volar. - This adds `remark-frontmatter` with TOML and YAML support as well as `remark-gfm` by default unless specified otherwise in `tsconfig.json`. - The language server now requires `typescript.tsdk` to be passed via initialization options. - This adds support for debugging virtual documents using Volar labs. Closes #168 Closes #284 Closes #295 Closes #298 Closes #301 --- demo/package.json | 1 + demo/src/index.js | 12 +- demo/webpack.config.js | 7 +- packages/language-server/index.js | 343 +------ packages/language-server/lib/configuration.js | 35 +- packages/language-server/lib/convert.js | 426 --------- packages/language-server/lib/documents.js | 73 -- .../lib/language-server-plugin.js | 41 + .../lib/language-service-manager.js | 202 ---- packages/language-server/package.json | 6 +- .../language-server/tests/completion.test.js | 111 ++- .../language-server/tests/definitions.test.js | 86 +- .../language-server/tests/diagnostics.test.js | 63 +- .../tests/document-link.test.js | 92 ++ .../tests/document-symbols.test.js | 21 +- .../tests/folding-ranges.test.js | 72 +- packages/language-server/tests/hover.test.js | 54 +- .../language-server/tests/initialize.test.js | 115 ++- .../language-server/tests/no-tsconfig.test.js | 12 +- .../language-server/tests/plugins.test.js | 12 +- .../tests/prepare-rename.test.js | 49 +- packages/language-server/tests/rename.test.js | 45 +- packages/language-server/tests/utils.js | 18 + packages/language-service/index.js | 2 +- packages/language-service/lib/error.js | 59 -- packages/language-service/lib/index.js | 896 ------------------ .../language-service/lib/language-module.js | 378 ++++++++ .../language-service/lib/language-service.js | 27 + packages/language-service/lib/markdown.js | 51 - packages/language-service/lib/object.js | 38 - packages/language-service/lib/outline.js | 88 -- packages/language-service/lib/path.js | 17 - packages/language-service/lib/utils.js | 283 ------ .../lib/volar-service-markdown/index.cjs | 337 +++++++ .../lib/volar-service-markdown/index.d.cts | 12 + .../lib/volar-service-yaml/index.js | 159 ++++ packages/language-service/package.json | 21 +- .../language-service/test/language-module.js | 768 +++++++++++++++ packages/language-service/tsconfig.json | 4 +- packages/monaco/index.js | 96 +- packages/monaco/lib/convert.js | 313 ------ packages/monaco/lib/language-features.js | 282 ------ packages/monaco/mdx.worker.js | 68 +- packages/monaco/package.json | 4 +- packages/monaco/playwright.config.js | 13 +- packages/vscode-mdx/package.json | 8 +- packages/vscode-mdx/src/extension.js | 30 +- packages/vscode-mdx/tsconfig.json | 1 + 48 files changed, 2367 insertions(+), 3484 deletions(-) delete mode 100644 packages/language-server/lib/convert.js delete mode 100644 packages/language-server/lib/documents.js create mode 100644 packages/language-server/lib/language-server-plugin.js delete mode 100644 packages/language-server/lib/language-service-manager.js create mode 100644 packages/language-server/tests/document-link.test.js delete mode 100644 packages/language-service/lib/error.js delete mode 100644 packages/language-service/lib/index.js create mode 100644 packages/language-service/lib/language-module.js create mode 100644 packages/language-service/lib/language-service.js delete mode 100644 packages/language-service/lib/markdown.js delete mode 100644 packages/language-service/lib/object.js delete mode 100644 packages/language-service/lib/outline.js delete mode 100644 packages/language-service/lib/path.js delete mode 100644 packages/language-service/lib/utils.js create mode 100644 packages/language-service/lib/volar-service-markdown/index.cjs create mode 100644 packages/language-service/lib/volar-service-markdown/index.d.cts create mode 100644 packages/language-service/lib/volar-service-yaml/index.js create mode 100644 packages/language-service/test/language-module.js delete mode 100644 packages/monaco/lib/convert.js delete mode 100644 packages/monaco/lib/language-features.js diff --git a/demo/package.json b/demo/package.json index a53e2c29..7ab8988e 100644 --- a/demo/package.json +++ b/demo/package.json @@ -14,6 +14,7 @@ "html-webpack-plugin": "^5.0.0", "mini-css-extract-plugin": "^2.0.0", "monaco-editor": "^0.41.0", + "path-browserify": "^1.0.0", "remark-frontmatter": "^4.0.0", "remark-gfm": "^3.0.0", "webpack": "^5.0.0", diff --git a/demo/src/index.js b/demo/src/index.js index fd144306..f239ec18 100644 --- a/demo/src/index.js +++ b/demo/src/index.js @@ -35,7 +35,9 @@ window.MonacoEnvironment = { } case 'mdx': { - return new Worker(new URL('mdx.worker.js', import.meta.url)) + return new Worker( + new URL('@mdx-js/monaco/mdx.worker.js', import.meta.url) + ) } default: { @@ -60,13 +62,7 @@ monaco.languages.register({ }) // This is where we actually configure the MDX integration. -initializeMonacoMdx(monaco, { - createData: { - compilerOptions: { - checkJs: true - } - } -}) +await initializeMonacoMdx(monaco) // Synchronize the file tree on the left with the Monaco models. Files from // node_modules are hidden, but can be navigated to. diff --git a/demo/webpack.config.js b/demo/webpack.config.js index 6da7bb4a..9854d204 100644 --- a/demo/webpack.config.js +++ b/demo/webpack.config.js @@ -1,5 +1,6 @@ import HtmlWebPackPlugin from 'html-webpack-plugin' import MiniCssExtractPlugin from 'mini-css-extract-plugin' +import webpack from 'webpack' /** * @type {import('webpack').Configuration} @@ -8,7 +9,10 @@ const config = { devtool: 'source-map', entry: './src/index.js', resolve: { - conditionNames: ['worker'] + conditionNames: ['worker'], + alias: { + path: 'path-browserify' + } }, module: { exprContextRegExp: /$^/, @@ -33,6 +37,7 @@ const config = { ] }, plugins: [ + new webpack.IgnorePlugin({resourceRegExp: /perf_hooks/}), new HtmlWebPackPlugin(), new MiniCssExtractPlugin({filename: '[contenthash].css'}) ] diff --git a/packages/language-server/index.js b/packages/language-server/index.js index 5e32cddb..6de7bd81 100755 --- a/packages/language-server/index.js +++ b/packages/language-server/index.js @@ -1,346 +1,11 @@ #!/usr/bin/env node -/** - * @typedef {import('vscode-languageserver').TextDocumentChangeEvent} TextDocumentChangeEvent - * @typedef {import('vscode-languageserver-textdocument').TextDocument} TextDocument - */ - import process from 'node:process' -import {fileURLToPath} from 'node:url' -import {isMdx} from '@mdx-js/language-service' -import ts from 'typescript' import { createConnection, - CompletionItemTag, - MarkupKind, - ProposedFeatures, - TextDocumentSyncKind, - TextEdit -} from 'vscode-languageserver/node.js' -import { - convertDiagnostics, - convertNavigationBarItems, - convertOutliningSpanKind, - convertScriptElementKind, - createDocumentationString, - definitionInfoToLocationLinks, - textSpanToRange -} from './lib/convert.js' -import {documents, getDocByFileName, getMdxDoc} from './lib/documents.js' -import {getOrCreateLanguageService} from './lib/language-service-manager.js' + startLanguageServer +} from '@volar/language-server/node.js' +import {plugin} from './lib/language-server-plugin.js' process.title = 'mdx-language-server' -const connection = createConnection(ProposedFeatures.all) - -connection.onInitialize(() => { - return { - capabilities: { - completionProvider: { - completionItem: { - labelDetailsSupport: true - }, - resolveProvider: true - }, - definitionProvider: true, - documentSymbolProvider: {label: 'MDX'}, - foldingRangeProvider: true, - hoverProvider: true, - referencesProvider: true, - renameProvider: { - prepareProvider: true - }, - textDocumentSync: TextDocumentSyncKind.Full, - typeDefinitionProvider: true - } - } -}) - -connection.onCompletion(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const offset = doc.offsetAt(parameters.position) - const ls = await getOrCreateLanguageService(ts, doc.uri) - const info = ls.getCompletionsAtPosition(fileURLToPath(doc.uri), offset, { - triggerKind: parameters.context?.triggerKind, - triggerCharacter: /** @type {ts.CompletionsTriggerCharacter} */ ( - parameters.context?.triggerCharacter - ) - }) - - if (!info) { - return - } - - return { - isIncomplete: Boolean(info.isIncomplete), - items: info.entries.map((entry) => ({ - data: { - data: entry.data, - offset, - source: entry.source, - uri: doc.uri - }, - insertText: entry.name, - kind: convertScriptElementKind(entry.kind), - label: entry.name, - sortText: entry.sortText, - source: entry.source, - tags: entry.kindModifiers?.includes('deprecated') - ? [CompletionItemTag.Deprecated] - : [] - })) - } -}) - -connection.onCompletionResolve(async (parameters) => { - const {data, offset, source, uri} = parameters.data - - const doc = getMdxDoc(uri) - - if (!doc) { - return parameters - } - - const ls = await getOrCreateLanguageService(ts, uri) - const details = ls.getCompletionEntryDetails( - fileURLToPath(uri), - offset, - parameters.label, - undefined, - source, - undefined, - data - ) - - return { - ...parameters, - detail: details?.displayParts - ? ts.displayPartsToString(details.displayParts) - : undefined, - documentation: details - ? { - kind: MarkupKind.Markdown, - value: createDocumentationString(ts, details) - } - : undefined, - kind: details?.kind ? convertScriptElementKind(details.kind) : undefined, - label: details?.name ?? parameters.label - } -}) - -connection.onDefinition(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const entries = ls.getDefinitionAtPosition( - fileURLToPath(doc.uri), - doc.offsetAt(parameters.position) - ) - - return definitionInfoToLocationLinks(entries) -}) - -connection.onTypeDefinition(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const entries = ls.getTypeDefinitionAtPosition( - fileURLToPath(doc.uri), - doc.offsetAt(parameters.position) - ) - - return definitionInfoToLocationLinks(entries) -}) - -connection.onDocumentSymbol(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const navigationBarItems = ls.getNavigationBarItems(fileURLToPath(doc.uri)) - - return convertNavigationBarItems(doc, navigationBarItems) -}) - -connection.onFoldingRanges(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const outlineSpans = ls.getOutliningSpans(fileURLToPath(doc.uri)) - - return outlineSpans.map((span) => { - const start = doc.positionAt(span.textSpan.start) - const end = doc.positionAt(span.textSpan.start + span.textSpan.length) - - return { - kind: convertOutliningSpanKind(ts, span.kind), - endCharacter: end.character, - endLine: end.line, - startCharacter: start.character, - startLine: start.line - } - }) -}) - -connection.onHover(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const info = ls.getQuickInfoAtPosition( - fileURLToPath(doc.uri), - doc.offsetAt(parameters.position) - ) - - if (!info) { - return - } - - const contents = ts.displayPartsToString(info.displayParts) - - return { - range: textSpanToRange(doc, info.textSpan), - contents: { - kind: MarkupKind.Markdown, - value: - '```typescript\n' + - contents + - '\n```\n' + - createDocumentationString(ts, info) - } - } -}) - -connection.onReferences(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const ls = await getOrCreateLanguageService(ts, doc.uri) - const entries = ls.getReferencesAtPosition( - fileURLToPath(doc.uri), - doc.offsetAt(parameters.position) - ) - - return entries?.map((entry) => ({ - uri: entry.fileName, - range: textSpanToRange(doc, entry.textSpan) - })) -}) - -connection.onPrepareRename(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const fileName = fileURLToPath(doc.uri) - const position = doc.offsetAt(parameters.position) - const ls = await getOrCreateLanguageService(ts, doc.uri) - const renameInfo = ls.getRenameInfo(fileName, position, { - allowRenameOfImportPath: false - }) - - if (renameInfo.canRename) { - return textSpanToRange(doc, renameInfo.triggerSpan) - } -}) - -connection.onRenameRequest(async (parameters) => { - const doc = getMdxDoc(parameters.textDocument.uri) - - if (!doc) { - return - } - - const fileName = fileURLToPath(doc.uri) - const position = doc.offsetAt(parameters.position) - const ls = await getOrCreateLanguageService(ts, doc.uri) - const locations = ls.findRenameLocations(fileName, position, false, false) - - if (!locations?.length) { - return - } - - /** @type {Record} */ - const changes = {} - for (const location of locations) { - const doc = getDocByFileName(location.fileName) - if (!doc) { - continue - } - - changes[doc.uri] ||= [] - const textEdits = changes[doc.uri] - textEdits.push( - TextEdit.replace( - textSpanToRange(doc, location.textSpan), - parameters.newName - ) - ) - } - - return {changes} -}) - -documents.onDidClose((event) => { - connection.sendDiagnostics({uri: event.document.uri, diagnostics: []}) -}) - -/** - * @param {TextDocumentChangeEvent} event - */ -async function checkDiagnostics(event) { - const {uri} = event.document - - if (!isMdx(uri)) { - return - } - - const path = fileURLToPath(uri) - const ls = await getOrCreateLanguageService(ts, uri) - const diagnostics = [ - ...ls.getSemanticDiagnostics(path), - ...ls.getSuggestionDiagnostics(path), - ...ls.getSyntacticDiagnostics(path) - ] - - connection.sendDiagnostics({ - uri, - diagnostics: diagnostics.map((diag) => - convertDiagnostics(ts, event.document, diag) - ) - }) -} - -documents.onDidChangeContent(checkDiagnostics) - -documents.onDidOpen(checkDiagnostics) - -connection.listen() -documents.listen(connection) +startLanguageServer(createConnection(), plugin) diff --git a/packages/language-server/lib/configuration.js b/packages/language-server/lib/configuration.js index cd90c5e2..8449e1c3 100644 --- a/packages/language-server/lib/configuration.js +++ b/packages/language-server/lib/configuration.js @@ -3,32 +3,44 @@ * @typedef {import('unified').PluggableList} PluggableList */ +import path from 'node:path' import {loadPlugin} from 'load-plugin' +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' + +/** @type {PluggableList} */ +const defaultPlugins = [[remarkFrontmatter, ['toml', 'yaml']], remarkGfm] /** * Load remark plugins from a configuration object. * - * @param {string} cwd + * @param {unknown} tsConfig * The current working directory to resolve plugins from. - * @param {unknown} config - * The object that defines the configuration. + * @param {typeof import('typescript')} [ts] * @returns {Promise} * A list of unified plugins to use. */ -export async function loadPlugins(cwd, config) { - if (typeof config !== 'object') { - return +export async function loadPlugins(tsConfig, ts) { + if (typeof tsConfig !== 'string' || !ts) { + return [[remarkFrontmatter, ['toml', 'yaml']], remarkGfm] } - if (!config) { - return + const jsonText = ts.sys.readFile(tsConfig) + + if (jsonText === undefined) { + return defaultPlugins } - if (!('plugins' in config)) { - return + const {config, error} = ts.parseConfigFileTextToJson(tsConfig, jsonText) + if (error) { + return defaultPlugins + } + + if (!config?.mdx) { + return defaultPlugins } - const pluginConfig = config.plugins + const pluginConfig = config.mdx.plugins if (typeof pluginConfig !== 'object') { return @@ -41,6 +53,7 @@ export async function loadPlugins(cwd, config) { const pluginArray = Array.isArray(pluginConfig) ? pluginConfig : Object.entries(pluginConfig) + const cwd = path.dirname(tsConfig) /** @type {Promise[]} */ const plugins = [] diff --git a/packages/language-server/lib/convert.js b/packages/language-server/lib/convert.js deleted file mode 100644 index ed62d924..00000000 --- a/packages/language-server/lib/convert.js +++ /dev/null @@ -1,426 +0,0 @@ -/** - * @typedef {import('typescript')} ts - * @typedef {import('typescript').CompletionEntryDetails} CompletionEntryDetails - * @typedef {import('typescript').DefinitionInfo} DefinitionInfo - * @typedef {import('typescript').Diagnostic} Diagnostic - * @typedef {import('typescript').DiagnosticCategory} DiagnosticCategory - * @typedef {import('typescript').DiagnosticMessageChain} DiagnosticMessageChain - * @typedef {import('typescript').DiagnosticRelatedInformation} DiagnosticRelatedInformation - * @typedef {import('typescript').JSDocTagInfo} JSDocTagInfo - * @typedef {import('typescript').NavigationBarItem} NavigationBarItem - * @typedef {import('typescript').OutliningSpanKind} OutliningSpanKind - * @typedef {import('typescript').QuickInfo} QuickInfo - * @typedef {import('typescript').SymbolDisplayPart} SymbolDisplayPart - * @typedef {import('typescript').ScriptElementKind} ScriptElementKind - * @typedef {import('typescript').TextSpan} TextSpan - * @typedef {import('vscode-languageserver').Diagnostic} LspDiagnostic - * @typedef {import('vscode-languageserver').DiagnosticRelatedInformation} LspDiagnosticRelatedInformation - * @typedef {import('vscode-languageserver-textdocument').TextDocument} TextDocument - */ - -import { - CompletionItemKind, - DiagnosticSeverity, - DiagnosticTag, - DocumentSymbol, - FoldingRangeKind, - LocationLink, - Range, - SymbolKind -} from 'vscode-languageserver' -import {getOrReadDocByFileName} from './documents.js' - -/** - * Convert a TypeScript script element kind to a Monaco completion kind. - * - * @param {ScriptElementKind} kind - * The TypeScript script element kind to convert - * @returns {CompletionItemKind} - * The matching Monaco completion item kind. - */ -export function convertScriptElementKind(kind) { - switch (kind) { - case 'primitive type': - case 'keyword': { - return CompletionItemKind.Keyword - } - - case 'var': - case 'local var': { - return CompletionItemKind.Variable - } - - case 'property': - case 'getter': - case 'setter': { - return CompletionItemKind.Field - } - - case 'function': - case 'method': - case 'construct': - case 'call': - case 'index': { - return CompletionItemKind.Function - } - - case 'enum': { - return CompletionItemKind.Enum - } - - case 'module': { - return CompletionItemKind.Module - } - - case 'class': { - return CompletionItemKind.Class - } - - case 'interface': { - return CompletionItemKind.Interface - } - - case 'warning': { - return CompletionItemKind.File - } - - default: { - return CompletionItemKind.Property - } - } -} - -/** - * Convert a TypeScript script element kind to a Monaco symbol. - * - * @param {ScriptElementKind} kind - * The TypeScript script element kind to convert - * @returns {SymbolKind} - * The matching Monaco symbol kind. - */ -export function convertScriptElementKindToSymbolKind(kind) { - switch (kind) { - case 'property': - case 'getter': - case 'setter': { - return SymbolKind.Property - } - - case 'function': - case 'method': - case 'construct': - case 'call': - case 'index': { - return SymbolKind.Function - } - - case 'enum': { - return SymbolKind.Enum - } - - case 'module': { - return SymbolKind.Module - } - - case 'class': { - return SymbolKind.Class - } - - case 'interface': { - return SymbolKind.Interface - } - - default: { - return SymbolKind.Variable - } - } -} - -/** - * Create a markdown documentation string from TypeScript details. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {CompletionEntryDetails | QuickInfo} details - * The details to represent. - * @returns {string} - * The details represented as a markdown string. - */ -export function createDocumentationString(ts, details) { - let documentationString = ts.displayPartsToString(details.documentation) - if (details.tags) { - for (const tag of details.tags) { - documentationString += `\n\n${tagToString(tag)}` - } - } - - return documentationString -} - -/** - * Represent a TypeScript JSDoc tag as a string. - * - * @param {JSDocTagInfo} tag - * The JSDoc tag to represent. - * @returns {string} - * A representation of the JSDoc tag. - */ -function tagToString(tag) { - let tagLabel = `*@${tag.name}*` - if (tag.name === 'param' && tag.text) { - const [parameterName, ...rest] = tag.text - tagLabel += `\`${parameterName.text}\`` - if (rest.length > 0) { - tagLabel += ` — ${rest.map((r) => r.text).join(' ')}` - } - } else if (Array.isArray(tag.text)) { - tagLabel += ` — ${tag.text.map((r) => r.text).join(' ')}` - } else if (tag.text) { - tagLabel += ` — ${tag.text}` - } - - return tagLabel -} - -/** - * Convert a text span to a LSP range that matches the given document. - * - * @param {TextDocument} doc - * The document to which the text span applies. - * @param {TextSpan} span - * The TypeScript text span to convert. - * @returns {Range} - * The text span as an LSP range. - */ -export function textSpanToRange(doc, span) { - const p1 = doc.positionAt(span.start) - const p2 = doc.positionAt(span.start + span.length || 1) - return Range.create(p1, p2) -} - -/** - * Convert a TypeScript diagnostic category to an LSP severity. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {DiagnosticCategory} category - * The TypeScript diagnostic category to convert. - * @returns {DiagnosticSeverity} - * THe TypeScript diagnostic severity as LSP diagnostic severity. - */ -function tsDiagnosticCategoryToMarkerSeverity(ts, category) { - switch (category) { - case ts.DiagnosticCategory.Warning: { - return DiagnosticSeverity.Warning - } - - case ts.DiagnosticCategory.Error: { - return DiagnosticSeverity.Error - } - - case ts.DiagnosticCategory.Suggestion: { - return DiagnosticSeverity.Hint - } - - default: { - return DiagnosticSeverity.Information - } - } -} - -/** - * Flatten a TypeScript diagnostic message chain into a string representation. - * @param {string | DiagnosticMessageChain | undefined} diag - * The diagnostic to represent. - * @param {number} [indent] - * The indentation to use. - * @returns {string} - * A flattened diagnostic text. - */ -function flattenDiagnosticMessageText(diag, indent = 0) { - if (typeof diag === 'string') { - return diag - } - - if (diag === undefined) { - return '' - } - - let result = '' - if (indent) { - result += `\n${' '.repeat(indent)}` - } - - result += diag.messageText - indent++ - if (diag.next) { - for (const kid of diag.next) { - result += flattenDiagnosticMessageText(kid, indent) - } - } - - return result -} - -/** - * Convert TypeScript diagnostic related information to LSP related information. - * - * @param {DiagnosticRelatedInformation[]} [relatedInformation] - * The TypeScript related information to convert. - * @returns {LspDiagnosticRelatedInformation[]} - * TypeScript diagnostic related information as Monaco related information. - */ -function convertRelatedInformation(relatedInformation) { - if (!relatedInformation) { - return [] - } - - /** @type {LspDiagnosticRelatedInformation[]} */ - const result = [] - for (const info of relatedInformation) { - if (!info.file?.fileName) { - continue - } - - const related = getOrReadDocByFileName(info.file.fileName) - - if (!related) { - continue - } - - const infoStart = info.start || 0 - const infoLength = info.length || 1 - const range = Range.create( - related.positionAt(infoStart), - related.positionAt(infoStart + infoLength) - ) - - result.push({ - location: { - range, - uri: related.uri - }, - message: flattenDiagnosticMessageText(info.messageText) - }) - } - - return result -} - -/** - * Convert a TypeScript dignostic to a LSP diagnostic. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {TextDocument} doc - * The text document to which the diagnostic applies. - * @param {Diagnostic} diag - * The TypeScript diagnostic to convert. - * @returns {LspDiagnostic} - * The TypeScript diagnostic converted to an LSP diagnostic. - */ -export function convertDiagnostics(ts, doc, diag) { - const diagStart = diag.start || 0 - const diagLength = diag.length || 1 - const range = Range.create( - doc.positionAt(diagStart), - doc.positionAt(diagStart + diagLength) - ) - - /** @type {DiagnosticTag[]} */ - const tags = [] - if (diag.reportsUnnecessary) { - tags.push(DiagnosticTag.Unnecessary) - } - - if (diag.reportsDeprecated) { - tags.push(DiagnosticTag.Deprecated) - } - - return { - code: `ts${diag.code}`, - message: flattenDiagnosticMessageText(diag.messageText), - range, - relatedInformation: convertRelatedInformation(diag.relatedInformation), - severity: tsDiagnosticCategoryToMarkerSeverity(ts, diag.category), - tags - } -} - -/** - * Convert TypeScript definition info to location links. - * - * @param {readonly DefinitionInfo[] | undefined} info - * The TypeScript definition info to convert. - * @returns {LocationLink[] | undefined} - * The definition info represented as LSP location links. - */ -export function definitionInfoToLocationLinks(info) { - if (!info) { - return - } - - /** @type {LocationLink[]} */ - const locationLinks = [] - for (const entry of info) { - const entryDoc = getOrReadDocByFileName(entry.fileName) - if (entryDoc) { - locationLinks.push( - LocationLink.create( - entryDoc.uri, - textSpanToRange(entryDoc, entry.textSpan), - textSpanToRange(entryDoc, entry.textSpan) - ) - ) - } - } - - return locationLinks -} - -/** - * Convert TypeScript navigation bar items to location links. - * - * @param {TextDocument} doc - * The text document to which the navigation bar items apply. - * @param {NavigationBarItem[]} items - * The navigation bar items to convert. - * @returns {DocumentSymbol[]} - * The navigation bar items as document symvols - */ -export function convertNavigationBarItems(doc, items) { - return items - .filter((item) => item.kind !== 'module') - .map((item) => { - return DocumentSymbol.create( - item.text, - undefined, - convertScriptElementKindToSymbolKind(item.kind), - textSpanToRange(doc, item.spans[0]), - textSpanToRange(doc, item.spans[0]), - convertNavigationBarItems(doc, item.childItems) - ) - }) -} - -/** - * Convert a TypeScript outlining span kind to a LSP folding range kind. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {OutliningSpanKind} kind - * The TypeScript outlining span kind to convert. - * @returns {FoldingRangeKind} - * The kind as an LSP folding range kind. - */ -export function convertOutliningSpanKind(ts, kind) { - if (kind === ts.OutliningSpanKind.Comment) { - return FoldingRangeKind.Comment - } - - if (kind === ts.OutliningSpanKind.Imports) { - return FoldingRangeKind.Imports - } - - return FoldingRangeKind.Region -} diff --git a/packages/language-server/lib/documents.js b/packages/language-server/lib/documents.js deleted file mode 100644 index 4842c8e1..00000000 --- a/packages/language-server/lib/documents.js +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'node:fs' -import {pathToFileURL} from 'node:url' -import {isMdx} from '@mdx-js/language-service' -import {TextDocuments} from 'vscode-languageserver' -import {TextDocument} from 'vscode-languageserver-textdocument' - -/** - * The global text documents manager. - */ -export const documents = new TextDocuments(TextDocument) - -/** - * Return a document based on its file name. - * - * Documents are stored using a file URL. This function allows to do a lookup by - * file name instead. The document will only be returned if it’s open. - * - * @param {string} fileName - * The file name to lookup. - * @returns {TextDocument | undefined} - * The text document that matches the filename. - */ -export function getDocByFileName(fileName) { - return documents.get(String(pathToFileURL(fileName))) -} - -/** - * Return a document based on its file name. - * - * Documents are stored using a file URL. This function allows to do a lookup by - * file name instead. If the file hasn’t been opened, it will be read from the - * file system. - * - * @param {string} fileName - * The file name to lookup. - * @returns {TextDocument | undefined} - * The text document that matches the filename. - */ -export function getOrReadDocByFileName(fileName) { - const doc = getDocByFileName(fileName) - if (doc) { - return doc - } - - let content - try { - content = fs.readFileSync(fileName, 'utf8') - } catch { - return - } - - return TextDocument.create( - String(pathToFileURL(fileName)), - // The language ID doesn’t really matter for our use case. - 'plaintext', - 0, - content - ) -} - -/** - * Get a document, but only if it’s an MDX document. - * - * @param {string} uri - * The file URL of the document. - * @returns {TextDocument | undefined} - * The MDX text document that matches the given URI, if it exists. - */ -export function getMdxDoc(uri) { - if (isMdx(uri)) { - return documents.get(uri) - } -} diff --git a/packages/language-server/lib/language-server-plugin.js b/packages/language-server/lib/language-server-plugin.js new file mode 100644 index 00000000..4708d2ff --- /dev/null +++ b/packages/language-server/lib/language-server-plugin.js @@ -0,0 +1,41 @@ +/** + * @typedef {import('@volar/language-server/node.js').LanguageServerPlugin} LanguageServerPlugin + */ + +import assert from 'node:assert' +import {resolveConfig} from '@mdx-js/language-service' +import {loadPlugins} from './configuration.js' + +/** + * @type {LanguageServerPlugin} + */ +export function plugin(initOptions, modules) { + return { + extraFileExtensions: [ + {extension: 'mdx', isMixedContent: true, scriptKind: 7} + ], + + watchFileExtensions: [ + 'cjs', + 'ctx', + 'js', + 'json', + 'mdx', + 'mjs', + 'mts', + 'ts', + 'tsx' + ], + + async resolveConfig(config, ctx) { + assert(modules.typescript, 'TypeScript module is missing') + + const plugins = await loadPlugins( + ctx?.project?.tsConfig, + modules.typescript + ) + + return resolveConfig(config, modules.typescript, plugins) + } + } +} diff --git a/packages/language-server/lib/language-service-manager.js b/packages/language-server/lib/language-service-manager.js deleted file mode 100644 index a939be8c..00000000 --- a/packages/language-server/lib/language-service-manager.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @typedef {import('typescript')} ts - * @typedef {import('typescript').CompilerOptions} CompilerOptions - * @typedef {import('typescript').IScriptSnapshot} IScriptSnapshot - * @typedef {import('typescript').LanguageService} LanguageService - * @typedef {import('typescript').LanguageServiceHost} LanguageServiceHost - * @typedef {import('typescript').ProjectReference} ProjectReference - */ - -import path from 'node:path' -import {fileURLToPath, pathToFileURL} from 'node:url' -import {createMdxLanguageService} from '@mdx-js/language-service' -import {loadPlugins} from './configuration.js' -import { - documents, - getDocByFileName, - getOrReadDocByFileName -} from './documents.js' - -/** - * Create a function for getting a script snapshot based on a TypeScript module. - * @param {ts} ts - * The TypeScript module to use. - * @returns {(fileName: string) => IScriptSnapshot | undefined} - * A function for getting a Script snapshot. - */ -function createGetScriptSnapshot(ts) { - return (fileName) => { - const doc = getOrReadDocByFileName(fileName) - - if (doc) { - return ts.ScriptSnapshot.fromString(doc.getText()) - } - } -} - -/** - * Get a list of the file paths of all open documents. - * - * @returns {string[]} - * The list of open documents. - */ -function getScriptFileNames() { - return documents.keys().map((uri) => fileURLToPath(uri)) -} - -/** - * Get the current script version of a file. - * - * If a file has previously been opened, it will be available in the document - * registry. This will increment the version for every edit made. - * - * If a file isn’t available in the document registry, version 0 will be - * returned. - * - * @param {string} fileName - * The file name to get the version for. - * @returns {string} - * The script version. - */ -function getScriptVersion(fileName) { - const doc = getDocByFileName(fileName) - - return doc ? String(doc.version) : '0' -} - -/** - * Create a language service host that works with the language server. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {CompilerOptions} options - * The compiler options to use. - * @param {readonly ProjectReference[]} [references] - * The compiler options to use. - * @returns {LanguageServiceHost} - * A language service host that works with the language server. - */ -function createLanguageServiceHost(ts, options, references) { - return { - ...ts.sys, - getCompilationSettings: () => options, - getDefaultLibFileName: ts.getDefaultLibFilePath, - getProjectReferences: () => references, - getScriptSnapshot: createGetScriptSnapshot(ts), - getScriptVersion, - getScriptFileNames, - useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames - } -} - -/** @type {LanguageService} */ -let defaultLanguageService - -/** - * Get the default language service. - * - * The default language service be used when a file is opened outside of a - * TypeScript project. (No `tsconfig.json` is found.) - * - * The default language service is created once if needed, then reused. - * - * @param {ts} ts - * The TypeScript module to use. - * @returns {LanguageService} - * The default language service. - */ -function getDefaultLanguageService(ts) { - if (!defaultLanguageService) { - defaultLanguageService = createMdxLanguageService( - ts, - createLanguageServiceHost(ts, { - allowJs: true, - lib: ['lib.es2020.full.d.ts'], - module: ts.ModuleKind.Node16, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - target: ts.ScriptTarget.Latest - }) - ) - } - - return defaultLanguageService -} - -/** - * Create a language service for the given file URI. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {string} configPath - * The path to the TypeScript configuration file. - * @returns {Promise} - * An MDX language service. - */ -async function createLanguageService(ts, configPath) { - const jsonText = ts.sys.readFile(configPath) - if (jsonText === undefined) { - return getDefaultLanguageService(ts) - } - - const {config, error} = ts.parseConfigFileTextToJson(configPath, jsonText) - if (error || !config) { - return getDefaultLanguageService(ts) - } - - const plugins = await loadPlugins(path.dirname(configPath), config.mdx) - - const {options, projectReferences} = ts.parseJsonConfigFileContent( - config, - ts.sys, - fileURLToPath(new URL('.', pathToFileURL(configPath))), - {...ts.getDefaultCompilerOptions(), allowJs: true}, - 'tsconfig.json', - undefined, - [ - { - extension: '.mdx', - isMixedContent: true, - scriptKind: ts.ScriptKind.JSX - } - ] - ) - - return createMdxLanguageService( - ts, - createLanguageServiceHost(ts, options, projectReferences), - plugins - ) -} - -/** @type {Map>} */ -const cache = new Map() - -/** - * Get or create a language service for the given file URI. - * - * The language service is cached per TypeScript project. A TypeScript project - * is defined by a `tsconfig.json` file. - * - * @param {ts} ts - * The TypeScript module to use. - * @param {string} uri - * The file URI for which to get the language service. - * @returns {LanguageService | Promise} - * A cached MDX language service. - */ -export function getOrCreateLanguageService(ts, uri) { - const configPath = ts.findConfigFile(fileURLToPath(uri), ts.sys.fileExists) - if (!configPath) { - return getDefaultLanguageService(ts) - } - - // It’s important this caching logic is synchronous. This is why we cache the - // promise, not the value. - let promise = cache.get(configPath) - if (!promise) { - promise = createLanguageService(ts, configPath) - cache.set(configPath, promise) - } - - return promise -} diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 5fe13da4..31d053e0 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -34,10 +34,10 @@ }, "dependencies": { "@mdx-js/language-service": "0.1.0", + "@volar/language-server": "~1.10.0", "load-plugin": "^5.0.0", - "typescript": "^5.0.0", - "vscode-languageserver": "^8.0.0", - "vscode-languageserver-textdocument": "^1.0.0" + "remark-frontmatter": "^4.0.0", + "remark-gfm": "^3.0.0" }, "devDependencies": { "@types/node": "^20.0.0" diff --git a/packages/language-server/tests/completion.test.js b/packages/language-server/tests/completion.test.js index d7636085..463ad6a8 100644 --- a/packages/language-server/tests/completion.test.js +++ b/packages/language-server/tests/completion.test.js @@ -3,8 +3,20 @@ */ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' -import {CompletionRequest, InitializeRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import { + CompletionItemKind, + CompletionRequest, + CompletionResolveRequest, + InitializeRequest, + InsertTextFormat +} from 'vscode-languageserver' +import { + createConnection, + fixturePath, + fixtureUri, + openTextDocument, + tsdk +} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +32,9 @@ afterEach(() => { test('support completion in ESM', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/completion.mdx') @@ -32,25 +45,55 @@ test('support completion in ESM', async () => { assert.ok(result) assert.ok('items' in result) - const completion = result.items.find((r) => r.insertText === 'Boolean') + const completion = result.items.find((r) => r.label === 'Boolean') assert.deepEqual(completion, { + commitCharacters: ['.', ',', ';', '('], data: { + original: { + data: { + fileName: fixturePath('node16/completion.mdx.jsx'), + offset: 30, + originalItem: {name: 'Boolean'}, + uri: fixtureUri('node16/completion.mdx.jsx') + } + }, + serviceId: 'typescript', + uri: fixtureUri('node16/completion.mdx'), + virtualDocumentUri: fixtureUri('node16/completion.mdx.jsx') + }, + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Variable, + label: 'Boolean', + sortText: '15' + }) + + const resolved = await connection.sendRequest( + CompletionResolveRequest.type, + completion + ) + assert.deepEqual(resolved, { + commitCharacters: ['.', ',', ';', '('], + data: { + fileName: fixturePath('node16/completion.mdx.jsx'), offset: 30, - uri: fixtureUri('node16/completion.mdx') + originalItem: {name: 'Boolean'}, + uri: fixtureUri('node16/completion.mdx.jsx') }, - insertText: 'Boolean', + detail: 'interface Boolean\nvar Boolean: BooleanConstructor', + documentation: {kind: 'markdown', value: ''}, + insertTextFormat: 1, kind: 6, label: 'Boolean', - sortText: '15', - tags: [] + sortText: '15' }) }) test('support completion in JSX', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/completion.mdx') @@ -61,25 +104,55 @@ test('support completion in JSX', async () => { assert.ok(result) assert.ok('items' in result) - const completion = result.items.find((r) => r.insertText === 'Boolean') + const completion = result.items.find((r) => r.label === 'Boolean') assert.deepEqual(completion, { + commitCharacters: ['.', ',', ';', '('], + data: { + original: { + data: { + fileName: fixturePath('node16/completion.mdx.jsx'), + offset: 77, + originalItem: {name: 'Boolean'}, + uri: fixtureUri('node16/completion.mdx.jsx') + } + }, + serviceId: 'typescript', + uri: fixtureUri('node16/completion.mdx'), + virtualDocumentUri: fixtureUri('node16/completion.mdx.jsx') + }, + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Variable, + label: 'Boolean', + sortText: '15' + }) + + const resolved = await connection.sendRequest( + CompletionResolveRequest.type, + completion + ) + assert.deepEqual(resolved, { + commitCharacters: ['.', ',', ';', '('], data: { + fileName: fixturePath('node16/completion.mdx.jsx'), offset: 77, - uri: fixtureUri('node16/completion.mdx') + originalItem: {name: 'Boolean'}, + uri: fixtureUri('node16/completion.mdx.jsx') }, - insertText: 'Boolean', + detail: 'interface Boolean\nvar Boolean: BooleanConstructor', + documentation: {kind: 'markdown', value: ''}, + insertTextFormat: 1, kind: 6, label: 'Boolean', - sortText: '15', - tags: [] + sortText: '15' }) }) test('ignore completion in markdown content', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/completion.mdx') @@ -88,5 +161,5 @@ test('ignore completion in markdown content', async () => { textDocument: {uri} }) - assert.deepEqual(result, null) + assert.deepEqual(result, {isIncomplete: false, items: []}) }) diff --git a/packages/language-server/tests/definitions.test.js b/packages/language-server/tests/definitions.test.js index 9a3afe66..a0877d4f 100644 --- a/packages/language-server/tests/definitions.test.js +++ b/packages/language-server/tests/definitions.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {DefinitionRequest, InitializeRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +20,9 @@ afterEach(() => { test('resolve file-local definitions in ESM', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/a.mdx') @@ -32,9 +33,13 @@ test('resolve file-local definitions in ESM', async () => { assert.deepEqual(result, [ { + originSelectionRange: { + start: {line: 4, character: 2}, + end: {line: 4, character: 3} + }, targetRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} + start: {line: 1, character: 0}, + end: {line: 1, character: 22} }, targetSelectionRange: { start: {line: 1, character: 16}, @@ -48,8 +53,9 @@ test('resolve file-local definitions in ESM', async () => { test('resolve cross-file definitions in ESM if the other file was previously opened', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) await openTextDocument(connection, 'node16/a.mdx') @@ -61,9 +67,13 @@ test('resolve cross-file definitions in ESM if the other file was previously ope assert.deepEqual(result, [ { + originSelectionRange: { + start: {line: 0, character: 9}, + end: {line: 0, character: 10} + }, targetRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} + start: {line: 1, character: 0}, + end: {line: 1, character: 22} }, targetSelectionRange: { start: {line: 1, character: 16}, @@ -77,8 +87,9 @@ test('resolve cross-file definitions in ESM if the other file was previously ope test('resolve cross-file definitions in ESM if the other file is unopened', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/b.mdx') @@ -89,9 +100,13 @@ test('resolve cross-file definitions in ESM if the other file is unopened', asyn assert.deepEqual(result, [ { + originSelectionRange: { + start: {line: 0, character: 9}, + end: {line: 0, character: 10} + }, targetRange: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} + start: {line: 1, character: 0}, + end: {line: 1, character: 22} }, targetSelectionRange: { start: {line: 1, character: 16}, @@ -102,39 +117,12 @@ test('resolve cross-file definitions in ESM if the other file is unopened', asyn ]) }) -test('resolve markdown link references', async () => { - await connection.sendRequest(InitializeRequest.type, { - processId: null, - rootUri: null, - capabilities: {} - }) - - const {uri} = await openTextDocument(connection, 'node16/link-reference.mdx') - const result = await connection.sendRequest(DefinitionRequest.type, { - position: {line: 0, character: 10}, - textDocument: {uri} - }) - - assert.deepEqual(result, [ - { - targetRange: { - start: {line: 2, character: 0}, - end: {line: 2, character: 24} - }, - targetSelectionRange: { - start: {line: 2, character: 0}, - end: {line: 2, character: 24} - }, - targetUri: fixtureUri('node16/link-reference.mdx') - } - ]) -}) - test('does not resolve shadow content', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/undefined-props.mdx') @@ -149,8 +137,9 @@ test('does not resolve shadow content', async () => { test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -165,8 +154,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') @@ -175,5 +165,5 @@ test('ignore non-mdx files', async () => { textDocument: {uri} }) - assert.deepEqual(result, null) + assert.deepEqual(result, []) }) diff --git a/packages/language-server/tests/diagnostics.test.js b/packages/language-server/tests/diagnostics.test.js index e88fbf20..363aaa99 100644 --- a/packages/language-server/tests/diagnostics.test.js +++ b/packages/language-server/tests/diagnostics.test.js @@ -8,6 +8,7 @@ import { createConnection, fixtureUri, openTextDocument, + tsdk, waitForDiagnostics } from './utils.js' @@ -25,8 +26,9 @@ afterEach(() => { test('type errors', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const diagnosticsPromise = waitForDiagnostics(connection) @@ -38,21 +40,32 @@ test('type errors', async () => { assert.deepEqual(diagnostics, { uri: textDocument.uri, + version: 1, diagnostics: [ { - code: 'ts2568', + code: 2568, + data: { + documentUri: fixtureUri('node16/type-errors.mdx.jsx'), + isFormat: false, + original: {}, + ruleFixIndex: 0, + serviceOrRuleId: 'typescript', + type: 'service', + uri: fixtureUri('node16/type-errors.mdx'), + version: 0 + }, message: - "Property 'counts' may not exist on type 'Props'. Did you mean 'count'?", + "Property 'counter' may not exist on type 'Props'. Did you mean 'count'?", range: { - start: {line: 6, character: 15}, - end: {line: 6, character: 21} + start: {line: 14, character: 51}, + end: {line: 14, character: 58} }, relatedInformation: [ { location: { range: { - end: {line: 12, character: 2}, - start: {line: 11, character: 4} + start: {line: 11, character: 4}, + end: {line: 12, character: 2} }, uri: fixtureUri('node16/type-errors.mdx') }, @@ -60,22 +73,32 @@ test('type errors', async () => { } ], severity: 4, - tags: [] + source: 'ts' }, { - code: 'ts2568', + code: 2568, + data: { + documentUri: fixtureUri('node16/type-errors.mdx.jsx'), + isFormat: false, + original: {}, + ruleFixIndex: 0, + serviceOrRuleId: 'typescript', + type: 'service', + uri: fixtureUri('node16/type-errors.mdx'), + version: 0 + }, message: - "Property 'counter' may not exist on type 'Props'. Did you mean 'count'?", + "Property 'counts' may not exist on type 'Props'. Did you mean 'count'?", range: { - start: {line: 14, character: 51}, - end: {line: 14, character: 58} + start: {line: 6, character: 15}, + end: {line: 6, character: 21} }, relatedInformation: [ { location: { range: { - start: {line: 11, character: 4}, - end: {line: 12, character: 2} + end: {line: 12, character: 2}, + start: {line: 11, character: 4} }, uri: fixtureUri('node16/type-errors.mdx') }, @@ -83,7 +106,7 @@ test('type errors', async () => { } ], severity: 4, - tags: [] + source: 'ts' } ] }) @@ -92,8 +115,9 @@ test('type errors', async () => { test('does not resolve shadow content', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const diagnosticsPromise = waitForDiagnostics(connection) @@ -105,6 +129,7 @@ test('does not resolve shadow content', async () => { assert.deepEqual(diagnostics, { uri: textDocument.uri, - diagnostics: [] + diagnostics: [], + version: 1 }) }) diff --git a/packages/language-server/tests/document-link.test.js b/packages/language-server/tests/document-link.test.js new file mode 100644 index 00000000..0af5f1bb --- /dev/null +++ b/packages/language-server/tests/document-link.test.js @@ -0,0 +1,92 @@ +/** + * @typedef {import('vscode-languageserver').ProtocolConnection} ProtocolConnection + */ +import assert from 'node:assert/strict' +import {afterEach, beforeEach, test} from 'node:test' +import {DocumentLinkRequest, InitializeRequest} from 'vscode-languageserver' +import { + createConnection, + fixturePath, + fixtureUri, + openTextDocument, + tsdk +} from './utils.js' + +/** @type {ProtocolConnection} */ +let connection + +beforeEach(() => { + connection = createConnection() +}) + +afterEach(() => { + connection.dispose() +}) + +test('resolve markdown link references', async () => { + await connection.sendRequest(InitializeRequest.type, { + processId: null, + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} + }) + + const {uri} = await openTextDocument(connection, 'node16/link-reference.mdx') + const result = await connection.sendRequest(DocumentLinkRequest.type, { + textDocument: {uri} + }) + + assert.deepEqual(result, [ + { + range: { + start: {line: 0, character: 9}, + end: {line: 0, character: 12} + }, + tooltip: 'Go to link definition', + // This is caused by an upstream bug in Volar + // target: fixtureUri('node16/link-reference.mdx#L3,8'), + target: fixtureUri('node16/link-reference.mdx.md#L3,8'), + data: { + uri: fixtureUri('node16/link-reference.mdx'), + original: { + data: { + kind: 1, + source: { + isAngleBracketLink: false, + hrefText: 'mdx', + pathText: 'mdx', + resource: { + $mid: 1, + path: fixturePath('node16/link-reference.mdx.md'), + scheme: 'file' + }, + range: { + start: {line: 0, character: 8}, + end: {line: 0, character: 15} + }, + targetRange: { + start: {line: 0, character: 9}, + end: {line: 0, character: 12} + }, + hrefRange: { + start: {line: 0, character: 9}, + end: {line: 0, character: 12} + } + }, + href: {kind: 2, ref: 'mdx'} + } + }, + serviceId: 'markdown' + } + }, + { + range: {start: {line: 2, character: 7}, end: {line: 2, character: 24}}, + target: 'https://mdxjs.com/', + data: { + uri: fixtureUri('node16/link-reference.mdx'), + original: {}, + serviceId: 'markdown' + } + } + ]) +}) diff --git a/packages/language-server/tests/document-symbols.test.js b/packages/language-server/tests/document-symbols.test.js index 349b5f24..8bb90629 100644 --- a/packages/language-server/tests/document-symbols.test.js +++ b/packages/language-server/tests/document-symbols.test.js @@ -8,7 +8,7 @@ import { InitializeRequest, SymbolKind } from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -24,8 +24,9 @@ afterEach(() => { test('resolve document symbols', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/mixed.mdx') @@ -43,8 +44,8 @@ test('resolve document symbols', async () => { start: {line: 10, character: 0} }, selectionRange: { - end: {line: 15, character: 1}, - start: {line: 10, character: 0} + end: {line: 10, character: 32}, + start: {line: 10, character: 16} } } ]) @@ -53,8 +54,9 @@ test('resolve document symbols', async () => { test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -68,8 +70,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') diff --git a/packages/language-server/tests/folding-ranges.test.js b/packages/language-server/tests/folding-ranges.test.js index 7e687bc6..52884e63 100644 --- a/packages/language-server/tests/folding-ranges.test.js +++ b/packages/language-server/tests/folding-ranges.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {FoldingRangeRequest, InitializeRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +20,9 @@ afterEach(() => { test('resolve folding ranges', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/mixed.mdx') @@ -38,81 +39,52 @@ test('resolve folding ranges', async () => { startLine: 2 }, { - endCharacter: 11, - endLine: 45, - kind: 'region', - startCharacter: 0, - startLine: 6 - }, - { - endCharacter: 1, - endLine: 15, - kind: 'region', + endCharacter: 10, + endLine: 14, startCharacter: 43, startLine: 10 }, { - endCharacter: 3, - endLine: 13, - kind: 'region', + endCharacter: 12, + endLine: 12, startCharacter: 16, startLine: 11 }, { - endCharacter: 38, + endLine: 45, + startLine: 6 + }, + { endLine: 31, - kind: 'region', - startCharacter: 0, startLine: 17 }, { - endCharacter: 9, endLine: 23, - kind: 'region', - startCharacter: 0, startLine: 21 }, { - endCharacter: 38, endLine: 31, - kind: 'region', - startCharacter: 0, startLine: 25 }, { - endCharacter: 38, endLine: 31, - kind: 'region', - startCharacter: 0, startLine: 29 }, { - endCharacter: 11, - endLine: 45, - kind: 'region', - startCharacter: 0, + endLine: 42, startLine: 33 }, { - endCharacter: 3, + endLine: 45, + startLine: 43 + }, + { endLine: 39, - kind: 'region', - startCharacter: 0, startLine: 37 }, { - endCharacter: 11, endLine: 45, - kind: 'region', - startCharacter: 0, startLine: 41 - }, - { - endCharacter: 11, - endLine: 45, - kind: 'region', - startCharacter: 2, - startLine: 43 } ]) }) @@ -120,8 +92,9 @@ test('resolve folding ranges', async () => { test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -135,8 +108,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') diff --git a/packages/language-server/tests/hover.test.js b/packages/language-server/tests/hover.test.js index 611b6437..be7aba5c 100644 --- a/packages/language-server/tests/hover.test.js +++ b/packages/language-server/tests/hover.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {HoverRequest, InitializeRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +20,9 @@ afterEach(() => { test('resolve hover in ESM', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/a.mdx') @@ -33,7 +34,7 @@ test('resolve hover in ESM', async () => { assert.deepEqual(result, { contents: { kind: 'markdown', - value: '```typescript\nfunction a(): void\n```\nDescription of `a`' + value: '```typescript\nfunction a(): void\n```\n\nDescription of `a`' }, range: { end: {line: 4, character: 3}, @@ -45,8 +46,9 @@ test('resolve hover in ESM', async () => { test('resolve import hover in ESM if the other file was previously opened', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) await openTextDocument(connection, 'node16/a.mdx') @@ -60,7 +62,7 @@ test('resolve import hover in ESM if the other file was previously opened', asyn contents: { kind: 'markdown', value: - '```typescript\n(alias) function a(): void\nimport a\n```\nDescription of `a`' + '```typescript\n(alias) function a(): void\nimport a\n```\n\nDescription of `a`' }, range: { start: {line: 0, character: 9}, @@ -72,8 +74,9 @@ test('resolve import hover in ESM if the other file was previously opened', asyn test('resolve import hover in ESM if the other file is unopened', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/b.mdx') @@ -86,7 +89,7 @@ test('resolve import hover in ESM if the other file is unopened', async () => { contents: { kind: 'markdown', value: - '```typescript\n(alias) function a(): void\nimport a\n```\nDescription of `a`' + '```typescript\n(alias) function a(): void\nimport a\n```\n\nDescription of `a`' }, range: { start: {line: 0, character: 9}, @@ -98,8 +101,9 @@ test('resolve import hover in ESM if the other file is unopened', async () => { test('resolve import hover in JSX expressions', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/a.mdx') @@ -111,7 +115,7 @@ test('resolve import hover in JSX expressions', async () => { assert.deepEqual(result, { contents: { kind: 'markdown', - value: '```typescript\nfunction a(): void\n```\nDescription of `a`' + value: '```typescript\nfunction a(): void\n```\n\nDescription of `a`' }, range: { start: {line: 11, character: 1}, @@ -123,8 +127,9 @@ test('resolve import hover in JSX expressions', async () => { test('support mdxJsxTextElement', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument( @@ -140,7 +145,7 @@ test('support mdxJsxTextElement', async () => { contents: { kind: 'markdown', value: - '```typescript\nfunction Component(): void\n```\nDescription of `Component`' + '```typescript\nfunction Component(): void\n```\n\nDescription of `Component`' }, range: { start: {line: 3, character: 1}, @@ -152,8 +157,9 @@ test('support mdxJsxTextElement', async () => { test('resolve import hover in JSX elements', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/a.mdx') @@ -165,7 +171,7 @@ test('resolve import hover in JSX elements', async () => { assert.deepEqual(result, { contents: { kind: 'markdown', - value: '```typescript\nfunction Component(): JSX.Element\n```\n' + value: '```typescript\nfunction Component(): JSX.Element\n```' }, range: { start: {line: 13, character: 1}, @@ -177,8 +183,9 @@ test('resolve import hover in JSX elements', async () => { test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -193,8 +200,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') diff --git a/packages/language-server/tests/initialize.test.js b/packages/language-server/tests/initialize.test.js index adb1de05..53ecc83e 100644 --- a/packages/language-server/tests/initialize.test.js +++ b/packages/language-server/tests/initialize.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {InitializeRequest} from 'vscode-languageserver-protocol' -import {createConnection} from './utils.js' +import {createConnection, fixtureUri, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -22,24 +22,123 @@ test('initialize', async () => { InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} } ) assert.deepEqual(initializeResponse, { capabilities: { - completionProvider: { - completionItem: {labelDetailsSupport: true}, + callHierarchyProvider: true, + codeActionProvider: { + codeActionKinds: [ + '', + 'quickfix', + 'refactor', + 'refactor.extract', + 'refactor.inline', + 'refactor.rewrite', + 'source', + 'source.fixAll', + 'source.organizeImports' + ], resolveProvider: true }, + codeLensProvider: {resolveProvider: true}, + colorProvider: true, + completionProvider: { + resolveProvider: true, + triggerCharacters: [ + '.', + '"', + "'", + '`', + '/', + '<', + '@', + '#', + ' ', + '*', + ':' + ] + }, definitionProvider: true, - documentSymbolProvider: {label: 'MDX'}, + documentFormattingProvider: true, + documentHighlightProvider: true, + documentLinkProvider: {resolveProvider: true}, + documentOnTypeFormattingProvider: { + firstTriggerCharacter: ';', + moreTriggerCharacter: ['}', '\n'] + }, + documentRangeFormattingProvider: true, + documentSymbolProvider: true, foldingRangeProvider: true, hoverProvider: true, + implementationProvider: true, + inlayHintProvider: {resolveProvider: true}, + linkedEditingRangeProvider: true, referencesProvider: true, renameProvider: {prepareProvider: true}, - textDocumentSync: 1, - typeDefinitionProvider: true + selectionRangeProvider: true, + semanticTokensProvider: { + full: false, + legend: { + tokenModifiers: [ + 'declaration', + 'definition', + 'readonly', + 'static', + 'deprecated', + 'abstract', + 'async', + 'modification', + 'documentation', + 'defaultLibrary' + ], + tokenTypes: [ + 'namespace', + 'class', + 'enum', + 'interface', + 'struct', + 'typeParameter', + 'type', + 'parameter', + 'variable', + 'property', + 'enumMember', + 'decorator', + 'event', + 'function', + 'method', + 'macro', + 'label', + 'comment', + 'string', + 'keyword', + 'number', + 'regexp', + 'operator' + ] + }, + range: true + }, + signatureHelpProvider: { + retriggerCharacters: [')'], + triggerCharacters: ['(', ',', '<'] + }, + textDocumentSync: 2, + typeDefinitionProvider: true, + workspace: { + fileOperations: { + willRename: { + filters: [ + {pattern: {glob: '**/*.{cjs,ctx,js,json,mdx,mjs,mts,ts,tsx}'}} + ] + } + } + }, + workspaceSymbolProvider: true } }) }) diff --git a/packages/language-server/tests/no-tsconfig.test.js b/packages/language-server/tests/no-tsconfig.test.js index 27d4f15e..851c42b3 100644 --- a/packages/language-server/tests/no-tsconfig.test.js +++ b/packages/language-server/tests/no-tsconfig.test.js @@ -7,7 +7,9 @@ import {InitializeRequest} from 'vscode-languageserver' import { createConnection, openTextDocument, - waitForDiagnostics + waitForDiagnostics, + tsdk, + fixtureUri } from './utils.js' /** @type {ProtocolConnection} */ @@ -24,8 +26,9 @@ afterEach(() => { test('no tsconfig exists', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('no-tsconfig'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const diagnosticsPromise = waitForDiagnostics(connection) @@ -37,6 +40,7 @@ test('no tsconfig exists', async () => { assert.deepEqual(diagnostics, { diagnostics: [], - uri: textDocument.uri + uri: textDocument.uri, + version: 1 }) }) diff --git a/packages/language-server/tests/plugins.test.js b/packages/language-server/tests/plugins.test.js index 643e2ed9..c5343fc3 100644 --- a/packages/language-server/tests/plugins.test.js +++ b/packages/language-server/tests/plugins.test.js @@ -7,7 +7,9 @@ import {InitializeRequest} from 'vscode-languageserver' import { createConnection, openTextDocument, - waitForDiagnostics + waitForDiagnostics, + tsdk, + fixtureUri } from './utils.js' /** @type {ProtocolConnection} */ @@ -24,8 +26,9 @@ afterEach(() => { test('frontmatter', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('frontmatter'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const diagnosticsPromise = waitForDiagnostics(connection) @@ -37,6 +40,7 @@ test('frontmatter', async () => { assert.deepEqual(diagnostics, { diagnostics: [], - uri: textDocument.uri + uri: textDocument.uri, + version: 1 }) }) diff --git a/packages/language-server/tests/prepare-rename.test.js b/packages/language-server/tests/prepare-rename.test.js index 096e0919..ab109866 100644 --- a/packages/language-server/tests/prepare-rename.test.js +++ b/packages/language-server/tests/prepare-rename.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {InitializeRequest, PrepareRenameRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +20,9 @@ afterEach(() => { test('handle prepare rename request of variable', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/a.mdx') @@ -36,43 +37,12 @@ test('handle prepare rename request of variable', async () => { }) }) -test('handle unknown rename request', async () => { - await connection.sendRequest(InitializeRequest.type, { - processId: null, - rootUri: null, - capabilities: {} - }) - - const {uri} = await openTextDocument(connection, 'node16/a.mdx') - const result = await connection.sendRequest(PrepareRenameRequest.type, { - position: {line: 0, character: 1}, - textDocument: {uri} - }) - - assert.deepEqual(result, null) -}) - -test('handle undefined prepare rename request', async () => { - await connection.sendRequest(InitializeRequest.type, { - processId: null, - rootUri: null, - capabilities: {} - }) - - const {uri} = await openTextDocument(connection, 'node16/undefined-props.mdx') - const result = await connection.sendRequest(PrepareRenameRequest.type, { - position: {line: 0, character: 35}, - textDocument: {uri} - }) - - assert.deepEqual(result, null) -}) - test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -87,8 +57,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') diff --git a/packages/language-server/tests/rename.test.js b/packages/language-server/tests/rename.test.js index 66720efd..a30baade 100644 --- a/packages/language-server/tests/rename.test.js +++ b/packages/language-server/tests/rename.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import {afterEach, beforeEach, test} from 'node:test' import {InitializeRequest, RenameRequest} from 'vscode-languageserver' -import {createConnection, fixtureUri, openTextDocument} from './utils.js' +import {createConnection, fixtureUri, openTextDocument, tsdk} from './utils.js' /** @type {ProtocolConnection} */ let connection @@ -20,8 +20,9 @@ afterEach(() => { test('handle rename request of variable for opened references', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) await openTextDocument(connection, 'node16/b.mdx') @@ -38,8 +39,8 @@ test('handle rename request of variable for opened references', async () => { { newText: 'renamed', range: { - start: {line: 1, character: 16}, - end: {line: 1, character: 17} + start: {line: 11, character: 1}, + end: {line: 11, character: 2} } }, { @@ -52,14 +53,8 @@ test('handle rename request of variable for opened references', async () => { { newText: 'renamed', range: { - end: { - character: 2, - line: 11 - }, - start: { - character: 1, - line: 11 - } + start: {line: 1, character: 16}, + end: {line: 1, character: 17} } } ], @@ -71,6 +66,15 @@ test('handle rename request of variable for opened references', async () => { end: {line: 0, character: 10} } } + ], + [fixtureUri('node16/mixed.mdx')]: [ + { + newText: 'renamed', + range: { + start: {line: 0, character: 9}, + end: {line: 0, character: 10} + } + } ] } }) @@ -79,8 +83,9 @@ test('handle rename request of variable for opened references', async () => { test('handle undefined rename request', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/undefined-props.mdx') @@ -96,8 +101,9 @@ test('handle undefined rename request', async () => { test('ignore non-existent mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const uri = fixtureUri('node16/non-existent.mdx') @@ -113,8 +119,9 @@ test('ignore non-existent mdx files', async () => { test('ignore non-mdx files', async () => { await connection.sendRequest(InitializeRequest.type, { processId: null, - rootUri: null, - capabilities: {} + rootUri: fixtureUri('node16'), + capabilities: {}, + initializationOptions: {typescript: {tsdk}} }) const {uri} = await openTextDocument(connection, 'node16/component.tsx') diff --git a/packages/language-server/tests/utils.js b/packages/language-server/tests/utils.js index 24983355..b143a622 100644 --- a/packages/language-server/tests/utils.js +++ b/packages/language-server/tests/utils.js @@ -6,6 +6,9 @@ import {spawn} from 'node:child_process' import fs from 'node:fs/promises' +import {createRequire} from 'node:module' +import path from 'node:path' +import {fileURLToPath} from 'node:url' import { createProtocolConnection, DidOpenTextDocumentNotification, @@ -14,6 +17,13 @@ import { PublishDiagnosticsNotification } from 'vscode-languageserver/node.js' +const require = createRequire(import.meta.url) + +/** + * The path to the TypeScript SDK. + */ +export const tsdk = path.dirname(require.resolve('typescript')) + /** * @returns {ProtocolConnection} * The protocol connection for the MDX language server. @@ -46,6 +56,14 @@ export function fixtureUri(fileName) { return String(new URL(`../../../fixtures/${fileName}`, import.meta.url)) } +/** + * @param {string} fileName + * @returns {string} + */ +export function fixturePath(fileName) { + return fileURLToPath(fixtureUri(fileName)) +} + /** * Make the LSP connection open a file. * diff --git a/packages/language-service/index.js b/packages/language-service/index.js index 5a181c7f..47c8940b 100644 --- a/packages/language-service/index.js +++ b/packages/language-service/index.js @@ -1 +1 @@ -export {createMdxLanguageService, isMdx} from './lib/index.js' +export {resolveConfig} from './lib/language-service.js' diff --git a/packages/language-service/lib/error.js b/packages/language-service/lib/error.js deleted file mode 100644 index fec197dc..00000000 --- a/packages/language-service/lib/error.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @typedef {import('typescript').DiagnosticWithLocation} DiagnosticWithLocation - * @typedef {import('vfile-message').VFileMessage} VFileMessage - */ - -/** - * Check whether or not an object is a vfile message. - * - * @param {unknown} object - * The object to check. - * @returns {object is VFileMessage} - * Whether or not the object is a vfile message. - */ -function isVFileMessage(object) { - if (typeof object !== 'object') { - return false - } - - if (!object) { - return false - } - - const message = /** @type {VFileMessage | Record} */ (object) - return typeof message.message === 'string' -} - -/** - * Represent an error as a TypeScript diagnostic. - * - * @param {import('typescript')} ts - * The TypeScript module to use. - * @param {unknown} error - * The error to represent. - * @returns {[DiagnosticWithLocation]} - * The error as a TypeScript diagnostic. - */ -export function toDiagnostic(ts, error) { - let start = 0 - let length = 1 - let messageText = 'An unexpecter parsing error occurred' - if (isVFileMessage(error)) { - start = error.position?.start?.offset ?? 0 - length = (error.position?.end?.offset ?? start) - start - messageText = error.reason - } - - return [ - { - category: ts.DiagnosticCategory.Error, - // @ts-expect-error A number is expected, but it’s only used for display purposes. - code: 'MDX', - messageText, - // @ts-expect-error We don’t use file. - file: undefined, - start, - length - } - ] -} diff --git a/packages/language-service/lib/index.js b/packages/language-service/lib/index.js deleted file mode 100644 index b0e72dfc..00000000 --- a/packages/language-service/lib/index.js +++ /dev/null @@ -1,896 +0,0 @@ -/** - * @typedef {import('typescript').Diagnostic} Diagnostic - * @typedef {import('typescript').DocumentSpan} DocumentSpan - * @typedef {import('typescript').LanguageService} LanguageService - * @typedef {import('typescript').LanguageServiceHost} LanguageServiceHost - * @typedef {import('typescript').NavigationBarItem} NavigationBarItem - * @typedef {import('typescript').RenameLocation} RenameLocation - * @typedef {import('typescript').SymbolDisplayPart} SymbolDisplayPart - * @typedef {import('typescript').TextSpan} TextSpan - * @typedef {import('unified').PluggableList} PluggableList - * @typedef {import('./utils.js').MDXSnapshot} MDXSnapshot - */ - -import remarkMdx from 'remark-mdx' -import remarkParse from 'remark-parse' -import {unified} from 'unified' -import {toDiagnostic} from './error.js' -import {getMarkdownDefinitionAtPosition} from './markdown.js' -import {bindAll} from './object.js' -import {getFoldingRegions} from './outline.js' -import {fakeMdxPath} from './path.js' -import {mdxToJsx, unistPositionToTextSpan} from './utils.js' - -/** - * Check whether a file is an MDX file based on its file name. - * - * @param {string} fileName - * The file name to check. - * @returns {fileName is `${string}.mdx`} - * Whether or not the filename contains MDX. - */ -export function isMdx(fileName) { - return fileName.endsWith('.mdx') -} - -/** - * Assert that a file isn’t MDX. - * - * @param {string} fileName - * The file name to check. - * @param {string} fn - * The name of the function. - */ -function assertNotMdx(fileName, fn) { - if (isMdx(fileName)) { - throw new Error(`${fn} is not supported for MDX files`) - } -} - -/** - * Correct the MDX position of a text span for MDX files. - * - * @param {MDXSnapshot} snapshot - * The MDX TypeScript snapshot the text span belongs in. - * @param {TextSpan | undefined} textSpan - * The text span to correct. - * @returns {boolean} - * True if the original text span represents a real location in the document, - * otherwise false. If it’s false, the text span should be removed from the - * results. - */ -function patchTextSpan(snapshot, textSpan) { - if (!textSpan) { - return false - } - - const realStart = snapshot.getRealPosition(textSpan.start) - if (realStart === undefined) { - return false - } - - textSpan.start = realStart - return true -} - -/** - * Correct the text spans in an MDX file. - * - * @param {MDXSnapshot} snapshot - * The MDX TypeScript snapshot that belongs to the file name. - * @param {NavigationBarItem[]} items - * The navigation bar items to patch. - */ -function patchNavigationBarItem(snapshot, items) { - return items.filter((item) => { - item.spans = item.spans.filter((span) => patchTextSpan(snapshot, span)) - if (item.spans.length === 0) { - return false - } - - item.childItems = patchNavigationBarItem(snapshot, item.childItems) - return true - }) -} - -/** - * Create an MDX language service. - * - * The MDX language service wraps a TypeScript language service, but it can also - * handle MDX files. - * - * Most implementations work as follows: - * - * 1. Convert MDX code to JavaScript. - * 2. Let TypeScript process the JavaScript. - * 3. Convert any positional info back to its original location. - * - * In addition it supports some markdown features. - * - * @param {import('typescript')} ts - * The TypeScript module to use for creating the TypeScript language service. - * @param {LanguageServiceHost} host - * The TypeScript language service host. See - * https://github.com/microsoft/TypeScript/wiki/Using-the-Language-Service-API#language-service-host - * @param {PluggableList} [plugins] - * A list of remark plugins. Only syntax parser plugins are supported. For - * example `remark-frontmatter`, but not `remark-mdx-frontmatter` - * @returns {LanguageService} - * A TypeScript language service that can also handle MDX files. - */ -export function createMdxLanguageService(ts, host, plugins) { - const processor = unified().use(remarkParse).use(remarkMdx) - if (plugins) { - processor.use(plugins) - } - - /** @type {Map} */ - const scriptVersions = new Map() - /** @type {Map} */ - const scriptSnapshots = new Map() - const internalHost = bindAll(host) - - internalHost.getCompilationSettings = () => ({ - // Default to the JSX automatic runtime, because that’s what MDX does. - jsx: ts.JsxEmit.ReactJSX, - // Set these defaults to match MDX if the user explicitly sets the classic runtime. - jsxFactory: 'React.createElement', - jsxFragmentFactory: 'React.Fragment', - // Set this default to match MDX if the user overrides the import source. - jsxImportSource: 'react', - ...host.getCompilationSettings(), - // Always allow JS for type checking. - allowJs: true, - // This internal TypeScript property lets TypeScript load `.mdx` files. - allowNonTsExtensions: true - }) - - internalHost.getScriptKind = (fileName) => { - // Tell TypeScript to treat MDX files as JSX (not JS nor TSX). - if (isMdx(fileName)) { - return ts.ScriptKind.JSX - } - - // ScriptKind.Unknown tells TypeScript to resolve it internally. - // https://github.com/microsoft/TypeScript/blob/v4.9.4/src/compiler/utilities.ts#L6968 - // Note that ScriptKind.Unknown is 0, so it’s falsy. - // https://github.com/microsoft/TypeScript/blob/v4.9.4/src/compiler/types.ts#L6750 - return host.getScriptKind?.(fileName) ?? ts.ScriptKind.Unknown - } - - // `getScriptSnapshot` and `getScriptVersion` handle file synchronization in - // the TypeScript language service and play closely together. Before every - // method invocation, the language service synchronizes files. At first, it - // checks the snapshot version. If this is unchanged, it uses the existing - // snapshot. Otherwise it will request a new snapshot. - // - // The MDX language service hooks into this mechanism to handle conversion of - // MDX files to JSX, which TypeScript can handle. - internalHost.getScriptSnapshot = (fileName) => { - // For non-MDX files, there’s no need to perform any MDX specific - // synchronization. - if (!isMdx(fileName)) { - return host.getScriptSnapshot(fileName) - } - - return getMdxSnapshot(fileName) - } - - internalHost.getScriptVersion = (fileName) => { - const externalVersion = host.getScriptVersion(fileName) - // Since we’re only interested in processing MDX files, we can just forward - // non-MDX snapshot versions. - if (!isMdx(fileName)) { - return externalVersion - } - - const internalVersion = scriptVersions.get(fileName) - // If the external version is different from the internal, this means the - // file was updates, so we need to clear the cached snapshot. - if (externalVersion !== internalVersion) { - scriptSnapshots.delete(fileName) - scriptVersions.set(fileName, externalVersion) - } - - return externalVersion - } - - // When resolving an MDX file, TypeScript will try to resolve a file with the - // `.jsx` file extension. Here we make sure to work around that. - internalHost.resolveModuleNames = ( - moduleNames, - containingFile, - _reusedNames, - redirectedReference, - options - ) => - moduleNames.map((moduleName) => { - const resolvedModule = ts.resolveModuleName( - moduleName, - containingFile, - options, - { - ...internalHost, - readFile: (fileName) => host.readFile(fakeMdxPath(fileName)), - fileExists: (fileName) => host.fileExists(fakeMdxPath(fileName)) - }, - undefined, - redirectedReference - ).resolvedModule - - if (resolvedModule) { - resolvedModule.resolvedFileName = fakeMdxPath( - resolvedModule.resolvedFileName - ) - } - - return resolvedModule - }) - - const ls = ts.createLanguageService(internalHost) - - /** - * @param {string} fileName - * @returns {MDXSnapshot | undefined} - */ - function getMdxSnapshot(fileName) { - const snapshot = scriptSnapshots.get(fileName) - // `getScriptVersion` below deletes the snapshot if the version is outdated. - // So if the snapshot exists at this point, this means it’s ok to return - // as-is. - if (snapshot) { - return snapshot - } - - // If there is am existing snapshot, we need to synchronize from the host. - const externalSnapshot = host.getScriptSnapshot(fileName) - if (!externalSnapshot) { - return - } - - // Here we use the snapshot from the original host. Since this has MDX - // content, we need to convert it to JSX. - const length = externalSnapshot.getLength() - const mdx = externalSnapshot.getText(0, length) - const newSnapshot = mdxToJsx(mdx, processor) - newSnapshot.dispose = () => { - externalSnapshot.dispose?.() - scriptSnapshots.delete(fileName) - scriptVersions.delete(fileName) - } - - // It’s cached, so we only need to convert the MDX to JSX once. - scriptSnapshots.set(fileName, newSnapshot) - return newSnapshot - } - - /** - * Synchronize a snapshot with the external host. - * - * This function should be called first in every language service method - * pverride. - * - * @param {string} fileName - * The file name to synchronize. - * @returns {MDXSnapshot | undefined} - * The synchronized MDX snapshot. - */ - function syncSnapshot(fileName) { - // If it’s not an MDX file, there’s nothing to do. - if (!isMdx(fileName)) { - return - } - - // If the internal and external snapshot versions are the same, and a - // snapshot is present, this means it’s up-to-date, so there’s no need to - // sychronize. - const snapshot = getMdxSnapshot(fileName) - const externalVersion = host.getScriptVersion(fileName) - const internalVersion = scriptVersions.get(fileName) - if (internalVersion === externalVersion && snapshot) { - return snapshot - } - - // If there is am existing snapshot, we need to synchronize from the host. - const externalSnapshot = host.getScriptSnapshot(fileName) - if (!externalSnapshot) { - return - } - - // Here we use the snapshot from the original host. Since this has MDX - // content, we need to convert it to JSX. - const length = externalSnapshot.getLength() - const mdx = externalSnapshot.getText(0, length) - const newSnapshot = mdxToJsx(mdx, processor) - newSnapshot.dispose = () => { - externalSnapshot.dispose?.() - scriptSnapshots.delete(fileName) - scriptVersions.delete(fileName) - } - - // It’s cached, so we only need to convert the MDX to JSX once. Also the - // version is cached for later comparison. - scriptSnapshots.set(fileName, newSnapshot) - scriptVersions.set(fileName, externalVersion) - return newSnapshot - } - - /** - * @template {DocumentSpan} T - * @param {readonly T[]} documentSpans - * @returns {T[]} - */ - function patchDocumentSpans(documentSpans) { - /** @type {T[]} */ - const result = [] - for (const documentSpan of documentSpans) { - if (isMdx(documentSpan.fileName)) { - const snapshot = getMdxSnapshot(documentSpan.fileName) - if (!snapshot) { - // This should never occur - continue - } - - if (!patchTextSpan(snapshot, documentSpan.textSpan)) { - continue - } - - if (!patchTextSpan(snapshot, documentSpan.contextSpan)) { - delete documentSpan.contextSpan - } - } - - result.push(documentSpan) - - if ( - !documentSpan.originalFileName || - !isMdx(documentSpan.originalFileName) - ) { - continue - } - - const originalSnapshot = getMdxSnapshot(documentSpan.originalFileName) - if (originalSnapshot) { - if ( - !patchTextSpan(originalSnapshot, documentSpan.originalContextSpan) - ) { - delete documentSpan.originalContextSpan - } - - if (!patchTextSpan(originalSnapshot, documentSpan.originalTextSpan)) { - delete documentSpan.originalTextSpan - } - } - } - - return result - } - - /** - * @template {Diagnostic} T - * @param {readonly T[]} diagnostics - * @returns {T[]} - */ - function patchDiagnostics(diagnostics) { - /** @type {T[]} */ - const result = [] - - for (const diagnostic of diagnostics) { - const fileName = diagnostic.file?.fileName - if (!fileName || !isMdx(fileName)) { - result.push(diagnostic) - continue - } - - const snapshot = getMdxSnapshot(fileName) - - if (!snapshot) { - continue - } - - if (diagnostic.start === undefined) { - result.push(diagnostic) - } - - if (patchTextSpan(snapshot, /** @type {TextSpan} */ (diagnostic))) { - result.push(diagnostic) - } - } - - return result - } - - return { - applyCodeActionCommand(fileNameOrAction, formatSettignsOrAction) { - // @ts-expect-error The deprecated function signature prevents proper type - // safety. - return ls.applyCodeActionCommand(fileNameOrAction, formatSettignsOrAction) - }, - - cleanupSemanticCache() { - ls.cleanupSemanticCache() - }, - - commentSelection: notImplemented('commentSelection'), - - dispose() { - ls.dispose() - }, - - findReferences(fileName, position) { - const referenceSymbols = ls.findReferences(fileName, position) - - if (referenceSymbols) { - for (const referenceSymbol of referenceSymbols) { - patchDocumentSpans(referenceSymbol.references) - } - } - - return referenceSymbols - }, - - findRenameLocations( - fileName, - position, - findInStrings, - findInComments, - providePrefixAndSuffixTextForRename - ) { - const snapshot = syncSnapshot(fileName) - const locations = ls.findRenameLocations( - fileName, - snapshot?.getShadowPosition(position) ?? position, - findInStrings, - findInComments, - /** @type {boolean} */ (providePrefixAndSuffixTextForRename) - ) - - if (!locations) { - return - } - - /** @type {RenameLocation[]} */ - const result = [] - for (const location of locations) { - if (isMdx(location.fileName)) { - const locationSnapshot = getMdxSnapshot(location.fileName) - if (!locationSnapshot) { - // This should never occur! - continue - } - - if (!patchTextSpan(locationSnapshot, location.textSpan)) { - continue - } - - if (!patchTextSpan(locationSnapshot, location.contextSpan)) { - delete location.contextSpan - } - } - - result.push(location) - } - - return locations - }, - - getApplicableRefactors: notImplemented('getApplicableRefactors'), - - getBraceMatchingAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - const textSpans = ls.getBraceMatchingAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position - ) - - if (!snapshot) { - return textSpans - } - - return textSpans.filter((textSpan) => patchTextSpan(snapshot, textSpan)) - }, - - getBreakpointStatementAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - const textSpan = ls.getBreakpointStatementAtPosition(fileName, position) - - if (!textSpan) { - return - } - - if (snapshot) { - patchTextSpan(snapshot, textSpan) - } - - return textSpan - }, - - getCodeFixesAtPosition: notImplemented('getCodeFixesAtPosition'), - - getCombinedCodeFix(scope, fixId, formatOptions, preferences) { - assertNotMdx(scope.fileName, 'getCombinedCodeFix') - return ls.getCombinedCodeFix(scope, fixId, formatOptions, preferences) - }, - - getCompilerOptionsDiagnostics() { - return ls.getCompilerOptionsDiagnostics() - }, - - getCompletionEntryDetails( - fileName, - position, - entryName, - formatOptions, - source, - preferences, - data - ) { - const snapshot = syncSnapshot(fileName) - return ls.getCompletionEntryDetails( - fileName, - snapshot?.getShadowPosition(position) ?? position, - entryName, - formatOptions, - source, - preferences, - data - ) - }, - - getCompletionEntrySymbol: notImplemented('getCompletionEntrySymbol'), - - getCompletionsAtPosition(fileName, position, options, formattingSettings) { - const snapshot = syncSnapshot(fileName) - - if (snapshot && !snapshot.isJavaScript(position)) { - return - } - - const completionInfo = ls.getCompletionsAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position, - options, - formattingSettings - ) - - if (!completionInfo) { - return - } - - if (snapshot) { - if (!patchTextSpan(snapshot, completionInfo.optionalReplacementSpan)) { - delete completionInfo.optionalReplacementSpan - } - - if (completionInfo.entries) { - for (const entry of completionInfo.entries) { - if (!patchTextSpan(snapshot, entry.replacementSpan)) { - delete entry.replacementSpan - } - } - } - } - - return completionInfo - }, - - getDefinitionAndBoundSpan: notImplemented('getDefinitionAndBoundSpan'), - - getDefinitionAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - - let definition = ls.getDefinitionAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position - ) - - if (definition) { - definition = patchDocumentSpans(definition) - } - - if (snapshot) { - const node = getMarkdownDefinitionAtPosition(snapshot.ast, position) - - if (node?.position) { - const result = definition ?? [] - return [ - ...result, - { - textSpan: unistPositionToTextSpan(node.position), - fileName, - kind: ts.ScriptElementKind.linkName, - name: fileName, - containerKind: ts.ScriptElementKind.linkName, - containerName: fileName - } - ] - } - } - - return definition - }, - - getDocCommentTemplateAtPosition: notImplemented( - 'getDocCommentTemplateAtPosition' - ), - getDocumentHighlights: notImplemented('getDocumentHighlights'), - - getEditsForFileRename( - oldFilePath, - newFilePath, - formatOptions, - preferences - ) { - assertNotMdx(newFilePath, 'getEditsForRefactor') - return ls.getEditsForFileRename( - oldFilePath, - newFilePath, - formatOptions, - preferences - ) - }, - - getEditsForRefactor: notImplemented('getEditsForRefactor'), - getEmitOutput: notImplemented('getEmitOutput'), - getEncodedSemanticClassifications: notImplemented( - 'getEncodedSemanticClassifications' - ), - getEncodedSyntacticClassifications: notImplemented( - 'getEncodedSyntacticClassifications' - ), - getFileReferences: notImplemented('getFileReferences'), - getFormattingEditsAfterKeystroke: notImplemented( - 'getFormattingEditsAfterKeystroke' - ), - getFormattingEditsForDocument: notImplemented( - 'getFormattingEditsForDocument' - ), - getFormattingEditsForRange: notImplemented('getFormattingEditsForRange'), - getImplementationAtPosition: notImplemented('getImplementationAtPosition'), - getIndentationAtPosition: notImplemented('getIndentationAtPosition'), - getJsxClosingTagAtPosition: notImplemented('getJsxClosingTagAtPosition'), - getLinkedEditingRangeAtPosition: notImplemented( - 'getLinkedEditingRangeAtPosition' - ), - getMoveToRefactoringFileSuggestions: notImplemented( - 'getMoveToRefactoringFileSuggestions' - ), - getNameOrDottedNameSpan: notImplemented('getNameOrDottedNameSpan'), - - getNavigateToItems(searchValue, maxResultCount, fileName, excludeDtsFiles) { - if (fileName) { - assertNotMdx(fileName, 'getNavigateToItems') - } - - return ls.getNavigateToItems( - searchValue, - maxResultCount, - fileName, - excludeDtsFiles - ) - }, - - getNavigationBarItems(fileName) { - const snapshot = syncSnapshot(fileName) - const navigationBarItems = ls.getNavigationBarItems(fileName) - - if (snapshot) { - return patchNavigationBarItem(snapshot, navigationBarItems) - } - - return navigationBarItems - }, - - getNavigationTree: notImplemented('getNavigationTree'), - - getOutliningSpans(fileName) { - const snapshot = syncSnapshot(fileName) - const outliningSpans = ls.getOutliningSpans(fileName) - - if (!snapshot) { - return outliningSpans - } - - const results = getFoldingRegions(ts, snapshot.ast) - for (const span of outliningSpans) { - if (!patchTextSpan(snapshot, span.textSpan)) { - continue - } - - if (!patchTextSpan(snapshot, span.hintSpan)) { - continue - } - - results.push(span) - } - - return results.sort( - (a, b) => - a.textSpan.start - b.textSpan.start || - a.textSpan.length - b.textSpan.length || - a.kind.localeCompare(b.kind) - ) - }, - - getProgram() { - return ls.getProgram() - }, - - getQuickInfoAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - - const quickInfo = ls.getQuickInfoAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position - ) - - if (!snapshot) { - return quickInfo - } - - if (quickInfo && patchTextSpan(snapshot, quickInfo.textSpan)) { - return quickInfo - } - - const node = getMarkdownDefinitionAtPosition(snapshot.ast, position) - - if (!node?.position) { - return - } - - /** @type {SymbolDisplayPart[]} */ - const displayParts = [ - {text: '[', kind: 'punctuation'}, - {text: node.identifier, kind: 'aliasName'}, - {text: ']', kind: 'punctuation'}, - {text: ':', kind: 'punctuation'}, - {text: ' ', kind: 'space'}, - {text: node.url, kind: 'aliasName'} - ] - if (node.title) { - displayParts.push( - {text: ' ', kind: 'space'}, - {text: JSON.stringify(node.title), kind: 'stringLiteral'} - ) - } - - return { - kind: ts.ScriptElementKind.linkName, - kindModifiers: 'asd', - textSpan: unistPositionToTextSpan(node.position), - displayParts - } - }, - - getReferencesAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - const referenceEntries = ls.getReferencesAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position - ) - - if (referenceEntries) { - patchDocumentSpans(referenceEntries) - } - - return referenceEntries - }, - - getRenameInfo(fileName, position, options) { - const snapshot = syncSnapshot(fileName) - const info = ls.getRenameInfo( - fileName, - snapshot?.getShadowPosition(position) ?? position, - options - ) - - if (snapshot && info.canRename) { - if (patchTextSpan(snapshot, info.triggerSpan)) { - return info - } - - return { - canRename: false, - localizedErrorMessage: - 'Could not map the rename info to the MDX source' - } - } - - return info - }, - - getSemanticClassifications: notImplemented('getSemanticClassifications'), - - getSemanticDiagnostics(fileName) { - syncSnapshot(fileName) - const diagnostics = ls.getSemanticDiagnostics(fileName) - - return patchDiagnostics(diagnostics) - }, - - getSignatureHelpItems: notImplemented('getSignatureHelpItems'), - getSmartSelectionRange: notImplemented('getSmartSelectionRange'), - getSpanOfEnclosingComment: notImplemented('getSpanOfEnclosingComment'), - - getSuggestionDiagnostics(fileName) { - syncSnapshot(fileName) - const diagnostics = ls.getSuggestionDiagnostics(fileName) - - return patchDiagnostics(diagnostics) - }, - - getSyntacticClassifications: notImplemented('getSyntacticClassifications'), - - getSyntacticDiagnostics(fileName) { - const snapshot = syncSnapshot(fileName) - if (snapshot?.error) { - return toDiagnostic(ts, snapshot.error) - } - - const diagnostics = ls.getSyntacticDiagnostics(fileName) - - return patchDiagnostics(diagnostics) - }, - - getTodoComments: notImplemented('getTodoComments'), - - getTypeDefinitionAtPosition(fileName, position) { - const snapshot = syncSnapshot(fileName) - - const definition = ls.getDefinitionAtPosition( - fileName, - snapshot?.getShadowPosition(position) ?? position - ) - - if (definition) { - return patchDocumentSpans(definition) - } - }, - - isValidBraceCompletionAtPosition: notImplemented( - 'isValidBraceCompletionAtPosition' - ), - - organizeImports(args, formatOptions, preferences) { - assertNotMdx(args.fileName, 'organizeImports') - return ls.organizeImports(args, formatOptions, preferences) - }, - - prepareCallHierarchy: notImplemented('prepareCallHierarchy'), - provideCallHierarchyIncomingCalls: notImplemented( - 'provideCallHierarchyIncomingCalls' - ), - provideCallHierarchyOutgoingCalls: notImplemented( - 'provideCallHierarchyOutgoingCalls' - ), - provideInlayHints: notImplemented('provideInlayHints'), - toggleLineComment: notImplemented('toggleLineComment'), - toggleMultilineComment: notImplemented('toggleMultilineComment'), - getSupportedCodeFixes: notImplemented('getSupportedCodeFixes'), - uncommentSelection: notImplemented('uncommentSelection') - } - - /** - * Mark a method as not implemented for MDX files. - * - * This returns a function that can process JavaScript or TypeScript files, - * but will throw an error if given an MDX file. - * - * This only works for calls that take a file name as their first argument. - * - * @template {keyof LanguageService} T - * The name of the method. - * @param {T} name - * The name of the method. - * @returns {LanguageService[T]} - * A function that wraps the original language service method. - */ - function notImplemented(name) { - // @ts-expect-error - return (fileName, ...args) => { - assertNotMdx(fileName, name) - // @ts-expect-error - return ls[name](fileName, ...args) - } - } -} diff --git a/packages/language-service/lib/language-module.js b/packages/language-service/lib/language-module.js new file mode 100644 index 00000000..49396145 --- /dev/null +++ b/packages/language-service/lib/language-module.js @@ -0,0 +1,378 @@ +/** + * @typedef {import('@volar/language-core').Language} Language + * @typedef {import('@volar/language-core').VirtualFile} VirtualFile + * @typedef {import('estree').Program} Program + * @typedef {import('mdast').Root} Root + * @typedef {import('typescript').IScriptSnapshot} IScriptSnapshot + * @typedef {import('unified').PluggableList} PluggableList + * @typedef {import('unified').Processor} Processor + * @typedef {import('unist').Node} Node + * @typedef {import('unist').Parent} Parent + * + * @typedef {[start: number, end: number]} OffsetRange + */ + +import { + FileCapabilities, + FileKind, + FileRangeCapabilities +} from '@volar/language-server' +import remarkMdx from 'remark-mdx' +import remarkParse from 'remark-parse' +import {unified} from 'unified' +import {visitParents} from 'unist-util-visit-parents' + +const componentStart = ` +/** + * Render the MDX contents. + * + * @param {MDXContentProps} props + * The props that have been passed to the MDX component. + */ +export default function MDXContent(props) { + return <> +` +const componentEnd = ` + +} + +// @ts-ignore +/** @typedef {Props} MDXContentProps */ +` + +const fallback = componentStart + componentEnd + +const whitespaceRegex = /\s/u + +/** + * @param {OffsetRange[]} positions + * @param {number} index + * @returns {boolean} XXX + */ +function shouldShow(positions, index) { + return positions.some(([start, end]) => start <= index && index < end) +} + +/** + * @param {OffsetRange} a + * @param {OffsetRange} b + * @returns {number} XXX + */ +function compareRanges(a, b) { + return a[0] - b[0] || a[1] - b[1] +} + +/** + * @param {Parent} node + * @returns {number | undefined} XXX + */ +function findFirstOffset(node) { + for (const child of node.children) { + const start = child.position?.start?.offset + if (start !== undefined) { + return start + } + } +} + +/** + * @param {Parent} node + * @returns {number | undefined} XXX + */ +function findLastOffset(node) { + for (let index = node.children.length - 1; index >= 0; index--) { + const end = node.children[index].position?.end?.offset + if (end !== undefined) { + return end + } + } +} + +/** + * @param {string} fileName + * @param {IScriptSnapshot} snapshot + * @param {typeof import('typescript')} ts + * @param {Processor} processor + * @returns {VirtualFile[]} + */ +function getVirtualFiles(fileName, snapshot, ts, processor) { + const mdx = snapshot.getText(0, snapshot.getLength()) + /** @type {VirtualFile['mappings']} */ + const jsxMappings = [] + /** @type {VirtualFile['mappings']} */ + const mdMappings = [] + /** @type {Root} */ + let ast + + try { + ast = processor.parse(mdx) + } catch { + return [ + { + capabilities: {}, + codegenStacks: [], + embeddedFiles: [], + fileName: fileName + '.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: jsxMappings, + snapshot: ts.ScriptSnapshot.fromString(fallback) + }, + { + capabilities: {}, + codegenStacks: [], + embeddedFiles: [], + fileName: fileName + '.md', + kind: FileKind.TypeScriptHostFile, + mappings: mdMappings, + snapshot: ts.ScriptSnapshot.fromString(mdx) + } + ] + } + + /** @type {OffsetRange[]} */ + const esmPositions = [] + /** @type {OffsetRange[]} */ + const jsxPositions = [] + /** @type {VirtualFile[]} */ + const virtualFiles = [] + + visitParents(ast, (node) => { + const start = node.position?.start?.offset + const end = node.position?.end?.offset + + if (start === undefined || end === undefined) { + return + } + + switch (node.type) { + case 'yaml': { + const frontmatterWithFences = mdx.slice(start, end) + const frontmatterStart = frontmatterWithFences.indexOf(node.value) + virtualFiles.push({ + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: fileName + '.yaml', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [ + frontmatterStart, + frontmatterStart + node.value.length + ], + generatedRange: [0, node.value.length], + data: FileRangeCapabilities.full + } + ], + snapshot: ts.ScriptSnapshot.fromString(node.value) + }) + + break + } + + case 'mdxjsEsm': { + esmPositions.push([start, end]) + break + } + + case 'mdxJsxFlowElement': { + const firstOffset = findFirstOffset(node) + const lastOffset = findLastOffset(node) + if (firstOffset === undefined || lastOffset === undefined) { + jsxPositions.push([start, end]) + break + } + + jsxPositions.push([start, firstOffset], [lastOffset, end]) + break + } + + case 'mdxFlowExpression': + case 'mdxJsxTextElement': + case 'mdxTextExpression': { + jsxPositions.push([start, end]) + if (/** @type {Program} */ (node.data?.estree)?.body.length === 0) { + esmPositions.push([start + 1, end - 1]) + } + + break + } + + default: { + break + } + } + }) + + esmPositions.sort(compareRanges) + jsxPositions.sort(compareRanges) + let esmShadow = '' + let jsxShadow = '' + let mdShadow = '' + /** @type {number | undefined} */ + let mdChunkStart + + // eslint-disable-next-line unicorn/no-for-loop + for (let index = 0; index < mdx.length; index++) { + const char = mdx[index] + + if (whitespaceRegex.test(char)) { + esmShadow += char + jsxShadow += char + mdShadow += char + continue + } + + const shouldShowEsm = shouldShow(esmPositions, index) + const shouldShowJsx = shouldShow(jsxPositions, index) + esmShadow += shouldShowEsm ? char : ' ' + jsxShadow += shouldShowJsx ? char : ' ' + if (shouldShowEsm || shouldShowJsx) { + mdShadow += ' ' + if (mdChunkStart !== undefined) { + mdMappings.push({ + sourceRange: [mdChunkStart, index], + generatedRange: [mdChunkStart, index], + data: FileRangeCapabilities.full + }) + } + + mdChunkStart = undefined + } else { + mdShadow += char + if (mdChunkStart === undefined) { + mdChunkStart = index + } + } + } + + if (mdChunkStart !== undefined) { + mdMappings.push({ + sourceRange: [mdChunkStart, mdx.length - 1], + generatedRange: [mdChunkStart, mdx.length - 1], + data: FileRangeCapabilities.full + }) + } + + const jsxStart = esmShadow.length + componentStart.length + const js = esmShadow + componentStart + jsxShadow + componentEnd + + for (const [start, end] of esmPositions) { + jsxMappings.push({ + sourceRange: [start, end], + generatedRange: [start, end], + data: FileRangeCapabilities.full + }) + } + + for (const [start, end] of jsxPositions) { + jsxMappings.push({ + sourceRange: [start, end], + generatedRange: [start + jsxStart, end + jsxStart], + data: FileRangeCapabilities.full + }) + } + + virtualFiles.unshift( + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: fileName + '.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: jsxMappings, + snapshot: ts.ScriptSnapshot.fromString(js) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: fileName + '.md', + kind: FileKind.TypeScriptHostFile, + mappings: mdMappings, + snapshot: ts.ScriptSnapshot.fromString(mdShadow) + } + ) + + return virtualFiles +} + +/** + * @param {typeof import('typescript')} ts + * @param {PluggableList} [plugins] + * @returns {Language} + */ +export function getLanguageModule(ts, plugins) { + const processor = unified().use(remarkParse).use(remarkMdx) + if (plugins) { + processor.use(plugins) + } + + processor.freeze() + + return { + createVirtualFile(fileName, snapshot) { + if (!fileName.endsWith('.mdx')) { + return + } + + const length = snapshot.getLength() + + return { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: getVirtualFiles(fileName, snapshot, ts, processor), + fileName, + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, length], + generatedRange: [0, length], + data: FileRangeCapabilities.full + } + ], + snapshot + } + }, + + updateVirtualFile(mdxFile, snapshot) { + mdxFile.snapshot = snapshot + + const length = snapshot.getLength() + mdxFile.mappings = [ + { + sourceRange: [0, length], + generatedRange: [0, length], + data: FileRangeCapabilities.full + } + ] + + mdxFile.embeddedFiles = getVirtualFiles( + mdxFile.fileName, + snapshot, + ts, + processor + ) + }, + + resolveHost(host) { + return { + ...host, + getCompilationSettings: () => ({ + // Default to the JSX automatic runtime, because that’s what MDX does. + jsx: ts.JsxEmit.ReactJSX, + // Set these defaults to match MDX if the user explicitly sets the classic runtime. + jsxFactory: 'React.createElement', + jsxFragmentFactory: 'React.Fragment', + // Set this default to match MDX if the user overrides the import source. + jsxImportSource: 'react', + ...host.getCompilationSettings(), + // Always allow JS for type checking. + allowJs: true, + // This internal TypeScript property lets TypeScript load `.mdx` files. + allowNonTsExtensions: true + }) + } + } + } +} diff --git a/packages/language-service/lib/language-service.js b/packages/language-service/lib/language-service.js new file mode 100644 index 00000000..bd9c5354 --- /dev/null +++ b/packages/language-service/lib/language-service.js @@ -0,0 +1,27 @@ +/** + * @typedef {import('@volar/language-service').Config} Config + * @typedef {import('unified').PluggableList} PluggableList + */ + +import createTypeScriptService from 'volar-service-typescript' +import {getLanguageModule} from './language-module.js' +import {createMarkdownService} from './volar-service-markdown/index.cjs' +import {createYamlService} from './volar-service-yaml/index.js' + +/** + * @param {Config} config + * @param {typeof import('typescript')} ts + * @param {PluggableList | undefined} [plugins] + * @returns {Config} + */ +export function resolveConfig(config, ts, plugins) { + config.languages ||= {} + config.languages.mdx ||= getLanguageModule(ts, plugins) + + config.services ||= {} + config.services.typescript = createTypeScriptService.default() + config.services.markdown = createMarkdownService() + config.services.yaml = createYamlService() + + return config +} diff --git a/packages/language-service/lib/markdown.js b/packages/language-service/lib/markdown.js deleted file mode 100644 index 0af4c105..00000000 --- a/packages/language-service/lib/markdown.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @typedef {import('mdast').Definition} Definition - * @typedef {import('mdast').LinkReference} LinkReference - * @typedef {import('mdast').Root} Root - * @typedef {import('unist').Position} Position - */ - -import {visit} from 'unist-util-visit' - -/** - * Get the definition link of a markdown AST at a given position. - * - * @param {Root} ast - * The markdown AST. - * @param {number} position - * The position to get the definition for. - * @returns {Definition | undefined} - * The position at which the definition can be found. - */ -export function getMarkdownDefinitionAtPosition(ast, position) { - /** @type {LinkReference | undefined} */ - let reference - /** @type {Map} */ - const definitions = new Map() - - visit(ast, (node) => { - const start = node.position?.start.offset - const end = node.position?.end.offset - - if (start === undefined || end === undefined) { - return - } - - if (node.type === 'linkReference') { - if (position >= start && position <= end) { - reference = node - } - } else if ( - node.type === 'definition' && - !definitions.has(node.identifier) - ) { - definitions.set(node.identifier, node) - } - }) - - if (!reference) { - return - } - - return definitions.get(reference.identifier) -} diff --git a/packages/language-service/lib/object.js b/packages/language-service/lib/object.js deleted file mode 100644 index 147627ca..00000000 --- a/packages/language-service/lib/object.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Create a shallow copy of the original object, but bind all functions to the original object. - * - * @template {object} T - * The type of the object. - * @param {T} object - * The object to copy. - * @returns {T} - * The shallow copy. - */ -export function bindAll(object) { - /** @type {T} */ - const copy = Object.create(null) - let proto = object - - while (proto) { - for (const key of Object.getOwnPropertyNames(proto)) { - const k = /** @type {keyof T} */ (key) - if (k === 'constructor') { - continue - } - - if (k in copy) { - continue - } - - const value = object[k] - copy[k] = - typeof value === 'function' - ? /** @type {Function} */ (value).bind(object) - : value - } - - proto = Object.getPrototypeOf(proto) - } - - return copy -} diff --git a/packages/language-service/lib/outline.js b/packages/language-service/lib/outline.js deleted file mode 100644 index 2f2153ea..00000000 --- a/packages/language-service/lib/outline.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @typedef {import('mdast').Content} Content - * @typedef {import('mdast').Parent} Parent - * @typedef {import('mdast').Root} Root - * @typedef {import('typescript').OutliningSpan} OutliningSpan - * @typedef {import('unist').Node} Node - * @typedef {import('unist').Position} Position - */ - -import {visit} from 'unist-util-visit' -import {unistPositionToTextSpan} from './utils.js' - -/** - * Create outline spans based on a markdown AST. - * - * @param {typeof import('typescript')} ts - * The TypeScript module to use. - * @param {Root} ast - * The markdown AST to get outline spans for. - * @returns {OutliningSpan[]} - * The outline spans that represent Markdown sections - */ -export function getFoldingRegions(ts, ast) { - /** @type {OutliningSpan[]} */ - const sections = [] - - visit(ast, (node) => { - if (node.position && (node.type === 'code' || node.type === 'blockquote')) { - sections.push({ - textSpan: unistPositionToTextSpan(node.position), - hintSpan: unistPositionToTextSpan(node.position), - bannerText: node.type, - autoCollapse: false, - kind: ts.OutliningSpanKind.Region - }) - } - - if (!('children' in node)) { - return - } - - /** @type {(OutliningSpan | undefined)[]} */ - const scope = [] - - for (const child of node.children) { - const end = child.position?.end?.offset - - if (end === undefined) { - continue - } - - if (child.type === 'heading') { - const index = child.depth - 1 - for (const done of scope.splice(index)) { - if (done) { - sections.push(done) - } - } - - scope[index] = { - textSpan: unistPositionToTextSpan( - /** @type {Position} */ (child.position) - ), - hintSpan: unistPositionToTextSpan( - /** @type {Position} */ (child.position) - ), - bannerText: 'Heading ' + child.depth, - autoCollapse: false, - kind: ts.OutliningSpanKind.Region - } - } - - for (const section of scope) { - if (section) { - section.textSpan.length = end - section.textSpan.start - } - } - } - - for (const section of scope) { - if (section) { - sections.push(section) - } - } - }) - - return sections -} diff --git a/packages/language-service/lib/path.js b/packages/language-service/lib/path.js deleted file mode 100644 index b50df0ad..00000000 --- a/packages/language-service/lib/path.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Remove the `.jsx` postfix from a file name ending with `.mdx.jsx`. - * - * @param {string} fileName - * The file name to process. - * @returns {string} - * The filename without the `.jsx` postfix if it ends with `.mdx.jsx`. - */ -export function fakeMdxPath(fileName) { - const postfix = '.jsx' - - if (fileName.endsWith(`.mdx${postfix}`)) { - return fileName.slice(0, -postfix.length) - } - - return fileName -} diff --git a/packages/language-service/lib/utils.js b/packages/language-service/lib/utils.js deleted file mode 100644 index 168dd43d..00000000 --- a/packages/language-service/lib/utils.js +++ /dev/null @@ -1,283 +0,0 @@ -/** - * @typedef {import('estree').Program} Program - * @typedef {import('mdast').Root} Root - * @typedef {import('typescript').IScriptSnapshot} IScriptSnapshot - * @typedef {import('typescript').TextSpan} TextSpan - * @typedef {import('unified').Processor} Processor - * @typedef {import('unist').Node} Node - * @typedef {import('unist').Parent} Parent - * @typedef {import('unist').Position} Position - * - * @typedef {[start: number, end: number]} OffsetRange - * - * @typedef {object} MDXShadow - * @property {(start?: number, end?: number) => string} getText - * Same as {@link IScriptSnapshot.getText}, except omitting start and end, - * returns the entire text. - * @property {(position: number) => number | undefined} getShadowPosition - * Map a position from the real MDX document to the JSX shadow document. - * @property {(shadowPosition: number) => number | undefined} getRealPosition - * Map a position from the shadow document to the real MDX document. - * @property {(position: number) => boolean} isJavaScript - * Check if a position maps to JavaScript or markdown content. - * @property {unknown} [error] - * This is defined if a parsing error has occurred. - * @property {Root} ast - * The markdown AST (mdast). - * - * @typedef {MDXShadow & IScriptSnapshot} MDXSnapshot - */ - -import {visit} from 'unist-util-visit' - -const componentStart = ` -/** - * Render the MDX contents. - * - * @param {MDXContentProps} props - * The props that have been passed to the MDX component. - */ -export default function MDXContent(props) { - return <> -` -const componentEnd = ` - -} - -// @ts-ignore -/** @typedef {Props} MDXContentProps */ -` - -const fallback = 'export {}\n' - -const whitespaceRegex = /\s/u - -/** - * @param {OffsetRange[]} positions - * @param {number} index - * @returns {boolean} XXX - */ -function shouldShow(positions, index) { - return positions.some(([start, end]) => start <= index && index < end) -} - -/** - * @param {OffsetRange} a - * @param {OffsetRange} b - * @returns {number} XXX - */ -function compareRanges(a, b) { - return a[0] - b[0] || a[1] - b[1] -} - -/** - * @param {Parent} node - * @returns {number | undefined} XXX - */ -function findFirstOffset(node) { - for (const child of node.children) { - const start = child.position?.start?.offset - if (start !== undefined) { - return start - } - } -} - -/** - * @param {Parent} node - * @returns {number | undefined} XXX - */ -function findLastOffset(node) { - for (let index = node.children.length - 1; index >= 0; index--) { - const end = node.children[index].position?.end?.offset - if (end !== undefined) { - return end - } - } -} - -/** - * Convert MDX into JavaScript with JSX. - * - * MDX can be categorized in 3 types of content: - * - * 1. Markdown content; This is not relevant when offering TypeScript based IntelliSense. - * 2. ESM content; This includes JavaScript imports and exports. When MDX is compiled, this content - * is moved to the top-level scope. - * 3. JSX content; This includes JSX elements and expressions. When MDX is compiled, this content is - * moved into a function named `MDXContent`. - * - * The problem is that ESM and JSX can be mixed inside MDX, so the function body of `MDXContent` can - * be mixed with content in the top-level scope. To turn MDX into JavaScript that the TypeScript - * language service understands, we split the ESM and JSX content, by creating a copy of the - * original document and replacing any character that doesn’t fall into that category with - * whitespace. - * - * The JSX part is then wrapped inside an `MDXContent` function declaration and a JSX fragment. The - * ESM and wrapped JSX parts are concatenated to produce valid JavaScript source code which - * represents the JavaScript parts of MDX. This result can then be processed by the TypeScript - * language service. Any positional information returned by TypeScript that represents an MDX file, - * needs to be mapped using {@link toOriginalPosition} - * - * @see https://code.visualstudio.com/api/language-extensions/embedded-languages#language-services-sample - * @param {string} mdx - * The MDX code. - * @param {Processor} processor - * The unified processor to use. - * @returns {MDXSnapshot} JavaScript code that matches the MDX code, but shadowed. - */ -export function mdxToJsx(mdx, processor) { - /** @type {Root} */ - let ast - try { - ast = processor.parse(mdx) - } catch (error) { - return { - dispose() {}, - ast: processor.parse(fallback), - error, - - getChangeRange: () => undefined, - getText: (start = 0, end = fallback.length) => fallback.slice(start, end), - getLength: () => fallback.length, - - getShadowPosition: () => undefined, - getRealPosition: () => undefined, - isJavaScript: () => true - } - } - - /** @type {OffsetRange[]} */ - const esmPositions = [] - /** @type {OffsetRange[]} */ - const jsxPositions = [] - - visit( - ast, - /** - * @param {Node} node - */ - (node) => { - const start = node.position?.start?.offset - const end = node.position?.end?.offset - - if (start === undefined || end === undefined) { - return - } - - switch (node.type) { - case 'mdxjsEsm': { - esmPositions.push([start, end]) - break - } - - case 'mdxJsxFlowElement': { - const element = /** @type {Parent} */ (node) - - const firstOffset = findFirstOffset(element) - const lastOffset = findLastOffset(element) - if (firstOffset === undefined || lastOffset === undefined) { - jsxPositions.push([start, end]) - break - } - - jsxPositions.push([start, firstOffset], [lastOffset, end]) - break - } - - case 'mdxFlowExpression': - case 'mdxJsxTextElement': - case 'mdxTextExpression': { - jsxPositions.push([start, end]) - if (/** @type {Program} */ (node.data?.estree)?.body.length === 0) { - esmPositions.push([start + 1, end - 1]) - } - - break - } - - default: { - break - } - } - } - ) - - esmPositions.sort(compareRanges) - jsxPositions.sort(compareRanges) - let esmShadow = '' - let jsxShadow = '' - - // eslint-disable-next-line unicorn/no-for-loop - for (let index = 0; index < mdx.length; index++) { - const char = mdx[index] - - if (whitespaceRegex.test(char)) { - esmShadow += char - jsxShadow += char - continue - } - - esmShadow += shouldShow(esmPositions, index) ? char : ' ' - jsxShadow += shouldShow(jsxPositions, index) ? char : ' ' - } - - const js = esmShadow + componentStart + jsxShadow + componentEnd - - return { - ast, - - dispose() {}, - - getChangeRange: () => undefined, - getText: (start = 0, end = js.length) => js.slice(start, end), - getLength: () => js.length, - getShadowPosition(position) { - if (shouldShow(esmPositions, position)) { - return position - } - - if (shouldShow(jsxPositions, position)) { - return esmShadow.length + componentStart.length + position - } - }, - getRealPosition(shadowPosition) { - if (shadowPosition <= esmShadow.length) { - return shadowPosition - } - - if (shadowPosition <= esmShadow.length + componentStart.length) { - return - } - - if ( - shadowPosition <= - esmShadow.length + componentStart.length + jsxShadow.length - ) { - return shadowPosition - esmShadow.length - componentStart.length - } - }, - isJavaScript(position) { - return ( - shouldShow(esmPositions, position) || shouldShow(jsxPositions, position) - ) - } - } -} - -/** - * Represent a unist position as a TypeScript text span. - * - * @param {Position} position - * The unist position to represent. - * @returns {TextSpan} - * The input position as a text span. - */ -export function unistPositionToTextSpan(position) { - return { - start: /** @type {number} */ (position.start.offset), - length: - /** @type {number} */ (position.end.offset) - - /** @type {number} */ (position.start.offset) - } -} diff --git a/packages/language-service/lib/volar-service-markdown/index.cjs b/packages/language-service/lib/volar-service-markdown/index.cjs new file mode 100644 index 00000000..861d9266 --- /dev/null +++ b/packages/language-service/lib/volar-service-markdown/index.cjs @@ -0,0 +1,337 @@ +/** + * @typedef {import('@volar/language-service').Service} Service + * @typedef {import('@volar/language-service').TextDocument} TextDocument + * @typedef {import('vscode-markdown-languageservice').ILogger} ILogger + * @typedef {import('vscode-markdown-languageservice').IMdParser} IMdParser + * @typedef {import('vscode-markdown-languageservice').IWorkspace} IWorkspace + */ + +const {FileType, forEachEmbeddedFile} = require('@volar/language-service') +const MarkdownIt = require('markdown-it') +const { + createLanguageService, + githubSlugifier, + DiagnosticLevel, + LogLevel +} = require('vscode-markdown-languageservice') +const {Emitter, FileChangeType} = require('vscode-languageserver-protocol') +const {URI} = require('vscode-uri') + +const md = new MarkdownIt() + +/** + * @param {TextDocument} document + * @returns {boolean} + */ +function isMarkdown(document) { + return document.languageId === 'markdown' +} + +/** + * @param {unknown} condition + * @param {string} message + * @returns {asserts condition} + */ +function assert(condition, message) { + if (!condition) { + throw new Error(message) + } +} + +/** + * @returns {Service} + */ +function createMarkdownService() { + return (context) => { + if (!context) { + return { + provide: {} + } + } + + let lastProjectVersion = context.host.getProjectVersion() + assert(context.env, 'context.env must be defined') + const {fs, onDidChangeWatchedFiles} = context.env + assert(fs, 'context.env.fs must be defined') + assert( + onDidChangeWatchedFiles, + 'context.env.fs.onDidChangeWatchedFiles must be defined' + ) + + /** @type {ILogger} */ + const logger = { + level: LogLevel.Off, + + log() {} + } + + /** @type {IMdParser} */ + const parser = { + slugifier: githubSlugifier, + + async tokenize(document) { + return md.parse(document.getText(), {}) + } + } + + /** @type {Emitter} */ + const onDidChangeMarkdownDocument = new Emitter() + /** @type {Emitter} */ + const onDidCreateMarkdownDocument = new Emitter() + /** @type {Emitter} */ + const onDidDeleteMarkdownDocument = new Emitter() + + const fileWatcher = onDidChangeWatchedFiles((event) => { + for (const change of event.changes) { + switch (change.type) { + case FileChangeType.Changed: { + const document = context.getTextDocument(change.uri) + if (document) { + onDidChangeMarkdownDocument.fire(document) + } + + break + } + + case FileChangeType.Created: { + const document = context.getTextDocument(change.uri) + if (document) { + onDidCreateMarkdownDocument.fire(document) + } + + break + } + + case FileChangeType.Deleted: { + onDidDeleteMarkdownDocument.fire(URI.parse(change.uri)) + + break + } + // No default + } + } + }) + + /** @type {IWorkspace} */ + const workspace = { + async getAllMarkdownDocuments() { + return [] + }, + + getContainingDocument() { + return undefined + }, + + hasMarkdownDocument(resource) { + const document = context.getTextDocument(String(resource)) + return Boolean(document && isMarkdown(document)) + }, + + onDidChangeMarkdownDocument: onDidChangeMarkdownDocument.event, + + onDidCreateMarkdownDocument: onDidCreateMarkdownDocument.event, + + onDidDeleteMarkdownDocument: onDidDeleteMarkdownDocument.event, + + async openMarkdownDocument(resource) { + return context.getTextDocument(String(resource)) + }, + + async readDirectory(resource) { + const directory = await fs.readDirectory(String(resource)) + return directory.map(([fileName, fileType]) => [ + fileName, + {isDirectory: fileType === FileType.Directory} + ]) + }, + + async stat(resource) { + const stat = await fs.stat(String(resource)) + if (stat) { + return {isDirectory: stat.type === FileType.Directory} + } + }, + + workspaceFolders: [] + } + + const ls = createLanguageService({ + logger, + parser, + workspace + }) + + /** @type {Map} */ + const syncedVersions = new Map() + + const sync = () => { + const newProjectVersion = context.host.getProjectVersion() + const shouldUpdate = newProjectVersion !== lastProjectVersion + if (!shouldUpdate) { + return + } + + lastProjectVersion = newProjectVersion + const oldVersions = new Set(syncedVersions.keys()) + /** @type {Map} */ + const newVersions = new Map() + + for (const {root} of context.virtualFiles.allSources()) { + forEachEmbeddedFile(root, (embedded) => { + const document = context.getTextDocument(embedded.fileName) + if (document && isMarkdown(document)) { + newVersions.set(String(document.uri), document) + } + }) + } + + for (const [uri, document] of newVersions) { + const old = syncedVersions.get(uri) + syncedVersions.set(uri, document) + if (old) { + onDidChangeMarkdownDocument.fire(document) + } else { + onDidCreateMarkdownDocument.fire(document) + } + } + + for (const uri of oldVersions) { + if (!newVersions.has(uri)) { + syncedVersions.delete(uri) + onDidDeleteMarkdownDocument.fire(URI.parse(uri)) + } + } + } + + return { + dispose() { + ls.dispose() + fileWatcher.dispose() + onDidDeleteMarkdownDocument.dispose() + onDidCreateMarkdownDocument.dispose() + onDidChangeMarkdownDocument.dispose() + }, + + provide: {}, + + async provideCodeActions(document, range, context, token) { + if (isMarkdown(document)) { + return ls.getCodeActions(document, range, context, token) + } + }, + + async provideCompletionItems(document, position, context, token) { + if (isMarkdown(document)) { + const items = await ls.getCompletionItems( + document, + position, + {}, + token + ) + return { + isIncomplete: false, + items + } + } + }, + + async provideDiagnostics(document, token) { + if (isMarkdown(document)) { + sync() + + return ls.computeDiagnostics( + document, + { + ignoreLinks: [], + validateDuplicateLinkDefinitions: DiagnosticLevel.warning, + validateFileLinks: DiagnosticLevel.warning, + validateFragmentLinks: DiagnosticLevel.warning, + validateMarkdownFileLinkFragments: DiagnosticLevel.warning, + validateReferences: DiagnosticLevel.warning, + validateUnusedLinkDefinitions: DiagnosticLevel.warning + }, + token + ) + } + }, + + async provideDocumentHighlights(document, position, token) { + if (isMarkdown(document)) { + return ls.getDocumentHighlights(document, position, token) + } + }, + + async provideDocumentLinks(document, token) { + if (isMarkdown(document)) { + return ls.getDocumentLinks(document, token) + } + }, + + async provideDocumentSymbols(document, token) { + if (isMarkdown(document)) { + return ls.getDocumentSymbols( + document, + {includeLinkDefinitions: true}, + token + ) + } + }, + + async provideFileReferences(document, token) { + if (isMarkdown(document)) { + return ls.getFileReferences(URI.parse(document.uri), token) + } + }, + + async provideFoldingRanges(document, token) { + if (isMarkdown(document)) { + return ls.getFoldingRanges(document, token) + } + }, + + async provideReferences(document, position, token) { + if (isMarkdown(document)) { + return ls.getReferences( + document, + position, + {includeDeclaration: true}, + token + ) + } + }, + + async provideRenameEdits(document, position, newName, token) { + if (isMarkdown(document)) { + console.log(document) + const result = ls.getRenameEdit(document, position, newName, token) + console.log(result) + return result + } + }, + + async provideRenameRange(document, position, token) { + if (isMarkdown(document)) { + return ls.prepareRename(document, position, token) + } + }, + + async provideSelectionRanges(document, positions, token) { + if (isMarkdown(document)) { + return ls.getSelectionRanges(document, positions, token) + } + }, + + async provideWorkspaceSymbols(query, token) { + return ls.getWorkspaceSymbols(query, token) + }, + + async resolveDocumentLink(link, token) { + const result = await ls.resolveDocumentLink(link, token) + + return result || link + } + } + } +} + +exports.createMarkdownService = createMarkdownService diff --git a/packages/language-service/lib/volar-service-markdown/index.d.cts b/packages/language-service/lib/volar-service-markdown/index.d.cts new file mode 100644 index 00000000..9ff68ebf --- /dev/null +++ b/packages/language-service/lib/volar-service-markdown/index.d.cts @@ -0,0 +1,12 @@ +/** + * + */ +export type Service = import('@volar/language-service').Service +export type TextDocument = import('@volar/language-service').TextDocument +export type ILogger = import('vscode-markdown-languageservice').ILogger +export type IMdParser = import('vscode-markdown-languageservice').IMdParser +export type IWorkspace = import('vscode-markdown-languageservice').IWorkspace +/** + * @returns {Service} + */ +export function createMarkdownService(): Service diff --git a/packages/language-service/lib/volar-service-yaml/index.js b/packages/language-service/lib/volar-service-yaml/index.js new file mode 100644 index 00000000..45f1cf8a --- /dev/null +++ b/packages/language-service/lib/volar-service-yaml/index.js @@ -0,0 +1,159 @@ +/** + * @typedef {import('@volar/language-service').Service} Service + * @typedef {import('@volar/language-service').TextDocument} TextDocument + */ + +import {getLanguageService} from 'yaml-language-server/lib/umd/languageservice/yamlLanguageService.js' + +/** + * @param {TextDocument} document + * @returns {boolean} + */ +function isYaml(document) { + return document.languageId === 'yaml' +} + +/** + * @returns {undefined} + */ +function noop() {} + +/** + * @returns {Service} + */ +export function createYamlService() { + return (context) => { + const ls = getLanguageService({ + async schemaRequestService(uri) { + if (uri.startsWith('file:') && context?.env.fs) { + const result = await context?.env.fs.readFile(uri) + if (result) { + return result + } + + throw new Error(`No such file: ${uri}`) + } + + const response = await fetch(uri) + if (response.ok) { + return response.text() + } + + throw new Error(await response.text()) + }, + telemetry: { + send: noop, + sendError: noop, + sendTrack: noop + }, + // @ts-expect-error https://github.com/redhat-developer/yaml-language-server/pull/910 + clientCapabilities: context?.env?.clientCapabilities, + workspaceContext: { + resolveRelativePath(relativePath, resource) { + return String(new URL(relativePath, resource)) + } + } + }) + + ls.configure({ + completion: true, + customTags: [], + format: true, + hover: true, + isKubernetes: false, + validate: true, + yamlVersion: '1.2' + }) + + return { + provide: {}, + + triggerCharacters: [' ', ':'], + + async provideCodeActions(document, range, context) { + if (isYaml(document)) { + return ls.getCodeAction(document, { + context, + range, + textDocument: document + }) + } + }, + + async provideCodeLenses(document) { + if (isYaml(document)) { + return ls.getCodeLens(document) + } + }, + + async provideCompletionItems(document, position) { + if (isYaml(document)) { + return ls.doComplete(document, position, false) + } + }, + + async provideDefinition(document, position) { + if (isYaml(document)) { + return ls.doDefinition(document, {position, textDocument: document}) + } + }, + + async provideDiagnostics(document) { + if (isYaml(document)) { + return ls.doValidation(document, false) + } + }, + + async provideDocumentSymbols(document) { + if (isYaml(document)) { + return ls.findDocumentSymbols2(document, {}) + } + }, + + async provideHover(document, position) { + if (isYaml(document)) { + return ls.doHover(document, position) + } + }, + + async provideDocumentLinks(document) { + if (isYaml(document)) { + return ls.findLinks(document) + } + }, + + async provideFoldingRanges(document) { + if (isYaml(document)) { + return ls.getFoldingRanges(document, {}) + } + }, + + async provideOnTypeFormattingEdits(document, position, ch, options) { + if (isYaml(document)) { + return ls.doDocumentOnTypeFormatting(document, { + ch, + options, + position, + textDocument: document + }) + } + }, + + async provideDocumentFormattingEdits(document) { + if (isYaml(document)) { + return ls.doFormat(document, {}) + } + }, + + async provideSelectionRanges(document, positions) { + if (isYaml(document)) { + return ls.getSelectionRanges(document, positions) + } + }, + + async resolveCodeLens(codeLens) { + return ls.resolveCodeLens(codeLens) + } + } + } +} diff --git a/packages/language-service/package.json b/packages/language-service/package.json index 0927cb9a..dbd6c2eb 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -29,13 +29,30 @@ ], "scripts": { "build": "tsc --build --clean && tsc --build", - "prepack": "npm run build" + "prepack": "npm run build", + "test": "node --test" }, "dependencies": { + "@types/estree": "^1.0.0", + "@types/mdast": "^3.0.0", + "@types/markdown-it": "^13.0.0", + "@types/unist": "^2.0.0", + "@volar/language-core": "~1.10.0", + "@volar/language-service": "~1.10.0", + "markdown-it": "^13.0.0", "remark-mdx": "^2.0.0", "remark-parse": "^10.0.0", "unified": "^10.0.0", - "unist-util-visit": "^4.0.0" + "unist-util-visit-parents": "^5.0.0", + "volar-service-typescript": "0.0.11", + "vscode-markdown-languageservice": "0.4.0-alpha.5", + "vscode-languageserver-protocol": "^3.0.0", + "vscode-uri": "^3.0.0", + "yaml-language-server": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "remark-frontmatter": "^4.0.0" }, "sideEffects": false } diff --git a/packages/language-service/test/language-module.js b/packages/language-service/test/language-module.js new file mode 100644 index 00000000..3e638ada --- /dev/null +++ b/packages/language-service/test/language-module.js @@ -0,0 +1,768 @@ +/** + * @typedef {import('@volar/language-core').VirtualFile} VirtualFile + */ + +import assert from 'node:assert/strict' +import {test} from 'node:test' +import { + FileCapabilities, + FileKind, + FileRangeCapabilities +} from '@volar/language-server' +import remarkFrontmatter from 'remark-frontmatter' +import typescript from 'typescript' +import {getLanguageModule} from '../lib/language-module.js' + +test('create virtual file w/ mdxjsEsm', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('import {Planet} from "./Planet.js"', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 35], + generatedRange: [0, 35], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [0, 34], + sourceRange: [0, 34] + } + ], + snapshot: snapshotFromLines( + 'import {Planet} from "./Planet.js"', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + ' ', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 35], + generatedRange: [0, 35], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines(' ', '') + } + ] + }) +}) + +test('create virtual file w/ mdxFlowExpression', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('{Math.PI}', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 10], + generatedRange: [0, 10], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [199, 208], + sourceRange: [0, 9] + } + ], + snapshot: snapshotFromLines( + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + '{Math.PI}', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 10], + generatedRange: [0, 10], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines(' ', '') + } + ] + }) +}) + +test('create virtual file w/ mdxJsxFlowElement w/ children', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines( + '
', + '', + ' This content should not be part of the JSX embed', + '', + '
', + '' + ) + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 66], + generatedRange: [0, 66], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [255, 264], + sourceRange: [0, 9] + }, + { + data: FileRangeCapabilities.full, + generatedRange: [312, 320], + sourceRange: [57, 65] + } + ], + snapshot: snapshotFromLines( + ' ', + '', + ' ', + '', + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + '
', + '', + ' ', + '', + '
', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 66], + generatedRange: [0, 66], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines( + ' ', + '', + ' This content should not be part of the JSX embed', + '', + ' ', + '' + ) + } + ] + }) +}) + +test('create virtual file w/ mdxJsxFlowElement w/o children', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('
', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 8], + generatedRange: [0, 8], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [197, 204], + sourceRange: [0, 7] + } + ], + snapshot: snapshotFromLines( + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + '
', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 8], + generatedRange: [0, 8], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines(' ', '') + } + ] + }) +}) + +test('create virtual file w/ mdxJsxTextElement', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('A
', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 10], + generatedRange: [0, 10], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [201, 208], + sourceRange: [2, 9] + } + ], + snapshot: snapshotFromLines( + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + '
', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 10], + generatedRange: [0, 10], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines('A ', '') + } + ] + }) +}) + +test('create virtual file w/ mdxTextExpression', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('3 < {Math.PI} < 4', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 18], + generatedRange: [0, 18], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + data: FileRangeCapabilities.full, + generatedRange: [211, 220], + sourceRange: [4, 13] + } + ], + snapshot: snapshotFromLines( + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + ' {Math.PI} ', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 18], + generatedRange: [0, 18], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines('3 < < 4', '') + } + ] + }) +}) + +test('create virtual file w/ syntax error', () => { + const module = getLanguageModule(typescript) + + const snapshot = snapshotFromLines('<', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 2], + generatedRange: [0, 2], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: {}, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [], + snapshot: snapshotFromLines( + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: {}, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [], + snapshot: snapshotFromLines('<', '') + } + ] + }) +}) + +test('create virtual file w/ yaml frontmatter', () => { + const module = getLanguageModule(typescript, [remarkFrontmatter]) + + const snapshot = snapshotFromLines('---', 'hello: frontmatter', '---', '') + + const file = module.createVirtualFile('file:///test.mdx', snapshot, 'mdx') + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 27], + generatedRange: [0, 27], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [], + snapshot: snapshotFromLines( + ' ', + ' ', + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + ' ', + ' ', + ' ', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 27], + generatedRange: [0, 27], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines('---', 'hello: frontmatter', '---', '') + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.yaml', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [4, 22], + generatedRange: [0, 18], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines('hello: frontmatter') + } + ] + }) +}) + +test('update virtual file', () => { + const module = getLanguageModule(typescript) + + const file = module.createVirtualFile( + 'file:///test.mdx', + snapshotFromLines('Tihs lne haz tyops', ''), + 'mdx' + ) + + const snapshot = snapshotFromLines('This line is fixed', '') + module.updateVirtualFile(/** @type {VirtualFile} */ (file), snapshot) + + assert.deepEqual(file, { + capabilities: FileCapabilities.full, + codegenStacks: [], + fileName: 'file:///test.mdx', + kind: FileKind.TextFile, + mappings: [ + { + sourceRange: [0, 19], + generatedRange: [0, 19], + data: FileRangeCapabilities.full + } + ], + snapshot, + embeddedFiles: [ + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.jsx', + kind: FileKind.TypeScriptHostFile, + mappings: [], + snapshot: snapshotFromLines( + ' ', + '', + '/**', + ' * Render the MDX contents.', + ' *', + ' * @param {MDXContentProps} props', + ' * The props that have been passed to the MDX component.', + ' */', + 'export default function MDXContent(props) {', + ' return <>', + ' ', + '', + ' ', + '}', + '', + '// @ts-ignore', + '/** @typedef {Props} MDXContentProps */', + '' + ) + }, + { + capabilities: FileCapabilities.full, + codegenStacks: [], + embeddedFiles: [], + fileName: 'file:///test.mdx.md', + kind: FileKind.TypeScriptHostFile, + mappings: [ + { + sourceRange: [0, 19], + generatedRange: [0, 19], + data: FileRangeCapabilities.full + } + ], + snapshot: snapshotFromLines('This line is fixed', '') + } + ] + }) +}) + +test('compilation setting defaults', () => { + const module = getLanguageModule(typescript) + + const host = module.resolveHost?.({ + getCompilationSettings: () => ({}), + getProjectVersion: () => '1', + getScriptFileNames: () => [], + getScriptSnapshot: () => undefined, + rootPath: '/', + workspacePath: '/' + }) + + const compilerOptions = host?.getCompilationSettings() + + assert.deepEqual(compilerOptions, { + allowJs: true, + allowNonTsExtensions: true, + jsx: typescript.JsxEmit.ReactJSX, + jsxFactory: 'React.createElement', + jsxFragmentFactory: 'React.Fragment', + jsxImportSource: 'react' + }) +}) + +test('compilation setting overrides', () => { + const module = getLanguageModule(typescript) + + const host = module.resolveHost?.({ + getCompilationSettings: () => ({ + jsx: typescript.JsxEmit.React, + jsxFactory: 'h', + jsxFragmentFactory: 'Fragment', + jsxImportSource: 'preact', + allowJs: false, + allowNonTsExtensions: false + }), + getProjectVersion: () => '1', + getScriptFileNames: () => [], + getScriptSnapshot: () => undefined, + rootPath: '/', + workspacePath: '/' + }) + + const compilerOptions = host?.getCompilationSettings() + + assert.deepEqual(compilerOptions, { + allowJs: true, + allowNonTsExtensions: true, + jsx: typescript.JsxEmit.React, + jsxFactory: 'h', + jsxFragmentFactory: 'Fragment', + jsxImportSource: 'preact' + }) +}) + +/** + * @param {string[]} lines + * @returns {typescript.IScriptSnapshot} + */ +function snapshotFromLines(...lines) { + return typescript.ScriptSnapshot.fromString(lines.join('\n')) +} diff --git a/packages/language-service/tsconfig.json b/packages/language-service/tsconfig.json index c6f462a5..db3c8366 100644 --- a/packages/language-service/tsconfig.json +++ b/packages/language-service/tsconfig.json @@ -5,9 +5,9 @@ "checkJs": true, "declaration": true, "emitDeclarationOnly": true, - "lib": ["es2020"], "module": "node16", "skipLibCheck": true, - "strict": true + "strict": true, + "target": "es2020" } } diff --git a/packages/monaco/index.js b/packages/monaco/index.js index 12bc8e4f..ebb9d908 100644 --- a/packages/monaco/index.js +++ b/packages/monaco/index.js @@ -5,104 +5,34 @@ * @typedef {import('monaco-editor').editor.ITextModel} ITextModel * @typedef {import('monaco-editor').editor.MonacoWebWorker} MonacoWebWorker * @typedef {import('monaco-editor').languages.typescript.TypeScriptWorker} TypeScriptWorker - * @typedef {Partial} CreateData - * - * @typedef InitializeMonacoMdxOptions - * @property {CreateData} createData - * Options to pass to the MDX worker. */ -import {registerMarkerDataProvider} from 'monaco-marker-data-provider' -import { - createCompletionItemProvider, - createDefinitionProvider, - createHoverProvider, - createMarkerDataProvider, - createReferenceProvider -} from './lib/language-features.js' +import * as volar from '@volar/monaco' /** * Initialize MDX IntelliSense for MDX. * * @param {Monaco} monaco - * The Monaco editor module. - * @param {InitializeMonacoMdxOptions} [options] - * Additional options for MDX IntelliSense. - * @returns {IDisposable} + * @returns {Promise} * A disposable. */ -export function initializeMonacoMdx(monaco, options) { - const worker = /** @type {MonacoWebWorker} */ ( - monaco.editor.createWebWorker({ - moduleId: '@mdx-js/monaco', - label: 'mdx', - keepIdleModels: true, - createData: /** @type {CreateData} */ ({ - compilerOptions: options?.createData?.compilerOptions || {}, - extraLibs: options?.createData?.extraLibs || {}, - inlayHintsOptions: options?.createData?.inlayHintsOptions || {} - }) - }) - ) - - /** - * @param {Uri[]} resources - */ - const getProxy = (...resources) => worker.withSyncedResources(resources) - - /** - * Synchronize all MDX, JavaScript, and TypeScript files with the web worker. - * - * @param {ITextModel} model - */ - const synchronize = (model) => { - const languageId = model.getLanguageId() - if ( - languageId === 'mdx' || - languageId === 'javascript' || - languageId === 'javascriptreact' || - languageId === 'typescript' || - languageId === 'typescriptreact' - ) { - getProxy(model.uri) - } - } - - monaco.editor.onDidChangeModelLanguage(({model}) => { - synchronize(model) +export async function initializeMonacoMdx(monaco) { + const worker = monaco.editor.createWebWorker({ + moduleId: '@mdx-js/monaco/mdx.worker.js', + label: 'mdx' }) - const disposables = [ + const provides = await volar.languages.registerProvides( worker, - monaco.editor.onDidCreateModel(synchronize), - monaco.languages.registerCompletionItemProvider( - 'mdx', - createCompletionItemProvider(monaco, getProxy) - ), - monaco.languages.registerDefinitionProvider( - 'mdx', - createDefinitionProvider(monaco, getProxy) - ), - monaco.languages.registerHoverProvider( - 'mdx', - createHoverProvider(monaco, getProxy) - ), - monaco.languages.registerReferenceProvider( - 'mdx', - createReferenceProvider(monaco, getProxy) - ), - registerMarkerDataProvider( - monaco, - 'mdx', - createMarkerDataProvider(monaco, getProxy) - ) - ] + 'mdx', + () => monaco.editor.getModels().map((model) => model.uri), + monaco.languages + ) return { dispose() { - for (const disposable of disposables) { - disposable.dispose() - } + provides.disposw() + worker.dispose() } } } diff --git a/packages/monaco/lib/convert.js b/packages/monaco/lib/convert.js deleted file mode 100644 index f81de071..00000000 --- a/packages/monaco/lib/convert.js +++ /dev/null @@ -1,313 +0,0 @@ -/** - * @typedef {import('monaco-editor')} Monaco - * @typedef {import('monaco-editor').editor.IMarkerData} IMarkerData - * @typedef {import('monaco-editor').editor.IRelatedInformation} IRelatedInformation - * @typedef {import('monaco-editor').editor.ITextModel} ITextModel - * @typedef {import('monaco-editor').languages.typescript.Diagnostic} Diagnostic - * @typedef {import('monaco-editor').languages.typescript.DiagnosticRelatedInformation} DiagnosticRelatedInformation - * @typedef {import('monaco-editor').languages.CompletionItemKind} CompletionItemKind - * @typedef {import('monaco-editor').IRange} IRange - * @typedef {import('monaco-editor').MarkerSeverity} MarkerSeverity - * @typedef {import('monaco-editor').MarkerTag} MarkerTag - * @typedef {import('typescript').CompletionEntryDetails} CompletionEntryDetails - * @typedef {import('typescript').DiagnosticCategory} DiagnosticCategory - * @typedef {import('typescript').DiagnosticMessageChain} DiagnosticMessageChain - * @typedef {import('typescript').JSDocTagInfo} JSDocTagInfo - * @typedef {import('typescript').ScriptElementKind} ScriptElementKind - * @typedef {import('typescript').SymbolDisplayPart} SymbolDisplayPart - * @typedef {import('typescript').TextSpan} TextSpan - */ - -/** - * Convert a TypeScript script element kind to a Monaco completion item kind. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {ScriptElementKind} kind - * The TypeScript script element kind tp convert. - * @returns {CompletionItemKind} - * The matching Monaco completion item kind. - */ -export function convertScriptElementKind(monaco, kind) { - switch (kind) { - case 'primitive type': - case 'keyword': { - return monaco.languages.CompletionItemKind.Keyword - } - - case 'var': - case 'local var': { - return monaco.languages.CompletionItemKind.Variable - } - - case 'property': - case 'getter': - case 'setter': { - return monaco.languages.CompletionItemKind.Field - } - - case 'function': - case 'method': - case 'construct': - case 'call': - case 'index': { - return monaco.languages.CompletionItemKind.Function - } - - case 'enum': { - return monaco.languages.CompletionItemKind.Enum - } - - case 'module': { - return monaco.languages.CompletionItemKind.Module - } - - case 'class': { - return monaco.languages.CompletionItemKind.Class - } - - case 'interface': { - return monaco.languages.CompletionItemKind.Interface - } - - case 'warning': { - return monaco.languages.CompletionItemKind.File - } - - default: { - return monaco.languages.CompletionItemKind.Property - } - } -} - -/** - * Convert TypeScript symbol display parts to a string. - * - * @param {SymbolDisplayPart[] | undefined} displayParts - * The display parts to convert. - * @returns {string} - * A string representation of the symbol display parts. - */ -export function displayPartsToString(displayParts) { - if (displayParts) { - return displayParts.map((displayPart) => displayPart.text).join('') - } - - return '' -} - -/** - * Create a markdown documentation string - * - * @param {CompletionEntryDetails} details - * The details to represent. - * @returns {string} - * The details represented as a markdown string. - */ -export function createDocumentationString(details) { - let documentationString = displayPartsToString(details.documentation) - if (details.tags) { - for (const tag of details.tags) { - documentationString += `\n\n${tagToString(tag)}` - } - } - - return documentationString -} - -/** - * Represent a TypeScript JSDoc tag as a string. - * - * @param {JSDocTagInfo} tag - * The JSDoc tag to represent. - * @returns {string} - * A representation of the JSDoc tag. - */ -export function tagToString(tag) { - let tagLabel = `*@${tag.name}*` - if (tag.name === 'param' && tag.text) { - const [parameterName, ...rest] = tag.text - tagLabel += `\`${parameterName.text}\`` - if (rest.length > 0) tagLabel += ` — ${rest.map((r) => r.text).join(' ')}` - } else if (Array.isArray(tag.text)) { - tagLabel += ` — ${tag.text.map((r) => r.text).join(' ')}` - } else if (tag.text) { - tagLabel += ` — ${tag.text}` - } - - return tagLabel -} - -/** - * Convert a text span to a Monaco range that matches the given model. - * - * @param {ITextModel} model - * The Monaco model to which the text span applies. - * @param {TextSpan} span - * The TypeScript text span to convert. - * @returns {IRange} - * The text span as a Monaco range. - */ -export function textSpanToRange(model, span) { - const p1 = model.getPositionAt(span.start) - const p2 = model.getPositionAt(span.start + span.length) - const {lineNumber: startLineNumber, column: startColumn} = p1 - const {lineNumber: endLineNumber, column: endColumn} = p2 - return {startLineNumber, startColumn, endLineNumber, endColumn} -} - -/** - * Flatten a TypeScript diagnostic message chain into a string representation. - * @param {string | DiagnosticMessageChain | undefined} diag - * The diagnostic to represent. - * @param {number} [indent] - * The indentation to use. - * @returns {string} - * A flattened diagnostic text. - */ -function flattenDiagnosticMessageText(diag, indent = 0) { - if (typeof diag === 'string') { - return diag - } - - if (diag === undefined) { - return '' - } - - let result = '' - if (indent) { - result += `\n${' '.repeat(indent)}` - } - - result += diag.messageText - indent++ - if (diag.next) { - for (const kid of diag.next) { - result += flattenDiagnosticMessageText(kid, indent) - } - } - - return result -} - -/** - * Convert TypeScript diagnostic related information to Monaco related - * information. - * - * @param {ITextModel} model - * The Monaco model the information is related to. - * @param {DiagnosticRelatedInformation[]} [relatedInformation] - * The TypeScript related information to convert. - * @returns {IRelatedInformation[]} - * TypeScript diagnostic related information as Monaco related information. - */ -function convertRelatedInformation(model, relatedInformation) { - if (!relatedInformation) { - return [] - } - - /** @type {IRelatedInformation[]} */ - const result = [] - for (const info of relatedInformation) { - const relatedResource = model - - if (!relatedResource) { - continue - } - - const infoStart = info.start || 0 - const infoLength = info.length || 1 - const {lineNumber: startLineNumber, column: startColumn} = - relatedResource.getPositionAt(infoStart) - const {lineNumber: endLineNumber, column: endColumn} = - relatedResource.getPositionAt(infoStart + infoLength) - - result.push({ - resource: relatedResource.uri, - startLineNumber, - startColumn, - endLineNumber, - endColumn, - message: flattenDiagnosticMessageText(info.messageText) - }) - } - - return result -} - -/** - * Convert a TypeScript diagnostic category to a Monaco diagnostic severity. - * - * @param {Monaco} monaco - * The Monaco editor module. - * @param {DiagnosticCategory} category - * The TypeScript diagnostic category to convert. - * @returns {MarkerSeverity} - * TypeScript diagnostic severity as Monaco marker severity. - */ -function tsDiagnosticCategoryToMarkerSeverity(monaco, category) { - switch (category) { - case 0: { - return monaco.MarkerSeverity.Warning - } - - case 1: { - return monaco.MarkerSeverity.Error - } - - case 2: { - return monaco.MarkerSeverity.Hint - } - - default: { - return monaco.MarkerSeverity.Info - } - } -} - -/** - * Convert a TypeScript dignostic to a Monaco editor diagnostic. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {ITextModel} model - * The Monaco editor model to which the diagnostic applies. - * @param {Diagnostic} diag - * The TypeScript diagnostic to convert. - * @returns {IMarkerData} - * The TypeScript diagnostic converted to Monaco marker data. - */ -export function convertDiagnostics(monaco, model, diag) { - const diagStart = diag.start || 0 - const diagLength = diag.length || 1 - const {lineNumber: startLineNumber, column: startColumn} = - model.getPositionAt(diagStart) - const {lineNumber: endLineNumber, column: endColumn} = model.getPositionAt( - diagStart + diagLength - ) - - /** @type {MarkerTag[]} */ - const tags = [] - if (diag.reportsUnnecessary) { - tags.push(monaco.MarkerTag.Unnecessary) - } - - if (diag.reportsDeprecated) { - tags.push(monaco.MarkerTag.Deprecated) - } - - return { - severity: tsDiagnosticCategoryToMarkerSeverity(monaco, diag.category), - startLineNumber, - startColumn, - endLineNumber, - endColumn, - message: flattenDiagnosticMessageText(diag.messageText), - code: diag.code.toString(), - tags, - relatedInformation: convertRelatedInformation( - model, - diag.relatedInformation - ) - } -} diff --git a/packages/monaco/lib/language-features.js b/packages/monaco/lib/language-features.js deleted file mode 100644 index 7a05f8e8..00000000 --- a/packages/monaco/lib/language-features.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * @typedef {import('monaco-editor')} Monaco - * @typedef {import('monaco-editor').languages.CompletionItemProvider} CompletionItemProvider - * @typedef {import('monaco-editor').languages.DefinitionProvider} DefinitionProvider - * @typedef {import('monaco-editor').languages.HoverProvider} HoverProvider - * @typedef {import('monaco-editor').languages.Location} Location - * @typedef {import('monaco-editor').languages.ReferenceProvider} ReferenceProvider - * @typedef {import('monaco-editor').languages.typescript.TypeScriptWorker} TypeScriptWorker - * @typedef {import('monaco-editor').Uri} Uri - * @typedef {import('monaco-marker-data-provider').MarkerDataProvider} MarkerDataProvider - * @typedef {import('typescript').CompletionEntryDetails} CompletionEntryDetails - * @typedef {import('typescript').CompletionInfo} CompletionInfo - * @typedef {import('typescript').QuickInfo} QuickInfo - * @typedef {import('typescript').ReferenceEntry} ReferenceEntry - * - * @typedef {(...resources: Uri[]) => Promise} GetWorker - */ - -import { - convertDiagnostics, - convertScriptElementKind, - createDocumentationString, - displayPartsToString, - tagToString, - textSpanToRange -} from './convert.js' - -/** - * Create a completion item provider for MDX documents. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {GetWorker} getWorker - * A function to get the MDX web worker. - * @returns {CompletionItemProvider} - * A completion item provider for MDX documents. - */ -export function createCompletionItemProvider(monaco, getWorker) { - return { - async provideCompletionItems(model, position) { - const worker = await getWorker(model.uri) - const offset = model.getOffsetAt(position) - const wordInfo = model.getWordUntilPosition(position) - const wordRange = new monaco.Range( - position.lineNumber, - wordInfo.startColumn, - position.lineNumber, - wordInfo.endColumn - ) - - if (model.isDisposed()) { - return - } - - const info = /** @type {CompletionInfo | undefined} */ ( - await worker.getCompletionsAtPosition(String(model.uri), offset) - ) - - if (!info || model.isDisposed()) { - return - } - - const suggestions = info.entries.map((entry) => { - const range = entry.replacementSpan - ? textSpanToRange(model, entry.replacementSpan) - : wordRange - - const tags = entry.kindModifiers?.includes('deprecated') - ? [monaco.languages.CompletionItemTag.Deprecated] - : [] - - return { - uri: model.uri, - position, - offset, - range, - label: entry.name, - insertText: entry.name, - sortText: entry.sortText, - kind: convertScriptElementKind(monaco, entry.kind), - tags - } - }) - - return { - suggestions - } - }, - - async resolveCompletionItem(item) { - const {label, offset, uri} = /** @type {any} */ (item) - - const worker = await getWorker(uri) - - const details = /** @type {CompletionEntryDetails | undefined} */ ( - await worker.getCompletionEntryDetails(String(uri), offset, label) - ) - - if (!details) { - return item - } - - return { - ...item, - label: details.name, - kind: convertScriptElementKind(monaco, details.kind), - detail: displayPartsToString(details.displayParts), - documentation: { - value: createDocumentationString(details) - } - } - } - } -} - -/** - * Create a hover provider for MDX documents. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {GetWorker} getWorker - * A function to get the MDX web worker. - * @returns {HoverProvider} - * A hover provider for MDX documents. - */ -export function createHoverProvider(monaco, getWorker) { - return { - async provideHover(model, position) { - const worker = await getWorker(model.uri) - - /** @type {QuickInfo | undefined} */ - const info = await worker.getQuickInfoAtPosition( - String(model.uri), - model.getOffsetAt(position) - ) - - if (!info) { - return - } - - const documentation = displayPartsToString(info.documentation) - const tags = info.tags - ? info.tags.map((tag) => tagToString(tag)).join(' \n\n') - : '' - const contents = displayPartsToString(info.displayParts) - - return { - range: textSpanToRange(model, info.textSpan), - contents: [ - { - value: '```typescript\n' + contents + '\n```\n' - }, - { - value: documentation + (tags ? '\n\n' + tags : '') - } - ] - } - } - } -} - -/** - * Create a link provider for MDX documents. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {GetWorker} getWorker - * A function to get the MDX web worker. - * @returns {DefinitionProvider} - * A link provider for MDX documents. - */ -export function createDefinitionProvider(monaco, getWorker) { - return { - async provideDefinition(model, position) { - const worker = await getWorker(model.uri) - - const offset = model.getOffsetAt(position) - const entries = /** @type {ReferenceEntry[] | undefined} */ ( - await worker.getDefinitionAtPosition(String(model.uri), offset) - ) - if (!entries?.length) { - return - } - - /** @type {Location[]} */ - const result = [] - for (const entry of entries) { - const uri = monaco.Uri.parse(entry.fileName) - const refModel = monaco.editor.getModel(uri) - if (refModel) { - result.push({ - uri, - range: textSpanToRange(model, entry.textSpan) - }) - } - } - - return result - } - } -} - -/** - * Create a marker data provider for MDX documents. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {GetWorker} getWorker - * A function to get the MDX web worker. - * @returns {MarkerDataProvider} - * A marker data provider for MDX documents. - */ -export function createMarkerDataProvider(monaco, getWorker) { - return { - owner: 'mdx', - - async provideMarkerData(model) { - const worker = await getWorker(model.uri) - const uri = String(model.uri) - const diagnostics = await Promise.all([ - worker.getSemanticDiagnostics(uri), - worker.getSuggestionDiagnostics(uri), - worker.getSyntacticDiagnostics(uri) - ]) - - if (model.isDisposed()) { - return - } - - return diagnostics - .flat() - .map((diagnostic) => convertDiagnostics(monaco, model, diagnostic)) - } - } -} - -/** - * Create a reference provider for MDX documents. - * - * @param {Monaco} monaco - * The Monaco editor module to use. - * @param {GetWorker} getWorker - * A function to get the MDX web worker. - * @returns {ReferenceProvider} - * A reference provider for MDX documents. - */ -export function createReferenceProvider(monaco, getWorker) { - return { - async provideReferences(model, position) { - const worker = await getWorker(model.uri) - const resource = model.uri - const offset = model.getOffsetAt(position) - - if (model.isDisposed()) { - return - } - - const entries = /** @type {ReferenceEntry[] | undefined} */ ( - await worker.getReferencesAtPosition(resource.toString(), offset) - ) - - if (!entries || model.isDisposed()) { - return - } - - /** @type {Location[]} */ - const result = [] - for (const entry of entries) { - const uri = monaco.Uri.parse(entry.fileName) - const refModel = monaco.editor.getModel(uri) - if (refModel) { - result.push({ - uri: refModel.uri, - range: textSpanToRange(refModel, entry.textSpan) - }) - } - } - - return result - } - } -} diff --git a/packages/monaco/mdx.worker.js b/packages/monaco/mdx.worker.js index 0a451ce0..6977b8f1 100644 --- a/packages/monaco/mdx.worker.js +++ b/packages/monaco/mdx.worker.js @@ -1,69 +1,43 @@ -/* global ts */ /** - * @typedef {import('monaco-editor').languages.typescript.CompilerOptions} CompilerOptions - * @typedef {import('monaco-editor').languages.typescript.IExtraLibs} IExtraLibs - * @typedef {import('monaco-editor').languages.typescript.InlayHintsOptions} InlayHintsOptions - * @typedef {import('monaco-editor').languages.typescript.TypeScriptWorker} TypeScriptWorker * @typedef {import('monaco-editor').worker.IWorkerContext} IWorkerContext - * @typedef {import('typescript').LanguageServiceHost} LanguageServiceHost * @typedef {import('unified').PluggableList} PluggableList * - * @typedef {object} CreateData - * @property {CompilerOptions} compilerOptions - * The TypeScript compiler options configured by the user. - * @property {IExtraLibs} extraLibs - * Additional libraries to load. - * @property {InlayHintsOptions} inlayHintsOptions - * The TypeScript inlay hints options. - * - * @typedef {TypeScriptWorker & LanguageServiceHost} MDXWorker - * @typedef {new (ctx: IWorkerContext, createData: CreateData) => MDXWorker} TypeScriptWorkerClass - * * @typedef MDXWorkerOptions * @property {PluggableList} [plugins] * A list of remark plugins. Only syntax parser plugins are supported. For * example `remark-frontmatter`, but not `remark-mdx-frontmatter`. */ -import {createMdxLanguageService} from '@mdx-js/language-service' -// @ts-expect-error This module is untyped. -import {initialize as initializeWorker} from 'monaco-editor/esm/vs/editor/editor.worker.js' +import {resolveConfig} from '@mdx-js/language-service' +import { + createLanguageService, + createLanguageHost, + createServiceEnvironment +} from '@volar/monaco/worker.js' // @ts-expect-error This module is untyped. -import {create} from 'monaco-editor/esm/vs/language/typescript/ts.worker.js' +import {initialize} from 'monaco-editor/esm/vs/editor/editor.worker.js' +import typescript from 'typescript/lib/tsserverlibrary.js' /** @type {PluggableList | undefined} */ let plugins -/** - * @param {TypeScriptWorkerClass} TypeScriptWorker - * @returns {TypeScriptWorkerClass} A custom TypeScript worker which knows how to handle MDX. - */ -function worker(TypeScriptWorker) { - return class MDXWorker extends TypeScriptWorker { - _languageService = createMdxLanguageService( - // @ts-expect-error This is globally defined in the worker. - ts, - this, - plugins - ) - } -} - -// @ts-expect-error This is missing in the Monaco type definitions. -self.customTSWorkerFactory = worker - -// Trick the TypeScript worker into using the `customTSWorkerFactory` -self.importScripts = () => {} - // eslint-disable-next-line unicorn/prefer-add-event-listener self.onmessage = () => { - initializeWorker( + initialize( /** - * @param {IWorkerContext} ctx - * @param {CreateData} createData - * @returns {MDXWorker} The MDX TypeScript worker. + * @param {IWorkerContext} workerContext */ - (ctx, createData) => create(ctx, {...createData, customWorkerPath: true}) + (workerContext) => { + const env = createServiceEnvironment() + const host = createLanguageHost(workerContext.getMirrorModels, env, '/', { + checkJs: true, + jsx: typescript.JsxEmit.ReactJSX, + moduleResolution: typescript.ModuleResolutionKind.NodeJs + }) + const config = resolveConfig({}, typescript, plugins) + + return createLanguageService({typescript}, env, config, host) + } ) } diff --git a/packages/monaco/package.json b/packages/monaco/package.json index ab181303..0b0f5f1b 100644 --- a/packages/monaco/package.json +++ b/packages/monaco/package.json @@ -43,12 +43,12 @@ }, "dependencies": { "@mdx-js/language-service": "0.1.0", - "monaco-marker-data-provider": "^1.0.0", - "monaco-worker-manager": "^2.0.0", + "@volar/monaco": "~1.10.0", "unified": "^10.0.0" }, "devDependencies": { "@playwright/test": "^1.0.0", + "path-browserify": "^1.0.0", "playwright-monaco": "^1.0.0" } } diff --git a/packages/monaco/playwright.config.js b/packages/monaco/playwright.config.js index 5bf3592c..c7cbafa5 100644 --- a/packages/monaco/playwright.config.js +++ b/packages/monaco/playwright.config.js @@ -9,10 +9,15 @@ import {createServer} from 'playwright-monaco' */ const config = { use: { - baseURL: await createServer({ - setup: './tests/setup.js', - mdx: './mdx.worker.js' - }) + baseURL: await createServer( + { + setup: './tests/setup.js', + mdx: './mdx.worker.js' + }, + { + alias: {path: 'path-browserify'} + } + ) } } diff --git a/packages/vscode-mdx/package.json b/packages/vscode-mdx/package.json index 6c555c39..40a95163 100644 --- a/packages/vscode-mdx/package.json +++ b/packages/vscode-mdx/package.json @@ -40,16 +40,16 @@ }, "scripts": { "build": "node ./script/build.mjs", - "build:debug": "npm run copy-libs && npm run build debug", - "copy-libs": "cpy '../../node_modules/typescript/lib/lib.*.d.ts' out/", + "build:debug": "npm run build debug", "generate": "node --conditions development ./script/generate.mjs", - "vscode:prepublish": "npm run copy-libs && npm run build" + "vscode:prepublish": "npm run build" }, "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.0.0", + "@volar/language-server": "~1.10.0", + "@volar/vscode": "^1.0.0", "@vscode/vsce": "^2.0.0", - "cpy-cli": "^5.0.0", "esbuild": "^0.18.0", "ovsx": "^0.8.0", "undici": "^5.0.0", diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js index 46268133..a4a619fa 100644 --- a/packages/vscode-mdx/src/extension.js +++ b/packages/vscode-mdx/src/extension.js @@ -2,7 +2,11 @@ * @typedef {import('vscode').ExtensionContext} ExtensionContext */ -import {workspace} from 'vscode' +import * as path from 'node:path' +import {DiagnosticModel} from '@volar/language-server' +import * as languageServerProtocol from '@volar/language-server/protocol.js' +import {activateAutoInsertion, supportLabsVersion} from '@volar/vscode' +import {env, workspace} from 'vscode' import {LanguageClient} from 'vscode-languageclient/node.js' /** @@ -25,17 +29,27 @@ export async function activate(context) { 'MDX', {module: context.asAbsolutePath('out/language-server.js')}, { - documentSelector: [ - {scheme: 'file', language: 'mdx'}, - {scheme: 'file', language: 'typescript'}, - {scheme: 'file', language: 'typescriptreact'}, - {scheme: 'file', language: 'javascript'}, - {scheme: 'file', language: 'javascriptreact'} - ] + documentSelector: [{language: 'mdx'}], + initializationOptions: { + typescript: { + tsdk: path.join(env.appRoot, 'extensions/node_modules/typescript/lib') + }, + diagnosticModel: DiagnosticModel.Pull + } } ) await client.start() + + activateAutoInsertion([client], (document) => document.languageId === 'mdx') + + return { + volarLabs: { + version: supportLabsVersion, + languageClients: [client], + languageServerProtocol + } + } } /** diff --git a/packages/vscode-mdx/tsconfig.json b/packages/vscode-mdx/tsconfig.json index a57c92b9..7ebf3833 100644 --- a/packages/vscode-mdx/tsconfig.json +++ b/packages/vscode-mdx/tsconfig.json @@ -3,6 +3,7 @@ "exclude": ["coverage/", "node_modules/"], "compilerOptions": { "checkJs": true, + "esModuleInterop": false, "module": "node16", "noEmit": true, "skipLibCheck": true,