From 6c5d28f4744747f6755462ef3ef1e58a18def6a2 Mon Sep 17 00:00:00 2001 From: lumir Date: Tue, 4 Aug 2026 08:50:30 +0900 Subject: [PATCH 1/8] feat: support `languageOptions.parser` to improve parser performance by integrating a Rust parser --- .../2026-markdown-custom-parsers/README.md | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 designs/2026-markdown-custom-parsers/README.md diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md new file mode 100644 index 00000000..ae15404e --- /dev/null +++ b/designs/2026-markdown-custom-parsers/README.md @@ -0,0 +1,290 @@ +- Repo: eslint/markdown +- Start Date: 2026-08-04 +- RFC PR: (leave this empty, to be filled in later) +- Authors: lumirlumir + +# Support `languageOptions.parser` for Markdown + +## Summary + +This RFC proposes adding `languageOptions.parser` to `@eslint/markdown` so users can replace the built-in [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) parser with another synchronous parser. It also proposes `languageOptions.parserOptions` for parser-specific configuration. The immediate motivation is to integrate an optional Rust-based parser, but the API is not Rust-specific and can be used by other Markdown parsers. The built-in JavaScript parser remains the default, so existing configurations and browser usage are unchanged. + +## Motivation + +Markdown parsing can dominate the time spent linting Markdown. In the prototypes described in [eslint/markdown#703](https://github.com/eslint/markdown/issues/703) and [eslint/markdown#706](https://github.com/eslint/markdown/pull/706), parsing took approximately **75 ms** of a **100 ms** run. Replacing it with `@eslint-markdown/parser`, a Rust-based parser built on [`satteri`](https://github.com/bruits/satteri), reduced parsing time to approximately **2 ms** and the total runtime to approximately **27 ms**, making the overall run approximately **3.5 times faster**. Although these results are preliminary, they demonstrate that selecting a faster parser can materially improve linting performance. + +`@eslint/markdown` currently uses `mdast-util-from-markdown` from the mdast ecosystem. [`satteri`](https://github.com/bruits/satteri) is a high-performance Markdown and MDX processor that parses Markdown in Rust and exposes the result to JavaScript as mdast. Its [`markdownToMdast()`](https://satteri.bruits.org/docs/entry-points/#trees-without-compiling) API supports CommonMark and GFM as well as frontmatter and math parsing. Initial experiments indicate that it can support most of the cases already implemented by `@eslint/markdown`, although broader compatibility testing is still required. + +Changing the default parser to a native parser is not currently suitable. Native Node.js addons require binaries for each supported operating system and architecture, while browser environments require a separate WebAssembly build and loading code. Adding those artifacts to the default installation would increase distribution and maintenance costs for every user, including users who do not need the performance improvement. + +A parser option solves this by keeping the current portable parser as the default while allowing users to opt into another implementation. It also allows users to provide their own mdast-related parser with the syntax plugins they need. This proposal adds the option specifically to the Markdown language implementation. It does not propose a new parser mechanism for every ESLint language plugins such as JSON and CSS. + +## Detailed Design + +### Goals and Non-Goals + +This proposal has the following goals: + +1. Allow a configuration to select a custom parser for `markdown/commonmark` or `markdown/gfm`. +2. Preserve the existing behavior when no custom parser is configured. +3. Define a small parser contract that can be implemented by JavaScript, native Node.js addons, or WebAssembly packages. +4. Allow parsers that preserve the mdast contract expected by `MarkdownSourceCode` and existing `@eslint/markdown` rules. + +This proposal does not: + +- add a Rust parser to `@eslint/markdown` by default. +- change the default parser. +- define how native or WebAssembly parser packages are built or distributed. +- support asynchronous parsers. +- guarantee that every `@eslint/markdown` rule works with every custom parser. + +### New Language Options + +`MarkdownLanguageOptions` will gain two properties: + +```ts +export interface MarkdownLanguageOptions extends LanguageOptions { + frontmatter?: false | "yaml" | "toml" | "json"; + math?: boolean; + parser?: MarkdownParser; + parserOptions?: Record; +} +``` + +`parser` is an object with a synchronous `parse()` method. `parserOptions` contains additional options for that method and is used only when `parser` is also specified. + +The parser contract is: + +```ts +import type { Root } from "mdast"; +import type { ObjectMetaProperties } from "@eslint/core"; + +export type NonMdastParser = ObjectMetaProperties & { + parse(text: string, options?: any): unknown; +}; + +export type MdastParser = ObjectMetaProperties & { + parse(text: string, options?: any): Root; +}; + +export type MarkdownParser = NonMdastParser | MdastParser; +``` + +Parser metadata is optional and informational. `@eslint/markdown` does not change parsing behavior based on `meta`. + +The parser is an object rather than a bare function to follow the shape used by ESLint parser packages and to leave room for metadata without changing the invocation API. Like `fromMarkdown()`, the method receives the Markdown text as its first argument and parser options as its second argument. + +This RFC proposes only `parse()` for the initial implementation. Whether Markdown parsers need a `parseForESLint()`-style method or a way to return additional parser services is left for a future proposal after a concrete use case is identified. `MarkdownLanguage` already owns creation of `MarkdownSourceCode`, so the Rust prototype only needs to produce the syntax tree. + +### Parser Invocation + +When `languageOptions.parser` is present, `MarkdownLanguage#parse()` calls its `parse()` method with the Markdown source text and a newly created options object: + +```js +const root = parser.parse(text, { + mode: this.#mode, + frontmatter: context?.languageOptions?.frontmatter, + math: context?.languageOptions?.math, + ...context?.languageOptions?.parserOptions, +}); +``` + +ESLint merges `defaultLanguageOptions` before parsing, so `frontmatter` and `math` have their effective default value of `false`. `mode` comes from the configured language: `markdown/commonmark` supplies `"commonmark"`, and `markdown/gfm` supplies `"gfm"`. + +The values in `parserOptions` take precedence over the corresponding values derived from `languageOptions` when invoking the parser. Although `mode`, `frontmatter`, and `math` should normally be configured through the standard language and language options, a user can provide a different value specifically to the parser by setting the same property in `parserOptions`. This matches ESLint's existing parser-option precedence, as clarified in [eslint/eslint#20926](https://github.com/eslint/eslint/pull/20926). + +When `languageOptions.parser` is absent, `MarkdownLanguage#parse()` continues to call `mdast-util-from-markdown` with the existing micromark and mdast extensions. `parserOptions` is not a new configuration surface for the built-in parser and has no effect unless `parser` is also configured. Users who want to customize `mdast-util-from-markdown` can expose a small parser object that calls it with their chosen extensions. + +The parser call remains synchronous. Returning a promise is not supported. + +### Rust Parser Integration + +The proposed `@eslint-markdown/parser` package will use `satteri`, which exposes its Rust implementation to JavaScript through Node-API. The intended data flow is: + +1. JavaScript passes the Markdown source string to `satteri` through its Node-API binding. +2. `satteri` parses the source and stores the resulting mdast nodes in a Rust-managed memory pool called an arena. +3. The Rust arena is encoded into a compact binary buffer and passed to JavaScript. +4. JavaScript reads the buffer and materializes the mdast nodes without serializing and deserializing the complete tree as JSON. +5. The materialized [mdast `Root`](https://github.com/syntax-tree/mdast#root) is returned to `@eslint-markdown/parser`. + +This does not use `execSync()` or spawn a child process. Node.js loads the compiled `.node` addon as a dynamic library, and JavaScript calls its exported function directly. The call therefore remains synchronous and runs on the Node.js main thread, just like the current call to `fromMarkdown()`. + +The `@eslint-markdown/parser` package, including its platform-specific native binaries and any future WebAssembly build, is separate from `@eslint/markdown`. Its implementation and distribution are not required in order to add the general parser option. + +### AST Compatibility + +The supported compatibility target for a custom parser is an [mdast `Root`](https://github.com/syntax-tree/mdast#root). A parser intended to work with the bundled `@eslint/markdown` rules should produce the same standard node shapes and position information as the built-in parser. + +The complete compatibility contract is the mdast and unist data used by `@eslint/markdown`: + +- nodes use the `type` property. +- the top-level node is an mdast `Root`. +- child relationships follow mdast. +- nodes that may be reported by rules contain accurate unist `position` data. +- standard CommonMark, GFM, frontmatter, and math nodes match the shapes produced by the current parser. + +`@eslint/markdown` will not recursively validate the returned AST because doing so would add overhead to every parse. Parser authors are responsible for compatibility. Parser-specific nodes may be added, but existing rules are only expected to work when the nodes they consume remain compatible. + +As with custom JavaScript parsers, the runtime API does not attempt to prove that the returned AST matches the expected format. A parser may technically return different node types, and custom rules may be able to consume them, but that usage is outside the compatibility guarantee for the bundled rules. Whether the public TypeScript type should explicitly model non-mdast parsers remains an open question. + +This has a limitation for rule `meta.languages`: a rule can declare support for `markdown/commonmark` or `markdown/gfm`, but it cannot further constrain support to a particular parser or AST dialect. Parser and rule authors must document any such compatibility requirements. (TODO: Think about it further.) + +### Error Handling + +The existing error behavior remains in place. If a custom parser throws an error, `MarkdownLanguage#parse()` catches it and returns an unsuccessful `ParseResult` instead of rethrowing it. + +### Validation + +`MarkdownLanguage#validateLanguageOptions()` will add the following checks: + +- `parser`, when present, must be a non-null object with a callable `parse` property. +- `parserOptions`, when present, must be a non-null, non-array object. + +The existing validation for `frontmatter` and `math` remains unchanged. + +### Configuration Example + +A future Rust-based parser package could be selected as follows: + +```js +import markdown from "@eslint/markdown"; +import parser from "@eslint-markdown/parser"; + +export default [ + { + files: ["**/*.md"], + plugins: { + markdown, + }, + language: "markdown/gfm", + languageOptions: { + frontmatter: "yaml", + math: true, + parser, + parserOptions: { + implementationOption: true, + }, + }, + }, +]; +``` + +A JavaScript parser can implement the same interface: + +```js +import { fromMarkdown } from "mdast-util-from-markdown"; +import { customSyntax } from "micromark-extension-custom-syntax"; +import { customSyntaxFromMarkdown } from "mdast-util-custom-syntax"; + +const parser = { + meta: { + name: "example-markdown-parser", + version: "1.0.0", + }, + parse(text) { + return fromMarkdown(text, { + extensions: [customSyntax()], + mdastExtensions: [customSyntaxFromMarkdown()], + }); + }, +}; +``` + +### Implementation Approach + +The implementation in `eslint/markdown` will include: + +1. Exporting `NonMdastParser`, `MdastParser`, and `MarkdownParser` types and adding `parser` and `parserOptions` to `MarkdownLanguageOptions` in `src/types.ts`. +2. Extending `MarkdownLanguage#validateLanguageOptions()` with the validation described above. +3. Selecting and invoking the custom parser in `MarkdownLanguage#parse()` while leaving the current default-parser path unchanged. +4. Adding unit tests with a small parser object for invocation, option precedence, invalid configuration, thrown errors, and both Markdown modes. +5. Adding `@eslint-markdown/parser` as a development dependency and running integration tests against both the default JavaScript parser and the Rust-based parser. These tests will cover CommonMark, GFM, frontmatter, math, source locations, and the bundled rules. +6. Adding type tests for the public parser interfaces and configuration options. + +`@eslint-markdown/parser` remains an optional runtime dependency and is developed and released separately. It is included in `@eslint/markdown` only as a development dependency for integration and compatibility testing. + +## Documentation + +The `@eslint/markdown` README will document: + +- the `parser` and `parserOptions` language options. +- the required synchronous `parse(text, options)` contract. +- the mdast and positional-information requirements. +- an example using an external parser package. +- an example wrapping `mdast-util-from-markdown` with custom extensions. +- the fact that parser behavior, platform support, and rule compatibility are the parser author's responsibility. + +A blog post is not required for the API change. A future optimized parser may be announced separately when it is stable and has published compatibility and benchmark results. + +## Drawbacks + +Custom parsers may handle Markdown edge cases differently, resulting in different AST nodes, source locations, or fixes. They may also introduce installation and platform-specific issues, especially when using native binaries. + +## Backwards Compatibility Analysis + +This proposal is additive. Configurations that do not specify `languageOptions.parser` continue to use `mdast-util-from-markdown` with the same GFM, frontmatter, and math extensions as today. The default dependency graph and browser behavior do not change. + +Only configurations using the new options are affected by the new validation and custom parser behavior. A custom parser that returns an incompatible tree may produce a parse error or rule failures, but no existing configuration can encounter that behavior before opting into the feature. + +## Alternatives + +### Replace the Default Parser + +`@eslint/markdown` could replace `mdast-util-from-markdown` with a Rust-based parser if their output is sufficiently compatible. This would give all Node.js users the performance improvement without a configuration change. It was not selected because native binaries vary by operating system and architecture, browsers require a separate WebAssembly path, and the alternative parser is still experimental. Keeping the JavaScript parser as the default preserves the current portability and installation behavior. + +### Accept Bare Parser Functions + +`languageOptions.parser` could accept a function with the same signature as `fromMarkdown()`. A parser object was selected because it matches the package shape familiar from ESLint's JavaScript parsers, supports optional metadata, and can grow additional parser-level capabilities in a backwards-compatible way if a future RFC requires them. + +### Require and Validate mdast Trees + +`@eslint/markdown` could require every custom parser to return mdast and recursively validate the tree before running rules. This would provide a stronger compatibility boundary, but the validation would add runtime overhead and still could not detect every semantic difference between Markdown parsers. The proposed API documents mdast as the compatibility target without performing recursive runtime validation. + +## Open Questions + +1. `parseForESLint()`-style API? TODO +1. `NonMdastParser` type? TODO + +## Help Needed + +N/A + +## Frequently Asked Questions + +### Is this RFC specific to Rust? + +No. Rust parser performance motivated the proposal, but any synchronous parser that returns compatible mdast can implement the interface. + +### Why does `@eslint-markdown/parser` use `satteri` instead of `markdown-rs`? + +The initial plan was to use [`markdown-rs`](https://github.com/wooorm/markdown-rs) and transfer its Rust AST to JavaScript by serializing the complete tree as JSON and then calling `JSON.parse()`. Further experimentation showed that this serialization and deserialization added significant overhead. + +[`satteri`](https://github.com/bruits/satteri) instead stores the parsed tree in a Rust arena, transfers it through a compact binary buffer, and materializes the mdast nodes in JavaScript using a binary reader. This avoids the full-tree JSON serialization and deserialization used by the earlier `markdown-rs` integration design and was substantially faster in the prototype. For this reason, the implementation of `@eslint-markdown/parser` was changed to use `satteri`. + +### Will the Rust parser become the default? + +Not as part of this RFC. `mdast-util-from-markdown` remains the default. Replacing it would require separate compatibility, distribution, browser, and maintenance analysis. + +### Can a parser return a non-mdast AST? + +The runtime does not recursively validate the AST. Custom rules may be able to consume other node shapes, much like rules written for a custom JavaScript parser. However, `MarkdownSourceCode` and the bundled rules are designed for mdast, so non-mdast parsers are not guaranteed to work. The exact public type for this case is an open question. + +### Can a custom parser be asynchronous? + +No. The language parsing API is synchronous, and native Node.js addons can expose synchronous functions without spawning a child process. + +### Are existing rules guaranteed to work with every custom parser? + +No. They should work when the parser produces the mdast node shapes and positions described by the compatibility contract. Parser authors and users are responsible for differences in syntax support or edge-case behavior. + +### Does `parserOptions` configure the built-in parser? + +No. It configures the selected custom parser. To customize `mdast-util-from-markdown`, users can provide a parser object that wraps `fromMarkdown()` with the desired extensions. + +## Related Discussions + +- [eslint/markdown#703: Support `languageOptions.parser` to improve parser performance by integrating a Rust parser](https://github.com/eslint/markdown/issues/703) +- [eslint/markdown#706: Experimental implementation of `languageOptions.parser`](https://github.com/eslint/markdown/pull/706) +- [eslint/eslint#20926: Clarify precedence of `parserOptions` over `languageOptions`](https://github.com/eslint/eslint/pull/20926) +- [lumirlumir/npm-eslint-markdown#638: Rust-based `@eslint-markdown/parser` prototype](https://github.com/lumirlumir/npm-eslint-markdown/pull/638) +- [Original ESLint Discord discussion](https://discord.com/channels/688543509199716507/688770853588172860/1519556220917252146) From 59462ec9c71d428fa4120572194cc6fdb50a4c0b Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 22:46:07 +0900 Subject: [PATCH 2/8] wip --- .../2026-markdown-custom-parsers/README.md | 94 ++++++++++--------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index ae15404e..f06bb9bd 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -1,13 +1,13 @@ - Repo: eslint/markdown - Start Date: 2026-08-04 -- RFC PR: (leave this empty, to be filled in later) +- RFC PR: https://github.com/eslint/rfcs/pull/152 - Authors: lumirlumir # Support `languageOptions.parser` for Markdown ## Summary -This RFC proposes adding `languageOptions.parser` to `@eslint/markdown` so users can replace the built-in [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) parser with another synchronous parser. It also proposes `languageOptions.parserOptions` for parser-specific configuration. The immediate motivation is to integrate an optional Rust-based parser, but the API is not Rust-specific and can be used by other Markdown parsers. The built-in JavaScript parser remains the default, so existing configurations and browser usage are unchanged. +This RFC proposes adding `languageOptions.parser` to `@eslint/markdown` so users can replace the built-in [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) parser with another synchronous parser. The immediate motivation is to integrate an optional Rust-based parser, but the API is not Rust-specific and can be used by other Markdown parsers. The built-in JavaScript parser remains the default, so existing configurations and browser usage are unchanged. ## Motivation @@ -15,7 +15,7 @@ Markdown parsing can dominate the time spent linting Markdown. In the prototypes `@eslint/markdown` currently uses `mdast-util-from-markdown` from the mdast ecosystem. [`satteri`](https://github.com/bruits/satteri) is a high-performance Markdown and MDX processor that parses Markdown in Rust and exposes the result to JavaScript as mdast. Its [`markdownToMdast()`](https://satteri.bruits.org/docs/entry-points/#trees-without-compiling) API supports CommonMark and GFM as well as frontmatter and math parsing. Initial experiments indicate that it can support most of the cases already implemented by `@eslint/markdown`, although broader compatibility testing is still required. -Changing the default parser to a native parser is not currently suitable. Native Node.js addons require binaries for each supported operating system and architecture, while browser environments require a separate WebAssembly build and loading code. Adding those artifacts to the default installation would increase distribution and maintenance costs for every user, including users who do not need the performance improvement. +Changing the default parser to `satteri` is not currently suitable. Although `satteri` provides a browser-targeted WebAssembly binding, adopting it as the default would add platform-specific native binaries to the default Node.js dependency graph and a WebAssembly artifact and initialization path to the default browser dependency graph. This would increase distribution size and introduce additional runtime and bundler compatibility requirements for every user, including users who do not need the performance improvement. A parser option solves this by keeping the current portable parser as the default while allowing users to opt into another implementation. It also allows users to provide their own mdast-related parser with the syntax plugins they need. This proposal adds the option specifically to the Markdown language implementation. It does not propose a new parser mechanism for every ESLint language plugins such as JSON and CSS. @@ -40,18 +40,17 @@ This proposal does not: ### New Language Options -`MarkdownLanguageOptions` will gain two properties: +`MarkdownLanguageOptions` will gain one property: ```ts export interface MarkdownLanguageOptions extends LanguageOptions { frontmatter?: false | "yaml" | "toml" | "json"; math?: boolean; parser?: MarkdownParser; - parserOptions?: Record; } ``` -`parser` is an object with a synchronous `parse()` method. `parserOptions` contains additional options for that method and is used only when `parser` is also specified. +`parser` is an object with a synchronous `parse()` method. Parser-specific options can be added directly to `languageOptions` like `LanguageOptions`, `MarkdownLanguageOptions` permits additional properties, and those properties are forwarded to the parser. The parser contract is: @@ -59,15 +58,20 @@ The parser contract is: import type { Root } from "mdast"; import type { ObjectMetaProperties } from "@eslint/core"; -export type NonMdastParser = ObjectMetaProperties & { - parse(text: string, options?: any): unknown; -}; +/** @deprecated Use `MarkdownParserMode` instead. */ +export type ParserMode = MarkdownParserMode; -export type MdastParser = ObjectMetaProperties & { - parse(text: string, options?: any): Root; -}; +export type MarkdownParserMode = "commonmark" | "gfm"; -export type MarkdownParser = NonMdastParser | MdastParser; +export type MarkdownParser = ObjectMetaProperties & { + parse( + text: string, + options: MarkdownLanguageOptions & { + mode: MarkdownParserMode; + parser?: never; + }, + ): Root; +}; ``` Parser metadata is optional and informational. `@eslint/markdown` does not change parsing behavior based on `meta`. @@ -78,34 +82,41 @@ This RFC proposes only `parse()` for the initial implementation. Whether Markdow ### Parser Invocation -When `languageOptions.parser` is present, `MarkdownLanguage#parse()` calls its `parse()` method with the Markdown source text and a newly created options object: +`MarkdownLanguage#parse()` selects the configured parser, falling back to the parser in `defaultLanguageOptions`, and calls its `parse()` method with the Markdown source text and a newly created options object: ```js +const { + parser = this.defaultLanguageOptions.parser, + ...restLanguageOptions +} = context?.languageOptions ?? {}; + +// ... + const root = parser.parse(text, { + ...restLanguageOptions, mode: this.#mode, - frontmatter: context?.languageOptions?.frontmatter, - math: context?.languageOptions?.math, - ...context?.languageOptions?.parserOptions, }); ``` ESLint merges `defaultLanguageOptions` before parsing, so `frontmatter` and `math` have their effective default value of `false`. `mode` comes from the configured language: `markdown/commonmark` supplies `"commonmark"`, and `markdown/gfm` supplies `"gfm"`. -The values in `parserOptions` take precedence over the corresponding values derived from `languageOptions` when invoking the parser. Although `mode`, `frontmatter`, and `math` should normally be configured through the standard language and language options, a user can provide a different value specifically to the parser by setting the same property in `parserOptions`. This matches ESLint's existing parser-option precedence, as clarified in [eslint/eslint#20926](https://github.com/eslint/eslint/pull/20926). +The `parser` property itself is omitted from the options passed to `parse()`, and the language's `mode` is always applied after the other language options. All other standard and parser-specific language options are forwarded unchanged. -When `languageOptions.parser` is absent, `MarkdownLanguage#parse()` continues to call `mdast-util-from-markdown` with the existing micromark and mdast extensions. `parserOptions` is not a new configuration surface for the built-in parser and has no effect unless `parser` is also configured. Users who want to customize `mdast-util-from-markdown` can expose a small parser object that calls it with their chosen extensions. +The default parser is an object stored in `defaultLanguageOptions.parser`. Its `parse()` method removes `mode` from the forwarded language options and calls `mdast-util-from-markdown` with the existing micromark and mdast extensions. This keeps the built-in parser's behavior unchanged while using the same parser invocation path as a custom parser. The parser call remains synchronous. Returning a promise is not supported. ### Rust Parser Integration -The proposed `@eslint-markdown/parser` package will use `satteri`, which exposes its Rust implementation to JavaScript through Node-API. The intended data flow is: +The proposed `@eslint-markdown/parser` package will use [`satteri`](https://github.com/bruits/satteri) under the hood, which exposes its Rust implementation to JavaScript through Node-API. The intended data flow is: -1. JavaScript passes the Markdown source string to `satteri` through its Node-API binding. -2. `satteri` parses the source and stores the resulting mdast nodes in a Rust-managed memory pool called an arena. -3. The Rust arena is encoded into a compact binary buffer and passed to JavaScript. -4. JavaScript reads the buffer and materializes the mdast nodes without serializing and deserializing the complete tree as JSON. -5. The materialized [mdast `Root`](https://github.com/syntax-tree/mdast#root) is returned to `@eslint-markdown/parser`. +1. `@eslint/markdown` passes the Markdown source string and language options to `@eslint-markdown/parser`, which wraps `satteri` behind the `MarkdownParser` contract expected by `@eslint/markdown`. +2. `@eslint-markdown/parser` passes the Markdown source string to `satteri` through its Node-API binding. +3. `satteri` parses the source and stores the resulting mdast nodes in a Rust-managed memory pool called an arena. +4. The Rust arena is encoded into a compact binary buffer and passed to JavaScript. +5. JavaScript reads the buffer and materializes the mdast nodes without serializing and deserializing the complete tree as JSON. +6. The materialized [mdast `Root`](https://github.com/syntax-tree/mdast#root) is returned to `@eslint-markdown/parser`. +7. `@eslint-markdown/parser` returns the compatible mdast `Root` from its `parse()` method to `@eslint/markdown`. This does not use `execSync()` or spawn a child process. Node.js loads the compiled `.node` addon as a dynamic library, and JavaScript calls its exported function directly. The call therefore remains synchronous and runs on the Node.js main thread, just like the current call to `fromMarkdown()`. @@ -125,9 +136,7 @@ The complete compatibility contract is the mdast and unist data used by `@eslint `@eslint/markdown` will not recursively validate the returned AST because doing so would add overhead to every parse. Parser authors are responsible for compatibility. Parser-specific nodes may be added, but existing rules are only expected to work when the nodes they consume remain compatible. -As with custom JavaScript parsers, the runtime API does not attempt to prove that the returned AST matches the expected format. A parser may technically return different node types, and custom rules may be able to consume them, but that usage is outside the compatibility guarantee for the bundled rules. Whether the public TypeScript type should explicitly model non-mdast parsers remains an open question. - -This has a limitation for rule `meta.languages`: a rule can declare support for `markdown/commonmark` or `markdown/gfm`, but it cannot further constrain support to a particular parser or AST dialect. Parser and rule authors must document any such compatibility requirements. (TODO: Think about it further.) +The public `MarkdownParser` type requires `parse()` to return an mdast `Root`. At runtime, `@eslint/markdown` validates the parser interface but does not recursively validate the returned tree. Parser authors are responsible for producing a compatible mdast tree; incompatible trees may fail during source code construction or rule execution and are outside the supported contract. ### Error Handling @@ -138,7 +147,6 @@ The existing error behavior remains in place. If a custom parser throws an error `MarkdownLanguage#validateLanguageOptions()` will add the following checks: - `parser`, when present, must be a non-null object with a callable `parse` property. -- `parserOptions`, when present, must be a non-null, non-array object. The existing validation for `frontmatter` and `math` remains unchanged. @@ -161,9 +169,7 @@ export default [ frontmatter: "yaml", math: true, parser, - parserOptions: { - implementationOption: true, - }, + implementationOption: true, }, }, ]; @@ -194,10 +200,10 @@ const parser = { The implementation in `eslint/markdown` will include: -1. Exporting `NonMdastParser`, `MdastParser`, and `MarkdownParser` types and adding `parser` and `parserOptions` to `MarkdownLanguageOptions` in `src/types.ts`. +1. Exporting `MarkdownParserMode`, the deprecated `ParserMode` alias, and `MarkdownParser`, and adding `parser` to `MarkdownLanguageOptions` in `src/types.ts`. 2. Extending `MarkdownLanguage#validateLanguageOptions()` with the validation described above. -3. Selecting and invoking the custom parser in `MarkdownLanguage#parse()` while leaving the current default-parser path unchanged. -4. Adding unit tests with a small parser object for invocation, option precedence, invalid configuration, thrown errors, and both Markdown modes. +3. Representing the built-in parser in `defaultLanguageOptions.parser`, then selecting and invoking the configured or default parser in `MarkdownLanguage#parse()`. +4. Adding unit tests with a small parser object for invocation, option forwarding, invalid configuration, thrown errors, and both Markdown modes. 5. Adding `@eslint-markdown/parser` as a development dependency and running integration tests against both the default JavaScript parser and the Rust-based parser. These tests will cover CommonMark, GFM, frontmatter, math, source locations, and the bundled rules. 6. Adding type tests for the public parser interfaces and configuration options. @@ -207,14 +213,14 @@ The implementation in `eslint/markdown` will include: The `@eslint/markdown` README will document: -- the `parser` and `parserOptions` language options. +- the `parser` language option and how additional language options are forwarded to it. - the required synchronous `parse(text, options)` contract. - the mdast and positional-information requirements. - an example using an external parser package. - an example wrapping `mdast-util-from-markdown` with custom extensions. - the fact that parser behavior, platform support, and rule compatibility are the parser author's responsibility. -A blog post is not required for the API change. A future optimized parser may be announced separately when it is stable and has published compatibility and benchmark results. +After the parser option is released and an experimental version of `@eslint-markdown/parser` is published, a blog post would be useful to invite users to try the Rust-based parser and provide feedback. The post can explain how to opt in and share the available compatibility and benchmark results, clearly identifying any preliminary findings. ## Drawbacks @@ -224,13 +230,13 @@ Custom parsers may handle Markdown edge cases differently, resulting in differen This proposal is additive. Configurations that do not specify `languageOptions.parser` continue to use `mdast-util-from-markdown` with the same GFM, frontmatter, and math extensions as today. The default dependency graph and browser behavior do not change. -Only configurations using the new options are affected by the new validation and custom parser behavior. A custom parser that returns an incompatible tree may produce a parse error or rule failures, but no existing configuration can encounter that behavior before opting into the feature. +Only configurations using the new option are affected by the new validation and custom parser behavior. A custom parser that returns an incompatible tree may produce a parse error or rule failures, but no existing configuration can encounter that behavior before opting into the feature. ## Alternatives ### Replace the Default Parser -`@eslint/markdown` could replace `mdast-util-from-markdown` with a Rust-based parser if their output is sufficiently compatible. This would give all Node.js users the performance improvement without a configuration change. It was not selected because native binaries vary by operating system and architecture, browsers require a separate WebAssembly path, and the alternative parser is still experimental. Keeping the JavaScript parser as the default preserves the current portability and installation behavior. +`@eslint/markdown` could replace `mdast-util-from-markdown` with a Rust-based parser once it is sufficiently compatible and mature. This would make the performance improvement available without a configuration change. Although `satteri` already distributes platform-specific Node-API binaries and a browser-targeted WebAssembly binding, adopting it as the default would add those artifacts to the default dependency graph and introduce a WebAssembly initialization path and additional bundler compatibility requirements for browser consumers. Because the integration remains experimental, this proposal keeps the portable JavaScript parser as the default and makes the Rust-based parser opt-in while its compatibility and distribution behavior are evaluated. ### Accept Bare Parser Functions @@ -243,7 +249,8 @@ Only configurations using the new options are affected by the new validation and ## Open Questions 1. `parseForESLint()`-style API? TODO -1. `NonMdastParser` type? TODO + +1. `NonMdastParser`? TODO ## Help Needed @@ -267,7 +274,7 @@ Not as part of this RFC. `mdast-util-from-markdown` remains the default. Replaci ### Can a parser return a non-mdast AST? -The runtime does not recursively validate the AST. Custom rules may be able to consume other node shapes, much like rules written for a custom JavaScript parser. However, `MarkdownSourceCode` and the bundled rules are designed for mdast, so non-mdast parsers are not guaranteed to work. The exact public type for this case is an open question. +The public `MarkdownParser` type requires an mdast `Root`, but the runtime does not recursively validate the AST. A JavaScript parser may therefore return other node shapes at runtime, much like a custom JavaScript parser can. However, that is outside the supported contract, and `MarkdownSourceCode` and the bundled rules are designed for mdast. ### Can a custom parser be asynchronous? @@ -277,14 +284,13 @@ No. The language parsing API is synchronous, and native Node.js addons can expos No. They should work when the parser produces the mdast node shapes and positions described by the compatibility contract. Parser authors and users are responsible for differences in syntax support or edge-case behavior. -### Does `parserOptions` configure the built-in parser? +### Can a rule use `meta.languages` to require a particular parser? -No. It configures the selected custom parser. To customize `mdast-util-from-markdown`, users can provide a parser object that wraps `fromMarkdown()` with the desired extensions. +No. `meta.languages` describes compatibility with the selected ESLint language, not with a particular parser implementation. Because a custom parser is required to produce a compatible mdast `Root`, rules that declare support for `markdown/commonmark` or `markdown/gfm` are expected to work with any conforming parser. A parser that exposes a different AST contract should be provided through a separate ESLint language rather than through `languageOptions.parser`. ## Related Discussions - [eslint/markdown#703: Support `languageOptions.parser` to improve parser performance by integrating a Rust parser](https://github.com/eslint/markdown/issues/703) - [eslint/markdown#706: Experimental implementation of `languageOptions.parser`](https://github.com/eslint/markdown/pull/706) -- [eslint/eslint#20926: Clarify precedence of `parserOptions` over `languageOptions`](https://github.com/eslint/eslint/pull/20926) - [lumirlumir/npm-eslint-markdown#638: Rust-based `@eslint-markdown/parser` prototype](https://github.com/lumirlumir/npm-eslint-markdown/pull/638) - [Original ESLint Discord discussion](https://discord.com/channels/688543509199716507/688770853588172860/1519556220917252146) From c2aff5f23adbe0514ec91af121c05769b0eaeafd Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 22:53:34 +0900 Subject: [PATCH 3/8] wip --- designs/2026-markdown-custom-parsers/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index f06bb9bd..65abcc8c 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -248,9 +248,9 @@ Only configurations using the new option are affected by the new validation and ## Open Questions -1. `parseForESLint()`-style API? TODO +1. Does the Markdown parser contract need a `parseForESLint()`-style method? ESLint parsers can use `parseForESLint()` to return additional values such as parser services along with the AST. In this proposal, `MarkdownLanguage` creates `MarkdownSourceCode`, and the current integration only needs the parser to produce an mdast syntax tree. The current view is therefore that `parse()` is sufficient and `parseForESLint()` is unnecessary. Is there a concrete use case that requires including it in the initial contract? -1. `NonMdastParser`? TODO +1. Does the Markdown parser contract need a `NonMdastParser` type analogous to ESLint's [`NonESTreeParser`](https://github.com/eslint/eslint/blob/v10.9.1/lib/types/index.d.ts#L1021)? ESLint's JavaScript parser types distinguish parsers that return ESTree from parsers that may return another AST format. This initial proposal intentionally supports only parsers that return a compatible mdast `Root`, because `MarkdownSourceCode`, traversal, and the bundled rules are built around the mdast contract. The current view is therefore that `NonMdastParser` should not be included. Is there a concrete use case for a non-mdast parser that should be supported by this language rather than provided through a separate ESLint language? ## Help Needed From 4f2645373fd554439d7ea4aef9a915f7a0a8638c Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 23:09:55 +0900 Subject: [PATCH 4/8] wip --- .../2026-markdown-custom-parsers/README.md | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index 65abcc8c..fa6c2979 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -268,9 +268,29 @@ The initial plan was to use [`markdown-rs`](https://github.com/wooorm/markdown-r [`satteri`](https://github.com/bruits/satteri) instead stores the parsed tree in a Rust arena, transfers it through a compact binary buffer, and materializes the mdast nodes in JavaScript using a binary reader. This avoids the full-tree JSON serialization and deserialization used by the earlier `markdown-rs` integration design and was substantially faster in the prototype. For this reason, the implementation of `@eslint-markdown/parser` was changed to use `satteri`. -### Will the Rust parser become the default? +### How can the synchronous Markdown parsing API call into Rust? -Not as part of this RFC. `mdast-util-from-markdown` remains the default. Replacing it would require separate compatibility, distribution, browser, and maintenance analysis. +`@eslint-markdown/parser` loads `satteri` through its Node-API binding and invokes it directly on the Node.js main thread. The call remains synchronous from `MarkdownLanguage#parse()` through the parser wrapper and native binding. + +### Does the integration invoke Rust through `execSync()`? + +No. It neither spawns a Rust executable nor creates a child process for each parse. Node.js loads the compiled `.node` addon as a dynamic library, and JavaScript calls its exported function directly. + +### Did `markdown-rs` provide a synchronous Node.js API directly? + +No. `markdown-rs` is a Rust crate rather than a ready-made Node.js package, so the earlier design required a Node-API binding layer. The current prototype instead uses `satteri`, which already provides Node-API and WebAssembly bindings, behind the ESLint-compatible `@eslint-markdown/parser` wrapper. + +### Why add `languageOptions.parser` instead of replacing the default parser? + +Replacing `mdast-util-from-markdown` may be appropriate later if the Rust-based parser becomes sufficiently compatible and mature, but it is not part of this RFC. Making it the default now would add platform-specific native binaries to the default Node.js dependency graph and a WebAssembly artifact and initialization path for browser consumers. A parser option keeps the current installation and portability characteristics for existing users while allowing the experimental parser to be evaluated through explicit opt-in. + +### Why add a parser option when ESLint languages supersede the older parser abstraction? + +The two abstractions operate at different boundaries in this proposal. `markdown/commonmark` and `markdown/gfm` remain the ESLint languages and continue to define the `MarkdownSourceCode` and rule contract. `languageOptions.parser` selects only the implementation that produces the compatible mdast `Root` for one of those languages. A parser that exposes a different AST contract should be implemented as a separate ESLint language instead. + +### Why not add a `useNativeRustParser` language option instead? + +A dedicated boolean would couple `@eslint/markdown` to the experimental `@eslint-markdown/parser` package. A regular dependency would add the Rust parser and its platform-specific artifacts to every user's dependency tree, even when unused. Making it an optional peer would still require conditional loading, and asynchronous dynamic imports do not fit the current synchronous parsing path. Accepting a parser object instead keeps the dependency explicitly opt-in and supports future mdast-compatible parsers through the same API. ### Can a parser return a non-mdast AST? @@ -282,7 +302,7 @@ No. The language parsing API is synchronous, and native Node.js addons can expos ### Are existing rules guaranteed to work with every custom parser? -No. They should work when the parser produces the mdast node shapes and positions described by the compatibility contract. Parser authors and users are responsible for differences in syntax support or edge-case behavior. +No. Matching node types and properties does not guarantee identical parsing semantics. For example, parsers may disagree about whether a list marker without following whitespace starts a list or a paragraph, so a rule may observe a different node even though both parsers produce valid mdast. Existing rules should work when a parser follows the documented mdast shapes, positions, and Markdown semantics, but parser authors must document relevant syntax and edge-case differences. ### Can a rule use `meta.languages` to require a particular parser? From b33133fa01a1dd4bea283afb2886c8259e6414fb Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 23:23:26 +0900 Subject: [PATCH 5/8] wip --- designs/2026-markdown-custom-parsers/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index fa6c2979..28aa211a 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -17,7 +17,7 @@ Markdown parsing can dominate the time spent linting Markdown. In the prototypes Changing the default parser to `satteri` is not currently suitable. Although `satteri` provides a browser-targeted WebAssembly binding, adopting it as the default would add platform-specific native binaries to the default Node.js dependency graph and a WebAssembly artifact and initialization path to the default browser dependency graph. This would increase distribution size and introduce additional runtime and bundler compatibility requirements for every user, including users who do not need the performance improvement. -A parser option solves this by keeping the current portable parser as the default while allowing users to opt into another implementation. It also allows users to provide their own mdast-related parser with the syntax plugins they need. This proposal adds the option specifically to the Markdown language implementation. It does not propose a new parser mechanism for every ESLint language plugins such as JSON and CSS. +A parser option solves this by keeping the current portable parser as the default while allowing users to opt into another implementation. It also allows users to provide their own mdast-related parser with the syntax plugins they need. This proposal adds the option specifically to the Markdown language implementation. It does not propose a new parser mechanism for other ESLint language plugins, such as JSON and CSS. ## Detailed Design @@ -74,6 +74,8 @@ export type MarkdownParser = ObjectMetaProperties & { }; ``` +`ParserMode` was already exported, so it remains as a deprecated alias for backwards compatibility. New code should use `MarkdownParserMode`. + Parser metadata is optional and informational. `@eslint/markdown` does not change parsing behavior based on `meta`. The parser is an object rather than a bare function to follow the shape used by ESLint parser packages and to leave room for metadata without changing the invocation API. Like `fromMarkdown()`, the method receives the Markdown text as its first argument and parser options as its second argument. @@ -169,7 +171,7 @@ export default [ frontmatter: "yaml", math: true, parser, - implementationOption: true, + parserSpecificOption: true, // Parser-specific option }, }, ]; @@ -306,7 +308,7 @@ No. Matching node types and properties does not guarantee identical parsing sema ### Can a rule use `meta.languages` to require a particular parser? -No. `meta.languages` describes compatibility with the selected ESLint language, not with a particular parser implementation. Because a custom parser is required to produce a compatible mdast `Root`, rules that declare support for `markdown/commonmark` or `markdown/gfm` are expected to work with any conforming parser. A parser that exposes a different AST contract should be provided through a separate ESLint language rather than through `languageOptions.parser`. +No. `meta.languages` describes compatibility with the selected ESLint language, not with a particular parser implementation, so it cannot express parser-specific compatibility. Parser and rule authors must document any such requirements or edge-case differences. A parser that exposes a different AST contract should be provided through a separate ESLint language rather than through `languageOptions.parser`. ## Related Discussions From 3e1928be4a3843640658b8f28e6ba63683c5a5c1 Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 23:24:20 +0900 Subject: [PATCH 6/8] wip --- designs/2026-markdown-custom-parsers/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index 28aa211a..81f88aec 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -1,7 +1,7 @@ - Repo: eslint/markdown - Start Date: 2026-08-04 - RFC PR: https://github.com/eslint/rfcs/pull/152 -- Authors: lumirlumir +- Authors: lumir(lumirlumir) # Support `languageOptions.parser` for Markdown From 114eeedb2b24da724127d8eb0f527817c0096c70 Mon Sep 17 00:00:00 2001 From: lumir Date: Fri, 28 Aug 2026 23:28:48 +0900 Subject: [PATCH 7/8] wip --- designs/2026-markdown-custom-parsers/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index 81f88aec..b02ec23c 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -32,11 +32,11 @@ This proposal has the following goals: This proposal does not: -- add a Rust parser to `@eslint/markdown` by default. -- change the default parser. -- define how native or WebAssembly parser packages are built or distributed. -- support asynchronous parsers. -- guarantee that every `@eslint/markdown` rule works with every custom parser. +1. Add a Rust parser to `@eslint/markdown` by default. +2. Change the default parser. +3. Define how native or WebAssembly parser packages are built or distributed. +4. Support asynchronous parsers. +5. Guarantee that every `@eslint/markdown` rule works with every custom parser. ### New Language Options From 10925150c35bf4f008d4909bbedf386b4bcd9466 Mon Sep 17 00:00:00 2001 From: lumir Date: Wed, 7 Oct 2026 02:19:50 +0900 Subject: [PATCH 8/8] wip --- .../2026-markdown-custom-parsers/README.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/designs/2026-markdown-custom-parsers/README.md b/designs/2026-markdown-custom-parsers/README.md index b02ec23c..4fda7b5e 100644 --- a/designs/2026-markdown-custom-parsers/README.md +++ b/designs/2026-markdown-custom-parsers/README.md @@ -11,7 +11,7 @@ This RFC proposes adding `languageOptions.parser` to `@eslint/markdown` so users ## Motivation -Markdown parsing can dominate the time spent linting Markdown. In the prototypes described in [eslint/markdown#703](https://github.com/eslint/markdown/issues/703) and [eslint/markdown#706](https://github.com/eslint/markdown/pull/706), parsing took approximately **75 ms** of a **100 ms** run. Replacing it with `@eslint-markdown/parser`, a Rust-based parser built on [`satteri`](https://github.com/bruits/satteri), reduced parsing time to approximately **2 ms** and the total runtime to approximately **27 ms**, making the overall run approximately **3.5 times faster**. Although these results are preliminary, they demonstrate that selecting a faster parser can materially improve linting performance. +Markdown parsing can dominate the time spent linting Markdown. In the prototypes described in [eslint/markdown#703](https://github.com/eslint/markdown/issues/703) and [eslint/markdown#706](https://github.com/eslint/markdown/pull/706), parsing took approximately **75 ms** of a **100 ms** run. Replacing it with `eslint-markdown-parser`, a Rust-based parser built on [`satteri`](https://github.com/bruits/satteri), reduced parsing time to approximately **2 ms** and the total runtime to approximately **27 ms**, making the overall run approximately **3.5 times faster**. Although these results are preliminary, they demonstrate that selecting a faster parser can materially improve linting performance. `@eslint/markdown` currently uses `mdast-util-from-markdown` from the mdast ecosystem. [`satteri`](https://github.com/bruits/satteri) is a high-performance Markdown and MDX processor that parses Markdown in Rust and exposes the result to JavaScript as mdast. Its [`markdownToMdast()`](https://satteri.bruits.org/docs/entry-points/#trees-without-compiling) API supports CommonMark and GFM as well as frontmatter and math parsing. Initial experiments indicate that it can support most of the cases already implemented by `@eslint/markdown`, although broader compatibility testing is still required. @@ -110,19 +110,19 @@ The parser call remains synchronous. Returning a promise is not supported. ### Rust Parser Integration -The proposed `@eslint-markdown/parser` package will use [`satteri`](https://github.com/bruits/satteri) under the hood, which exposes its Rust implementation to JavaScript through Node-API. The intended data flow is: +[lumir(@lumirlumir)](https://github.com/lumirlumir) will create and maintain community-driven `eslint-markdown-parser` as an independent package. It is not an official ESLint project, and the ESLint team is not responsible for its implementation, distribution, or maintenance. The package will use [`satteri`](https://github.com/bruits/satteri) under the hood, which exposes its Rust implementation to JavaScript through Node-API. The intended data flow is: -1. `@eslint/markdown` passes the Markdown source string and language options to `@eslint-markdown/parser`, which wraps `satteri` behind the `MarkdownParser` contract expected by `@eslint/markdown`. -2. `@eslint-markdown/parser` passes the Markdown source string to `satteri` through its Node-API binding. +1. `@eslint/markdown` passes the Markdown source string and language options to `eslint-markdown-parser`, which wraps `satteri` behind the `MarkdownParser` contract expected by `@eslint/markdown`. +2. `eslint-markdown-parser` passes the Markdown source string to `satteri` through its Node-API binding. 3. `satteri` parses the source and stores the resulting mdast nodes in a Rust-managed memory pool called an arena. 4. The Rust arena is encoded into a compact binary buffer and passed to JavaScript. 5. JavaScript reads the buffer and materializes the mdast nodes without serializing and deserializing the complete tree as JSON. -6. The materialized [mdast `Root`](https://github.com/syntax-tree/mdast#root) is returned to `@eslint-markdown/parser`. -7. `@eslint-markdown/parser` returns the compatible mdast `Root` from its `parse()` method to `@eslint/markdown`. +6. The materialized [mdast `Root`](https://github.com/syntax-tree/mdast#root) is returned to `eslint-markdown-parser`. +7. `eslint-markdown-parser` returns the compatible mdast `Root` from its `parse()` method to `@eslint/markdown`. This does not use `execSync()` or spawn a child process. Node.js loads the compiled `.node` addon as a dynamic library, and JavaScript calls its exported function directly. The call therefore remains synchronous and runs on the Node.js main thread, just like the current call to `fromMarkdown()`. -The `@eslint-markdown/parser` package, including its platform-specific native binaries and any future WebAssembly build, is separate from `@eslint/markdown`. Its implementation and distribution are not required in order to add the general parser option. +The `eslint-markdown-parser` package, including its platform-specific native binaries and any future WebAssembly build, is separate from `@eslint/markdown`. Its implementation and distribution are not required in order to add the general parser option. ### AST Compatibility @@ -158,7 +158,7 @@ A future Rust-based parser package could be selected as follows: ```js import markdown from "@eslint/markdown"; -import parser from "@eslint-markdown/parser"; +import parser from "eslint-markdown-parser"; export default [ { @@ -206,10 +206,10 @@ The implementation in `eslint/markdown` will include: 2. Extending `MarkdownLanguage#validateLanguageOptions()` with the validation described above. 3. Representing the built-in parser in `defaultLanguageOptions.parser`, then selecting and invoking the configured or default parser in `MarkdownLanguage#parse()`. 4. Adding unit tests with a small parser object for invocation, option forwarding, invalid configuration, thrown errors, and both Markdown modes. -5. Adding `@eslint-markdown/parser` as a development dependency and running integration tests against both the default JavaScript parser and the Rust-based parser. These tests will cover CommonMark, GFM, frontmatter, math, source locations, and the bundled rules. +5. Adding `eslint-markdown-parser` as a development dependency and running integration tests against both the default JavaScript parser and the Rust-based parser. These tests will cover CommonMark, GFM, frontmatter, math, source locations, and the bundled rules. 6. Adding type tests for the public parser interfaces and configuration options. -`@eslint-markdown/parser` remains an optional runtime dependency and is developed and released separately. It is included in `@eslint/markdown` only as a development dependency for integration and compatibility testing. +`eslint-markdown-parser` remains an optional runtime dependency and is developed and released separately. It is included in `@eslint/markdown` only as a development dependency for integration and compatibility testing. ## Documentation @@ -222,7 +222,7 @@ The `@eslint/markdown` README will document: - an example wrapping `mdast-util-from-markdown` with custom extensions. - the fact that parser behavior, platform support, and rule compatibility are the parser author's responsibility. -After the parser option is released and an experimental version of `@eslint-markdown/parser` is published, a blog post would be useful to invite users to try the Rust-based parser and provide feedback. The post can explain how to opt in and share the available compatibility and benchmark results, clearly identifying any preliminary findings. +After the parser option is released and an experimental version of `eslint-markdown-parser` is published, a blog post would be useful to invite users to try the Rust-based parser and provide feedback. The post can explain how to opt in and share the available compatibility and benchmark results, clearly identifying any preliminary findings. ## Drawbacks @@ -264,15 +264,15 @@ N/A No. Rust parser performance motivated the proposal, but any synchronous parser that returns compatible mdast can implement the interface. -### Why does `@eslint-markdown/parser` use `satteri` instead of `markdown-rs`? +### Why does `eslint-markdown-parser` use `satteri` instead of `markdown-rs`? The initial plan was to use [`markdown-rs`](https://github.com/wooorm/markdown-rs) and transfer its Rust AST to JavaScript by serializing the complete tree as JSON and then calling `JSON.parse()`. Further experimentation showed that this serialization and deserialization added significant overhead. -[`satteri`](https://github.com/bruits/satteri) instead stores the parsed tree in a Rust arena, transfers it through a compact binary buffer, and materializes the mdast nodes in JavaScript using a binary reader. This avoids the full-tree JSON serialization and deserialization used by the earlier `markdown-rs` integration design and was substantially faster in the prototype. For this reason, the implementation of `@eslint-markdown/parser` was changed to use `satteri`. +[`satteri`](https://github.com/bruits/satteri) instead stores the parsed tree in a Rust arena, transfers it through a compact binary buffer, and materializes the mdast nodes in JavaScript using a binary reader. This avoids the full-tree JSON serialization and deserialization used by the earlier `markdown-rs` integration design and was substantially faster in the prototype. For this reason, the implementation of `eslint-markdown-parser` was changed to use `satteri`. ### How can the synchronous Markdown parsing API call into Rust? -`@eslint-markdown/parser` loads `satteri` through its Node-API binding and invokes it directly on the Node.js main thread. The call remains synchronous from `MarkdownLanguage#parse()` through the parser wrapper and native binding. +`eslint-markdown-parser` loads `satteri` through its Node-API binding and invokes it directly on the Node.js main thread. The call remains synchronous from `MarkdownLanguage#parse()` through the parser wrapper and native binding. ### Does the integration invoke Rust through `execSync()`? @@ -280,7 +280,7 @@ No. It neither spawns a Rust executable nor creates a child process for each par ### Did `markdown-rs` provide a synchronous Node.js API directly? -No. `markdown-rs` is a Rust crate rather than a ready-made Node.js package, so the earlier design required a Node-API binding layer. The current prototype instead uses `satteri`, which already provides Node-API and WebAssembly bindings, behind the ESLint-compatible `@eslint-markdown/parser` wrapper. +No. `markdown-rs` is a Rust crate rather than a ready-made Node.js package, so the earlier design required a Node-API binding layer. The current prototype instead uses `satteri`, which already provides Node-API and WebAssembly bindings, behind the ESLint-compatible `eslint-markdown-parser` wrapper. ### Why add `languageOptions.parser` instead of replacing the default parser? @@ -292,7 +292,7 @@ The two abstractions operate at different boundaries in this proposal. `markdown ### Why not add a `useNativeRustParser` language option instead? -A dedicated boolean would couple `@eslint/markdown` to the experimental `@eslint-markdown/parser` package. A regular dependency would add the Rust parser and its platform-specific artifacts to every user's dependency tree, even when unused. Making it an optional peer would still require conditional loading, and asynchronous dynamic imports do not fit the current synchronous parsing path. Accepting a parser object instead keeps the dependency explicitly opt-in and supports future mdast-compatible parsers through the same API. +A dedicated boolean would couple `@eslint/markdown` to the experimental `eslint-markdown-parser` package. A regular dependency would add the Rust parser and its platform-specific artifacts to every user's dependency tree, even when unused. Making it an optional peer would still require conditional loading, and asynchronous dynamic imports do not fit the current synchronous parsing path. Accepting a parser object instead keeps the dependency explicitly opt-in and supports future mdast-compatible parsers through the same API. ### Can a parser return a non-mdast AST? @@ -314,5 +314,5 @@ No. `meta.languages` describes compatibility with the selected ESLint language, - [eslint/markdown#703: Support `languageOptions.parser` to improve parser performance by integrating a Rust parser](https://github.com/eslint/markdown/issues/703) - [eslint/markdown#706: Experimental implementation of `languageOptions.parser`](https://github.com/eslint/markdown/pull/706) -- [lumirlumir/npm-eslint-markdown#638: Rust-based `@eslint-markdown/parser` prototype](https://github.com/lumirlumir/npm-eslint-markdown/pull/638) +- [lumirlumir/npm-eslint-markdown#638: Rust-based `eslint-markdown-parser` prototype](https://github.com/lumirlumir/npm-eslint-markdown/pull/638) - [Original ESLint Discord discussion](https://discord.com/channels/688543509199716507/688770853588172860/1519556220917252146)