Repository: mdx-js/mdx-analyzer
Pull request #473 (2024-08-30 18:14:16)

Change custom request types to LSP commands
### Initial checklist

*   [x] I read the support docs <!-- https://github.com/unifiedjs/.github/blob/main/support.md -->
*   [x] I read the contributing guide <!-- https://github.com/unifiedjs/.github/blob/main/contributing.md -->
*   [x] I agree to follow the code of conduct <!-- https://github.com/unifiedjs/.github/blob/main/code-of-conduct.md -->
*   [x] I searched issues and couldn’t find anything (or linked relevant results below) <!-- https://github.com/search?q=user%3Aunifiedjs&type=Issues -->
*   [x] If applicable, I’ve added docs and tests

### Description of changes

LSP commands are the intended way to implement custom behaviour.

<!--do not edit: pr-->


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 535fa9ca827b8363eb8618cfb86a2ea99329ce8c

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name                      | Type  |
| ------------------------- | ----- |
| @mdx-js/language-service  | Minor |
| @mdx-js/language-server   | Minor |
| @mdx-js/typescript-plugin | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/mdx-js/mdx-analyzer/new/lsp-commands?filename=.changeset/tidy-sloths-kneel.md&value=---%0A%22%40mdx-js%2Flanguage-server%22%3A%20patch%0A%22%40mdx-js%2Flanguage-service%22%3A%20patch%0A%22vscode-mdx%22%3A%20patch%0A---%0A%0AChange%20custom%20request%20types%20to%20LSP%20commands%0A)


github-actions[bot]: Hi! This is accepted and can go somewhere!

Team: please review this PR and use the `area/*` (to describe the scope of the change), `platform/*` (if this is related to a specific one), and `semver/*` and `type/*` labels to annotate this.

<!--xxx:beep-boop:yes-maintainers-->
remcohaszing: > The tests look like they're failing 🤔

Yes, because of a timing issue. I think we were just lucky the tests weren’t failing before. I’ll look into it.
remcohaszing: I’ll merge this as-is and process the comments in follow-up PRs.

Patch:
diff --git a/.changeset/large-shoes-applaud.md b/.changeset/large-shoes-applaud.md
new file mode 100644
index 0000000..d3c39ff
--- /dev/null
+++ b/.changeset/large-shoes-applaud.md
@@ -0,0 +1,6 @@
+---
+'@mdx-js/language-service': minor
+'@mdx-js/language-server': minor
+---
+
+Convert the custom MDX syntax toggle request types into LSP commands.
diff --git a/packages/language-server/README.md b/packages/language-server/README.md
index 0f5a225..69370b7 100644
--- a/packages/language-server/README.md
+++ b/packages/language-server/README.md
@@ -65,6 +65,66 @@ This language server supports all features supported by
 [`volar-service-typescript`][volar-service-typescript], plus some additional
 features specific to MDX.
 
+#### Commands
+
+The language server supports the following [LSP commands][]:
+
+##### `mdx.toggleDelete`
+
+Toggle delete syntax at the cursor position.
+It uses the `workspace/applyEdit` command to apply edits.
+
+###### Arguments
+
+* `uri` — The URI of the document to apply changes to.
+* `range` — The current selection range of the user.
+
+###### Returns
+
+`null`
+
+##### `mdx.toggleEmphasis`
+
+Toggle emphasis syntax at the cursor position.
+It uses the `workspace/applyEdit` command to apply edits.
+
+###### Arguments
+
+* `uri` — The URI of the document to apply changes to.
+* `range` — The current selection range of the user.
+
+###### Returns
+
+`null`
+
+##### `mdx.toggleInlineCode`
+
+Toggle inline code syntax at the cursor position.
+It uses the `workspace/applyEdit` command to apply edits.
+
+###### Arguments
+
+* `uri` — The URI of the document to apply changes to.
+* `range` — The current selection range of the user.
+
+###### Returns
+
+`null`
+
+##### `mdx.toggleStrong`
+
+Toggle strong syntax at the cursor position.
+It uses the `workspace/applyEdit` command to apply edits.
+
+###### Arguments
+
+* `uri` — The URI of the document to apply changes to.
+* `range` — The current selection range of the user.
+
+###### Returns
+
+`null`
+
 ### Initialize Options
 
 MDX language server supports the following LSP initialization options:
@@ -271,6 +331,8 @@ Detailed changes for each release are documented in [CHANGELOG.md](./CHANGELOG.m
 
 [lsp]: https://microsoft.github.io/language-server-protocol
 
+[lsp commands]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#command
+
 [mdx]: https://mdxjs.com
 
 [mit]: LICENSE
diff --git a/packages/language-server/index.js b/packages/language-server/index.js
index 8be9901..9e695e5 100755
--- a/packages/language-server/index.js
+++ b/packages/language-server/index.js
@@ -1,7 +1,6 @@
 #!/usr/bin/env node
 
 /**
- * @import {Commands} from '@mdx-js/language-service'
  * @import {PluggableList, Plugin} from 'unified'
  */
 
@@ -26,7 +25,6 @@ import remarkGfm from 'remark-gfm'
 import {create as createMarkdownServicePlugin} from 'volar-service-markdown'
 import {create as createTypeScriptServicePlugin} from 'volar-service-typescript'
 import {create as createTypeScriptSyntacticServicePlugin} from 'volar-service-typescript/lib/plugins/syntactic.js'
-import {URI} from 'vscode-uri'
 
 process.title = 'mdx-language-server'
 
@@ -68,7 +66,7 @@ connection.onInitialize(async (parameters) => {
           return context.env.getConfiguration?.('mdx.validate')
         }
       }),
-      createMdxServicePlugin()
+      createMdxServicePlugin(connection.workspace)
     ]
 
     if (tsEnabled) {
@@ -123,26 +121,6 @@ connection.onInitialize(async (parameters) => {
   }
 })
 
-connection.onRequest('mdx/toggleDelete', async (parameters) => {
-  const commands = await getCommands(parameters.uri)
-  return commands.toggleDelete(parameters)
-})
-
-connection.onRequest('mdx/toggleEmphasis', async (parameters) => {
-  const commands = await getCommands(parameters.uri)
-  return commands.toggleEmphasis(parameters)
-})
-
-connection.onRequest('mdx/toggleInlineCode', async (parameters) => {
-  const commands = await getCommands(parameters.uri)
-  return commands.toggleInlineCode(parameters)
-})
-
-connection.onRequest('mdx/toggleStrong', async (parameters) => {
-  const commands = await getCommands(parameters.uri)
-  return commands.toggleStrong(parameters)
-})
-
 connection.onInitialized(() => {
   const extensions = ['mdx']
   if (tsEnabled) {
@@ -164,12 +142,3 @@ connection.onInitialized(() => {
 })
 
 connection.listen()
-
-/**
- * @param {string} uri
- * @returns {Promise<Commands>}
- */
-async function getCommands(uri) {
-  const service = await server.project.getLanguageService(URI.parse(uri))
-  return service.context.inject('mdxCommands')
-}
diff --git a/packages/language-server/package.json b/packages/language-server/package.json
index e7a8f8c..c5b378d 100644
--- a/packages/language-server/package.json
+++ b/packages/language-server/package.json
@@ -38,12 +38,12 @@
     "remark-frontmatter": "^5.0.0",
     "remark-gfm": "^4.0.0",
     "volar-service-markdown": "0.0.61",
-    "volar-service-typescript": "0.0.61",
-    "vscode-uri": "^3.0.0"
+    "volar-service-typescript": "0.0.61"
   },
   "devDependencies": {
     "@types/node": "^22.0.0",
     "@volar/test-utils": "~2.4.0",
-    "unified": "^11.0.0"
+    "unified": "^11.0.0",
+    "vscode-uri": "^3.0.0"
   }
 }
diff --git a/packages/language-service/README.md b/packages/language-service/README.md
index be3b4ba..6fd4efc 100644
--- a/packages/language-service/README.md
+++ b/packages/language-service/README.md
@@ -18,7 +18,7 @@
 * [Use](#use)
 * [API](#api)
   * [`createMdxLanguagePlugin([plugins][, checkMdx][, jsxImportSource])`](#createmdxlanguagepluginplugins-checkmdx-jsximportsource)
-  * [`createMdxServicePlugin()`](#createmdxserviceplugin)
+  * [`createMdxServicePlugin(options)`](#createmdxservicepluginoptions)
   * [`resolveRemarkPlugins(mdxConfig, resolvePlugin)`](#resolveremarkpluginsmdxconfig-resolveplugin)
 * [Compatibility](#compatibility)
 * [Types](#types)
@@ -103,19 +103,34 @@ Create a [Volar][] language plugin to support [MDX][].
 
 A Volar language plugin to support MDX.
 
-### `createMdxServicePlugin()`
+### `createMdxServicePlugin(options)`
 
 Create a [Volar][] service module to support [MDX][].
 The service supports:
 
 * Reporting diagnostics for parsing errors.
 * Document drop support for images.
-* Custom commands for toggling `delete`, `emphasis`, `inlineCode`, and `strong`
-  text.
+* Custom commands.
+
+The following commands are supported:
+
+* `mdx.toggleDelete` — Toggle delete syntax at the cursor position.
+  This takes the URI as its first argument, and the LSP selection range as its
+  second argument.
+* `mdx.toggleEmphasis` — Toggle emphasis syntax at the cursor position.
+  This takes the URI as its first argument, and the LSP selection range as its
+  second argument.
+* `mdx.toggleInlineCode` — Toggle inline code syntax at the cursor position.
+  This takes the URI as its first argument, and the LSP selection range as its
+  second argument.
+* `mdx.toggleStrong` — Toggle strong syntax at the cursor position.
+  This takes the URI as its first argument, and the LSP selection range as its
+  second argument.
 
 #### Parameters
 
-This function doesn’t take any parameters.
+* `options` — An object with the following properties:
+  * `applyEdit` — A function to apply an LSP workspace edit.
 
 #### Returns
 
diff --git a/packages/language-service/index.js b/packages/language-service/index.js
index 8f6a8cd..a55cb58 100644
--- a/packages/language-service/index.js
+++ b/packages/language-service/index.js
@@ -1,8 +1,3 @@
-/**
- * @typedef {import('./lib/commands.js').SyntaxToggleParams} SyntaxToggleParams
- * @typedef {import('./lib/service-plugin.js').Commands} Commands
- */
-
 export {createMdxLanguagePlugin} from './lib/language-plugin.js'
 export {createMdxServicePlugin} from './lib/service-plugin.js'
 export {resolveRemarkPlugins} from './lib/tsconfig.js'
diff --git a/packages/language-service/lib/commands.js b/packages/language-service/lib/commands.js
index 88c3d01..0a33bb9 100644
--- a/packages/language-service/lib/commands.js
+++ b/packages/language-service/lib/commands.js
@@ -1,24 +1,7 @@
 /**
  * @import {LanguageServiceContext, Range, TextEdit} from '@volar/language-service'
  * @import {Nodes} from 'mdast'
- */
-
-/**
- * @typedef SyntaxToggleParams
- *   The request parameters for LSP toggle requests.
- * @property {string} uri
- *   The URI of the document the request is for.
- * @property {Range} range
- *   The range that is selected by the user.
- */
-
-/**
- * @callback SyntaxToggle
- *   A function to toggle prose markdown syntax based on the AST.
- * @param {SyntaxToggleParams} params
- *   The input parameters from the LSP request.
- * @returns {TextEdit[] | undefined}
- *   LSP text edits that should be made.
+ * @import {createMdxServicePlugin} from './service-plugin.js'
  */
 
 import {visitParents} from 'unist-util-visit-parents'
@@ -27,122 +10,132 @@ import {getNodeEndOffset, getNodeStartOffset} from './mdast-utils.js'
 import {VirtualMdxCode} from './virtual-code.js'
 
 /**
- * Create a function to toggle prose syntax based on the AST.
+ * Toggle prose syntax based on the AST.
  *
  * @param {LanguageServiceContext} context
  *   The Volar service context.
+ * @param {createMdxServicePlugin.Options} options
+ *   The options to use for applying workspace edits.
  * @param {Nodes['type']} type
  *   The type of the mdast node to toggle.
  * @param {string} separator
  *   The mdast node separator to insert.
- * @returns {SyntaxToggle}
- *   An LSP based syntax toggle function.
+ * @param {string} uri
+ *   The URI of the document the request is for.
+ * @param {Range} range
+ *   The range that is selected by the user.
+ * @returns {Promise<undefined>}
  */
-export function createSyntaxToggle(context, type, separator) {
-  return ({range, uri}) => {
-    const parsedUri = URI.parse(uri)
-    const sourceScript = context.language.scripts.get(parsedUri)
-    const root = sourceScript?.generated?.root
+export async function toggleSyntax(
+  context,
+  options,
+  type,
+  separator,
+  uri,
+  range
+) {
+  const parsedUri = URI.parse(uri)
+  const sourceScript = context.language.scripts.get(parsedUri)
+  const root = sourceScript?.generated?.root
+
+  if (!(root instanceof VirtualMdxCode)) {
+    return
+  }
 
-    if (!(root instanceof VirtualMdxCode)) {
-      return
-    }
+  const ast = root.ast
 
-    const ast = root.ast
+  if (!ast) {
+    return
+  }
 
-    if (!ast) {
-      return
-    }
+  const doc = context.documents.get(parsedUri, root.languageId, root.snapshot)
+  const selectionStart = doc.offsetAt(range.start)
+  const selectionEnd = doc.offsetAt(range.end)
 
-    const doc = context.documents.get(parsedUri, root.languageId, root.snapshot)
-    const selectionStart = doc.offsetAt(range.start)
-    const selectionEnd = doc.offsetAt(range.end)
+  /** @type {TextEdit[]} */
+  const edits = []
 
-    /** @type {TextEdit[]} */
-    const edits = []
+  visitParents(ast, 'text', (node, ancestors) => {
+    const nodeStart = getNodeStartOffset(node)
+    const nodeEnd = getNodeEndOffset(node)
 
-    visitParents(ast, 'text', (node, ancestors) => {
-      const nodeStart = getNodeStartOffset(node)
-      const nodeEnd = getNodeEndOffset(node)
+    if (selectionStart < nodeStart) {
+      // Outside of this node
+      return
+    }
 
-      if (selectionStart < nodeStart) {
-        // Outside of this node
-        return
-      }
+    if (selectionEnd > nodeEnd) {
+      // Outside of this node
+      return
+    }
 
-      if (selectionEnd > nodeEnd) {
-        // Outside of this node
-        return
-      }
+    const matchingAncestor = ancestors.find(
+      (ancestor) => ancestor.type === type
+    )
 
-      const matchingAncestor = ancestors.find(
-        (ancestor) => ancestor.type === type
+    if (matchingAncestor) {
+      const ancestorStart = getNodeStartOffset(matchingAncestor)
+      const ancestorEnd = getNodeEndOffset(matchingAncestor)
+      const firstChildStart = getNodeStartOffset(matchingAncestor.children[0])
+      const lastChildEnd = getNodeEndOffset(
+        /** @type {Nodes} */ (matchingAncestor.children.at(-1))
       )
 
-      if (matchingAncestor) {
-        const ancestorStart = getNodeStartOffset(matchingAncestor)
-        const ancestorEnd = getNodeEndOffset(matchingAncestor)
-        const firstChildStart = getNodeStartOffset(matchingAncestor.children[0])
-        const lastChildEnd = getNodeEndOffset(
-          /** @type {Nodes} */ (matchingAncestor.children.at(-1))
-        )
-
-        edits.push(
-          {
-            newText: '',
-            range: {
-              start: doc.positionAt(ancestorStart),
-              end: doc.positionAt(firstChildStart)
-            }
-          },
-          {
-            newText: '',
-            range: {
-              start: doc.positionAt(lastChildEnd),
-              end: doc.positionAt(ancestorEnd)
-            }
+      edits.push(
+        {
+          newText: '',
+          range: {
+            start: doc.positionAt(ancestorStart),
+            end: doc.positionAt(firstChildStart)
           }
-        )
-      } else {
-        const valueOffset = getNodeStartOffset(node)
-        let insertStart = valueOffset
-        let insertEnd = getNodeEndOffset(node)
-
-        for (const match of node.value.matchAll(/\b/g)) {
-          if (match.index === undefined) {
-            continue
+        },
+        {
+          newText: '',
+          range: {
+            start: doc.positionAt(lastChildEnd),
+            end: doc.positionAt(ancestorEnd)
           }
+        }
+      )
+    } else {
+      const valueOffset = getNodeStartOffset(node)
+      let insertStart = valueOffset
+      let insertEnd = getNodeEndOffset(node)
+
+      for (const match of node.value.matchAll(/\b/g)) {
+        if (match.index === undefined) {
+          continue
+        }
 
-          const matchOffset = valueOffset + match.index
-
-          if (matchOffset <= selectionStart) {
-            insertStart = matchOffset
-            continue
-          }
+        const matchOffset = valueOffset + match.index
 
-          if (matchOffset >= selectionEnd) {
-            insertEnd = matchOffset
-            break
-          }
+        if (matchOffset <= selectionStart) {
+          insertStart = matchOffset
+          continue
         }
 
-        const startPosition = doc.positionAt(insertStart)
-        const endPosition = doc.positionAt(insertEnd)
-        edits.push(
-          {
-            newText: separator,
-            range: {start: startPosition, end: startPosition}
-          },
-          {
-            newText: separator,
-            range: {start: endPosition, end: endPosition}
-          }
-        )
+        if (matchOffset >= selectionEnd) {
+          insertEnd = matchOffset
+          break
+        }
       }
-    })
 
-    if (edits) {
-      return edits
+      const startPosition = doc.positionAt(insertStart)
+      const endPosition = doc.positionAt(insertEnd)
+      edits.push(
+        {
+          newText: separator,
+          range: {start: startPosition, end: startPosition}
+        },
+        {
+          newText: separator,
+          range: {start: endPosition, end: endPosition}
+        }
+      )
     }
+  })
+
+  if (edits.length > 0) {
+    await options.applyEdit({changes: {[uri]: edits}})
   }
 }
diff --git a/packages/language-service/lib/service-plugin.js b/packages/language-service/lib/service-plugin.js
index a00bd45..da0171b 100644
--- a/packages/language-service/lib/service-plugin.js
+++ b/packages/language-service/lib/service-plugin.js
@@ -1,28 +1,27 @@
 /**
- * @import {DataTransferItem, LanguageServicePlugin} from '@volar/language-service'
- * @import {SyntaxToggle} from './commands.js'
- */
-
-/**
- * @typedef Commands
- * @property {SyntaxToggle} toggleDelete
- * @property {SyntaxToggle} toggleEmphasis
- * @property {SyntaxToggle} toggleInlineCode
- * @property {SyntaxToggle} toggleStrong
- */
-
-/**
- * @typedef Provide
- * @property {() => Commands} mdxCommands
+ * @import {DataTransferItem, LanguageServicePlugin, WorkspaceEdit} from '@volar/language-service'
  */
 
 import path from 'node:path/posix'
 import {toMarkdown} from 'mdast-util-to-markdown'
 import {fromPlace} from 'unist-util-lsp'
 import {URI, Utils} from 'vscode-uri'
-import {createSyntaxToggle} from './commands.js'
+import {toggleSyntax} from './commands.js'
 import {VirtualMdxCode} from './virtual-code.js'
 
+/**
+ * @callback ApplyEdit
+ * @param {WorkspaceEdit} edit
+ *   The workspace edit to apply.
+ * @returns {PromiseLike<unknown>}
+ */
+
+/**
+ * @typedef createMdxServicePlugin.Options
+ * @property {ApplyEdit} applyEdit
+ *   A function to apply workspace edits.
+ */
+
 // https://github.com/microsoft/vscode/blob/1.83.1/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts#L29-L41
 const imageExtensions = new Set([
   '.bmp',
@@ -50,10 +49,12 @@ const imageExtensions = new Set([
  * - Custom commands for toggling `delete`, `emphasis`, `inlineCode`, and
  *   `strong` text.
  *
+ * @param {createMdxServicePlugin.Options} options
+ *   Options to configure the MDX language service.
  * @returns {LanguageServicePlugin}
  *   The Volar service plugin for MDX files.
  */
-export function createMdxServicePlugin() {
+export function createMdxServicePlugin(options) {
   return {
     name: 'mdx',
 
@@ -62,18 +63,67 @@ export function createMdxServicePlugin() {
         interFileDependencies: false,
         workspaceDiagnostics: false
       },
+      executeCommandProvider: {
+        commands: [
+          'mdx.toggleDelete',
+          'mdx.toggleEmphasis',
+          'mdx.toggleInlineCode',
+          'mdx.toggleStrong'
+        ]
+      },
       documentDropEditsProvider: true
     },
 
     create(context) {
       return {
-        provide: {
-          mdxCommands() {
-            return {
-              toggleDelete: createSyntaxToggle(context, 'delete', '~'),
-              toggleEmphasis: createSyntaxToggle(context, 'emphasis', '_'),
-              toggleInlineCode: createSyntaxToggle(context, 'inlineCode', '`'),
-              toggleStrong: createSyntaxToggle(context, 'strong', '**')
+        executeCommand(command, args) {
+          switch (command) {
+            case 'mdx.toggleDelete': {
+              return toggleSyntax(
+                context,
+                options,
+                'delete',
+                '~',
+                args[0],
+                args[1]
+              )
+            }
+
+            case 'mdx.toggleEmphasis': {
+              return toggleSyntax(
+                context,
+                options,
+                'emphasis',
+                '_',
+                args[0],
+                args[1]
+              )
+            }
+
+            case 'mdx.toggleInlineCode': {
+              return toggleSyntax(
+                context,
+                options,
+                'inlineCode',
+                '`',
+                args[0],
+                args[1]
+              )
+            }
+
+            case 'mdx.toggleStrong': {
+              return toggleSyntax(
+                context,
+                options,
+                'strong',
+                '**',
+                args[0],
+                args[1]
+              )
+            }
+
+            default: {
+              throw new Error('Unknown command: ' + command)
             }
           }
         },
diff --git a/packages/vscode-mdx/src/extension.js b/packages/vscode-mdx/src/extension.js
index 8d056e4..c06f703 100644
--- a/packages/vscode-mdx/src/extension.js
+++ b/packages/vscode-mdx/src/extension.js
@@ -1,5 +1,5 @@
 /**
- * @import {LabsInfo, TextEdit} from '@volar/vscode'
+ * @import {ExecuteCommandSignature, LabsInfo, TextEdit} from '@volar/vscode'
  * @import {ExtensionContext} from 'vscode'
  */
 
@@ -11,13 +11,11 @@ import {
   getTsdk
 } from '@volar/vscode'
 import {
-  commands,
   extensions,
   window,
   workspace,
   Disposable,
-  ProgressLocation,
-  WorkspaceEdit
+  ProgressLocation
 } from 'vscode'
 import {LanguageClient, TransportKind} from '@volar/vscode/node.js'
 
@@ -60,7 +58,8 @@ export async function activate(context) {
       markdown: {
         isTrusted: true,
         supportHtml: true
-      }
+      },
+      middleware: {executeCommand}
     }
   )
 
@@ -117,11 +116,7 @@ async function startServer() {
 
         disposable = Disposable.from(
           activateAutoInsertion('mdx', client),
-          activateDocumentDropEdit('mdx', client),
-          activateMdxToggleCommand('toggleDelete'),
-          activateMdxToggleCommand('toggleEmphasis'),
-          activateMdxToggleCommand('toggleInlineCode'),
-          activateMdxToggleCommand('toggleStrong')
+          activateDocumentDropEdit('mdx', client)
         )
       }
     )
@@ -129,37 +124,36 @@ async function startServer() {
 }
 
 /**
+ * Execute a command with correct arguments.
+ *
  * @param {string} command
- * @returns {Disposable}
+ *   The name of the command to execute.
+ * @param {unknown[]} args
+ *   The original arguments passed to the command.
+ * @param {ExecuteCommandSignature} next
+ *   The next middleware to execute.
+ * @returns {Promise<unknown>}
+ *   The command result.
  */
-function activateMdxToggleCommand(command) {
-  return commands.registerCommand('mdx.' + command, async () => {
-    const editor = window.activeTextEditor
-    if (!editor) {
-      return
-    }
-
-    const document = editor.document
-    const beforeVersion = document.version
-
-    /** @type {TextEdit[] | undefined} */
-    const response = await client.sendRequest('mdx/' + command, {
-      uri: String(document.uri),
-      range: client.code2ProtocolConverter.asRange(editor.selection)
-    })
+async function executeCommand(command, args, next) {
+  switch (command) {
+    case 'mdx.toggleDelete':
+    case 'mdx.toggleEmphasis':
+    case 'mdx.toggleInlineCode':
+    case 'mdx.toggleStrong': {
+      const editor = window.activeTextEditor
+      if (!editor) {
+        return
+      }
 
-    if (!response?.length) {
-      return
+      return next(command, [
+        String(editor.document.uri),
+        client.code2ProtocolConverter.asRange(editor.selection)
+      ])
     }
 
-    const textEdits = await client.protocol2CodeConverter.asTextEdits(response)
-
-    if (beforeVersion !== document.version) {
-      return
+    default: {
+      return next(command, args)
     }
-
-    const workspaceEdit = new WorkspaceEdit()
-    workspaceEdit.set(document.uri, textEdits)
-    workspace.applyEdit(workspaceEdit, {})
-  })
+  }
 }
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f073dc1..519c1a3 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -4,11 +4,11 @@
     "checkJs": true,
     "composite": true,
     "declarationMap": true,
-    "lib": ["es2021"],
+    "lib": ["es2022"],
     "module": "node16",
     "moduleDetection": "force",
     "strict": true,
     "stripInternal": true,
-    "target": "es2021"
+    "target": "es2022"
   }
 }


Test patch:
diff --git a/packages/language-server/test/initialize.test.js b/packages/language-server/test/initialize.test.js
index a2c2da5..61d112f 100644
--- a/packages/language-server/test/initialize.test.js
+++ b/packages/language-server/test/initialize.test.js
@@ -55,6 +55,14 @@ test('initialize', async () => {
       },
       documentRangeFormattingProvider: true,
       documentSymbolProvider: true,
+      executeCommandProvider: {
+        commands: [
+          'mdx.toggleDelete',
+          'mdx.toggleEmphasis',
+          'mdx.toggleInlineCode',
+          'mdx.toggleStrong'
+        ]
+      },
       experimental: {
         autoInsertionProvider: {
           configurationSections: [
diff --git a/packages/language-server/test/syntax-toggle.test.js b/packages/language-server/test/syntax-toggle.test.js
index 7c13541..f048c70 100644
--- a/packages/language-server/test/syntax-toggle.test.js
+++ b/packages/language-server/test/syntax-toggle.test.js
@@ -1,7 +1,8 @@
 /**
  * @import {LanguageServerHandle} from '@volar/test-utils'
- * @import {SyntaxToggleParams} from '@mdx-js/language-service'
+ * @import {Range} from '@volar/language-server'
  */
+
 import assert from 'node:assert/strict'
 import {afterEach, beforeEach, test} from 'node:test'
 import {createServer, fixtureUri, tsdk} from './utils.js'
@@ -22,88 +23,156 @@ afterEach(() => {
 
 test('delete', async () => {
   await serverHandle.openInMemoryDocument('memory://1', 'mdx', 'Hello\n')
-  const result = await serverHandle.connection.sendRequest(
-    'mdx/toggleDelete',
-    /** @satisfies {SyntaxToggleParams} */ ({
-      uri: 'memory://1',
-      range: {end: {character: 3, line: 0}, start: {character: 3, line: 0}}
-    })
+  const editsPromise = new Promise((resolve) => {
+    serverHandle.connection.onRequest('workspace/applyEdit', resolve)
+  })
+  const result = await serverHandle.sendExecuteCommandRequest(
+    'mdx.toggleDelete',
+    [
+      'memory://1',
+      /** @satisfies {Range} */
+      ({end: {character: 3, line: 0}, start: {character: 3, line: 0}})
+    ]
   )
 
-  assert.deepEqual(result, [
-    {
-      newText: '~',
-      range: {end: {character: 0, line: 0}, start: {character: 0, line: 0}}
-    },
-    {
-      newText: '~',
-      range: {end: {character: 5, line: 0}, start: {character: 5, line: 0}}
+  assert.equal(result, null)
+  assert.deepEqual(await editsPromise, {
+    edit: {
+      changes: {
+        'memory://1': [
+          {
+            newText: '~',
+            range: {
+              end: {character: 0, line: 0},
+              start: {character: 0, line: 0}
+            }
+          },
+          {
+            newText: '~',
+            range: {
+              end: {character: 5, line: 0},
+              start: {character: 5, line: 0}
+            }
+          }
+        ]
+      }
     }
-  ])
+  })
 })
 
 test('emphasis', async () => {
   await serverHandle.openInMemoryDocument('memory://1', 'mdx', 'Hello\n')
-  const result = await serverHandle.connection.sendRequest(
-    'mdx/toggleEmphasis',
-    /** @satisfies {SyntaxToggleParams} */ ({
-      uri: 'memory://1',
-      range: {end: {character: 3, line: 0}, start: {character: 3, line: 0}}
-    })
+  const editsPromise = new Promise((resolve) => {
+    serverHandle.connection.onRequest('workspace/applyEdit', resolve)
+  })
+  const result = await serverHandle.sendExecuteCommandRequest(
+    'mdx.toggleEmphasis',
+    [
+      'memory://1',
+      /** @satisfies {Range} */
+      ({end: {character: 3, line: 0}, start: {character: 3, line: 0}})
+    ]
   )
 
-  assert.deepEqual(result, [
-    {
-      newText: '_',
-      range: {end: {character: 0, line: 0}, start: {character: 0, line: 0}}
-    },
-    {
-      newText: '_',
-      range: {end: {character: 5, line: 0}, start: {character: 5, line: 0}}
+  assert.equal(result, null)
+  assert.deepEqual(await editsPromise, {
+    edit: {
+      changes: {
+        'memory://1': [
+          {
+            newText: '_',
+            range: {
+              end: {character: 0, line: 0},
+              start: {character: 0, line: 0}
+            }
+          },
+          {
+            newText: '_',
+            range: {
+              end: {character: 5, line: 0},
+              start: {character: 5, line: 0}
+            }
+          }
+        ]
+      }
     }
-  ])
+  })
 })
 
 test('inlineCode', async () => {
   await serverHandle.openInMemoryDocument('memory://1', 'mdx', 'Hello\n')
-  const result = await serverHandle.connection.sendRequest(
-    'mdx/toggleInlineCode',
-    /** @satisfies {SyntaxToggleParams} */ ({
-      uri: 'memory://1',
-      range: {end: {character: 3, line: 0}, start: {character: 3, line: 0}}
-    })
+  const editsPromise = new Promise((resolve) => {
+    serverHandle.connection.onRequest('workspace/applyEdit', resolve)
+  })
+  const result = await serverHandle.sendExecuteCommandRequest(
+    'mdx.toggleInlineCode',
+    [
+      'memory://1',
+      /** @satisfies {Range} */
+      ({end: {character: 3, line: 0}, start: {character: 3, line: 0}})
+    ]
   )
 
-  assert.deepEqual(result, [
-    {
-      newText: '`',
-      range: {end: {character: 0, line: 0}, start: {character: 0, line: 0}}
-    },
-    {
-      newText: '`',
-      range: {end: {character: 5, line: 0}, start: {character: 5, line: 0}}
+  assert.equal(result, null)
+  assert.deepEqual(await editsPromise, {
+    edit: {
+      changes: {
+        'memory://1': [
+          {
+            newText: '`',
+            range: {
+              end: {character: 0, line: 0},
+              start: {character: 0, line: 0}
+            }
+          },
+          {
+            newText: '`',
+            range: {
+              end: {character: 5, line: 0},
+              start: {character: 5, line: 0}
+            }
+          }
+        ]
+      }
     }
-  ])
+  })
 })
 
 test('strong', async () => {
   await serverHandle.openInMemoryDocument('memory://1', 'mdx', 'Hello\n')
-  const result = await serverHandle.connection.sendRequest(
-    'mdx/toggleStrong',
-    /** @satisfies {SyntaxToggleParams} */ ({
-      uri: 'memory://1',
-      range: {end: {character: 3, line: 0}, start: {character: 3, line: 0}}
-    })
+  const editsPromise = new Promise((resolve) => {
+    serverHandle.connection.onRequest('workspace/applyEdit', resolve)
+  })
+  const result = await serverHandle.sendExecuteCommandRequest(
+    'mdx.toggleStrong',
+    [
+      'memory://1',
+      /** @satisfies {Range} */
+      ({end: {character: 3, line: 0}, start: {character: 3, line: 0}})
+    ]
   )
 
-  assert.deepEqual(result, [
-    {
-      newText: '**',
-      range: {end: {character: 0, line: 0}, start: {character: 0, line: 0}}
-    },
-    {
-      newText: '**',
-      range: {end: {character: 5, line: 0}, start: {character: 5, line: 0}}
+  assert.equal(result, null)
+  assert.deepEqual(await editsPromise, {
+    edit: {
+      changes: {
+        'memory://1': [
+          {
+            newText: '**',
+            range: {
+              end: {character: 0, line: 0},
+              start: {character: 0, line: 0}
+            }
+          },
+          {
+            newText: '**',
+            range: {
+              end: {character: 5, line: 0},
+              start: {character: 5, line: 0}
+            }
+          }
+        ]
+      }
     }
-  ])
+  })
 })