Skip to main content

markdown_syntax/
options.rs

1//! Parser configuration: which Markdown constructs are recognized and how.
2//!
3//! [`SyntaxOptions`] is the entry point — pick a preset, optionally tune it with
4//! the [`Construct`] builder, then call [`SyntaxOptions::parse`]. [`Constructs`]
5//! is the exhaustive per-feature flag set behind it, and [`ParseOptions`] holds
6//! the lexing knobs.
7
8use alloc::string::String;
9
10/// The full set of syntactic constructs the parser may recognize, one boolean
11/// per feature. This is the exhaustive escape hatch; most callers use the
12/// [`Constructs::commonmark`]/[`gfm`](Constructs::gfm)/[`mdx`](Constructs::mdx)/
13/// [`max`](Constructs::max) presets or the [`Construct`] builder instead of
14/// setting fields directly.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Constructs {
17    /// Raw HTML blocks, e.g. a `<div>…</div>` block at the top level.
18    pub html_block: bool,
19    /// Raw inline HTML, e.g. `<span>` within a paragraph.
20    pub html_inline: bool,
21    /// Structured Markdown-compatible HTML containers such as `<details>`.
22    pub html_container: bool,
23    /// Indented code blocks (each line indented four spaces or a tab).
24    pub indented_code: bool,
25    /// GFM pipe tables: a `| a | b |` row over a `|---|---|` delimiter row.
26    pub gfm_table: bool,
27    /// GFM task list items: `- [ ]` (unchecked) and `- [x]` (checked).
28    pub gfm_task_list_item: bool,
29    /// GFM strikethrough: `~~text~~`.
30    pub gfm_strikethrough: bool,
31    /// GFM literal autolinks: a bare `https://…`, `www.…`, or email becomes a
32    /// link without angle brackets.
33    pub gfm_autolink_literal: bool,
34    /// cmark-gfm "relaxed" URL autolinks: bare `scheme://` URLs (and a bare
35    /// leading `://`) are auto-linkified without angle brackets, e.g. `smb://`,
36    /// `irc://`, `rdar://`. This is a cmark extension beyond the GFM spec (which
37    /// defines only `http(s)://`/`www.`/email); on by default in `gfm()` for
38    /// GitHub/cmark-gfm parity. The angle form `<scheme:…>` works regardless.
39    pub relaxed_autolinks: bool,
40    /// GFM alerts: a `> [!NOTE]` (TIP/IMPORTANT/WARNING/CAUTION) blockquote.
41    pub gfm_alert: bool,
42    /// Underline spans: `__text__`. This overrides CommonMark's `__`-as-strong,
43    /// so it is off in the [`max`](Constructs::max) default.
44    pub underline: bool,
45    /// CriticMarkup-style insertions: `++text++`.
46    pub insert: bool,
47    /// Highlight / "mark" spans: `==text==`.
48    pub highlight: bool,
49    /// Subscript: a single-tilde span `~text~` (no spaces).
50    pub subscript: bool,
51    /// Superscript: `^text^`.
52    pub superscript: bool,
53    /// Spoiler spans: `||text||`.
54    pub spoiler: bool,
55    /// Emoji-style shortcodes: `:tada:`.
56    pub shortcode: bool,
57    /// Description (definition) lists: a term followed by `:`-led details.
58    pub description_list: bool,
59    /// Footnote definitions: `[^1]: the footnote body`.
60    pub footnote_definition: bool,
61    /// Footnote references: `[^1]` in running text.
62    pub footnote_reference: bool,
63    /// Inline footnotes: `^[the note inline]` (also needs `footnote_reference`).
64    pub inline_footnote: bool,
65    /// Block math: a `$$ … $$` fenced block.
66    pub math_block: bool,
67    /// Inline math: `$x$` (and the math-code form `` $`x`$ ``).
68    pub math_inline: bool,
69    /// A leading frontmatter block at the start of the document: `---` YAML or
70    /// `+++` TOML.
71    pub frontmatter: bool,
72    /// Wikilinks with the display title after the pipe: `[[target|title]]`
73    /// (the Obsidian convention). Mutually exclusive with the before-pipe order.
74    pub wikilink_title_after_pipe: bool,
75    /// Wikilinks with the display title before the pipe: `[[title|target]]`.
76    /// Mutually exclusive with the after-pipe order.
77    pub wikilink_title_before_pipe: bool,
78    /// MDX ESM: `import`/`export` statement lines.
79    pub mdx_esm: bool,
80    /// MDX block-level `{ … }` expressions.
81    pub mdx_expression_block: bool,
82    /// MDX inline `{ … }` expressions within text.
83    pub mdx_expression_inline: bool,
84    /// MDX block-level JSX: `<Component/>` as a block. Conflicts with raw HTML.
85    pub mdx_jsx_block: bool,
86    /// MDX inline JSX: `<Component/>` within text. Conflicts with raw HTML.
87    pub mdx_jsx_inline: bool,
88    /// Inline directive: `:name[label]{key=val}`. A directive, not MDX.
89    pub directive_text: bool,
90    /// Leaf directive: `::name[label]{key=val}` on its own line. A directive,
91    /// not MDX.
92    pub directive_leaf: bool,
93    /// Container directive: a `:::name … :::` fenced block. A directive, not MDX.
94    pub directive_container: bool,
95}
96
97impl Constructs {
98    /// The CommonMark baseline: raw HTML and indented code, no extensions.
99    pub const fn commonmark() -> Self {
100        Self {
101            html_block: true,
102            html_inline: true,
103            html_container: false,
104            indented_code: true,
105            gfm_table: false,
106            gfm_task_list_item: false,
107            gfm_strikethrough: false,
108            gfm_autolink_literal: false,
109            relaxed_autolinks: false,
110            gfm_alert: false,
111            underline: false,
112            insert: false,
113            highlight: false,
114            subscript: false,
115            superscript: false,
116            spoiler: false,
117            shortcode: false,
118            description_list: false,
119            footnote_definition: false,
120            footnote_reference: false,
121            inline_footnote: false,
122            math_block: false,
123            math_inline: false,
124            frontmatter: false,
125            wikilink_title_after_pipe: false,
126            wikilink_title_before_pipe: false,
127            mdx_esm: false,
128            mdx_expression_block: false,
129            mdx_expression_inline: false,
130            mdx_jsx_block: false,
131            mdx_jsx_inline: false,
132            directive_text: false,
133            directive_leaf: false,
134            directive_container: false,
135        }
136    }
137
138    /// GitHub Flavored Markdown: CommonMark plus tables, task lists,
139    /// strikethrough, literal autolinks, and footnotes.
140    pub const fn gfm() -> Self {
141        let mut constructs = Self::commonmark();
142        constructs.gfm_table = true;
143        constructs.gfm_task_list_item = true;
144        constructs.gfm_strikethrough = true;
145        constructs.gfm_autolink_literal = true;
146        constructs.relaxed_autolinks = true;
147        constructs.footnote_definition = true;
148        constructs.footnote_reference = true;
149        constructs
150    }
151
152    /// MDX: CommonMark with raw HTML and indented code off, and MDX ESM,
153    /// expressions, and JSX on.
154    pub const fn mdx() -> Self {
155        let mut constructs = Self::commonmark();
156        constructs.html_block = false;
157        constructs.html_inline = false;
158        constructs.html_container = false;
159        constructs.indented_code = false;
160        constructs.mdx_esm = true;
161        constructs.mdx_expression_block = true;
162        constructs.mdx_expression_inline = true;
163        constructs.mdx_jsx_block = true;
164        constructs.mdx_jsx_inline = true;
165        constructs
166    }
167
168    /// The maximal non-MDX construct set, and the default dialect: every
169    /// construct that does not reinterpret a core CommonMark delimiter. MDX is
170    /// off (it conflicts with raw HTML and reinterprets `{…}`/`<…>`), and
171    /// `underline` is off because it would parse `__bold__` as underline,
172    /// overriding CommonMark strong. The wikilink title order is after-pipe.
173    pub const fn max() -> Self {
174        Self {
175            html_block: true,
176            html_inline: true,
177            html_container: true,
178            indented_code: true,
179            gfm_table: true,
180            gfm_task_list_item: true,
181            gfm_strikethrough: true,
182            gfm_autolink_literal: true,
183            relaxed_autolinks: true,
184            gfm_alert: true,
185            underline: false,
186            insert: true,
187            highlight: true,
188            subscript: true,
189            superscript: true,
190            spoiler: true,
191            shortcode: true,
192            description_list: true,
193            footnote_definition: true,
194            footnote_reference: true,
195            inline_footnote: true,
196            math_block: true,
197            math_inline: true,
198            frontmatter: true,
199            wikilink_title_after_pipe: true,
200            wikilink_title_before_pipe: false,
201            mdx_esm: false,
202            mdx_expression_block: false,
203            mdx_expression_inline: false,
204            mdx_jsx_block: false,
205            mdx_jsx_inline: false,
206            directive_text: true,
207            directive_leaf: true,
208            directive_container: true,
209        }
210    }
211}
212
213impl Default for Constructs {
214    fn default() -> Self {
215        Self::max()
216    }
217}
218
219/// Lexing knobs that tune how existing constructs are read or how source text is
220/// preserved, separate from which constructs are recognized ([`Constructs`]).
221#[derive(Clone, Debug, Default, Eq, PartialEq)]
222pub struct ParseOptions {
223    /// Treat a single `~text~` as strikethrough (in addition to `~~text~~`).
224    /// Inert unless `gfm_strikethrough` is also enabled.
225    pub single_tilde_strikethrough: bool,
226    /// Keep backslash character escapes (e.g. `\*`) as `Escape` nodes instead of
227    /// folding them into text, so the original source can be reproduced.
228    pub preserve_character_escapes: bool,
229    /// Keep character references (e.g. `&amp;`) as `CharacterReference` nodes
230    /// instead of resolving them to their value.
231    pub preserve_character_references: bool,
232}
233
234/// A full syntax configuration: which [`Constructs`] are recognized plus the
235/// [`ParseOptions`] lexing knobs. Build one with a preset
236/// ([`commonmark`](SyntaxOptions::commonmark)/[`gfm`](SyntaxOptions::gfm)/
237/// [`mdx`](SyntaxOptions::mdx)/[`default`](SyntaxOptions::default)), optionally
238/// tune it with [`enable`](SyntaxOptions::enable)/[`disable`](SyntaxOptions::disable),
239/// then call [`parse`](SyntaxOptions::parse).
240#[derive(Clone, Debug, Eq, PartialEq)]
241pub struct SyntaxOptions {
242    /// Which syntactic constructs are recognized.
243    pub constructs: Constructs,
244    /// Lexing / source-preservation knobs.
245    pub parse: ParseOptions,
246}
247
248impl SyntaxOptions {
249    /// The strict CommonMark dialect.
250    pub fn commonmark() -> Self {
251        Self {
252            constructs: Constructs::commonmark(),
253            parse: ParseOptions::default(),
254        }
255    }
256
257    /// GitHub Flavored Markdown (also enables single-tilde strikethrough).
258    pub fn gfm() -> Self {
259        Self {
260            constructs: Constructs::gfm(),
261            parse: ParseOptions {
262                single_tilde_strikethrough: true,
263                preserve_character_escapes: false,
264                preserve_character_references: false,
265            },
266        }
267    }
268
269    /// The MDX dialect (JSX, expressions, ESM; no raw HTML).
270    pub fn mdx() -> Self {
271        Self {
272            constructs: Constructs::mdx(),
273            parse: ParseOptions::default(),
274        }
275    }
276
277    /// Enable a [`Construct`] on top of these options, returning the modified
278    /// options for chaining. Grouped constructs (footnotes, math, directives, …)
279    /// flip every flag in the group so no member is left silently inert.
280    pub fn enable(mut self, construct: Construct) -> Self {
281        construct.apply(&mut self.constructs, true);
282        self
283    }
284
285    /// Disable a [`Construct`], the inverse of [`SyntaxOptions::enable`].
286    pub fn disable(mut self, construct: Construct) -> Self {
287        construct.apply(&mut self.constructs, false);
288        self
289    }
290
291    /// Check for contradictory construct combinations (MDX JSX with raw HTML;
292    /// both wikilink title orders). Returns `Ok(())` for every preset; only a
293    /// hand-built config can trip a [`SyntaxConfigError`].
294    pub fn validate(&self) -> Result<(), SyntaxConfigError> {
295        if (self.constructs.mdx_jsx_block || self.constructs.mdx_jsx_inline)
296            && (self.constructs.html_block
297                || self.constructs.html_inline
298                || self.constructs.html_container)
299        {
300            return Err(SyntaxConfigError::MdxHtmlConflict);
301        }
302        if self.constructs.wikilink_title_after_pipe && self.constructs.wikilink_title_before_pipe {
303            return Err(SyntaxConfigError::WikilinkTitleOrderConflict);
304        }
305
306        Ok(())
307    }
308}
309
310impl Default for SyntaxOptions {
311    fn default() -> Self {
312        Self {
313            constructs: Constructs::max(),
314            parse: ParseOptions::default(),
315        }
316    }
317}
318
319/// Where a wikilink's display title sits relative to the `|` separator. The two
320/// orders are mutually exclusive ([`SyntaxConfigError::WikilinkTitleOrderConflict`]).
321#[derive(Clone, Copy, Debug, Eq, PartialEq)]
322pub enum WikiLinkOrder {
323    /// `[[target|title]]` — the Obsidian convention, and the maximal default.
324    TitleAfterPipe,
325    /// `[[title|target]]`.
326    TitleBeforePipe,
327}
328
329/// A discoverable, typo-proof front door for toggling a syntax feature via
330/// [`SyntaxOptions::enable`] / [`SyntaxOptions::disable`]. Each variant maps to
331/// one conceptual feature; grouped features flip every underlying [`Constructs`]
332/// flag together. The raw [`Constructs`] struct remains the exhaustive escape
333/// hatch for fine-grained control.
334#[derive(Clone, Copy, Debug, Eq, PartialEq)]
335#[non_exhaustive]
336pub enum Construct {
337    /// GFM pipe tables: `| a | b |` over `|---|---|`.
338    Table,
339    /// GFM task list items: `- [ ]` / `- [x]`.
340    TaskList,
341    /// Strikethrough: `~~text~~`.
342    Strikethrough,
343    /// GFM literal autolinks plus the cmark relaxed `scheme://` extension.
344    Autolink,
345    /// GFM alerts: `> [!NOTE]` callouts.
346    Alert,
347    /// Footnote definitions, references, and inline footnotes.
348    Footnotes,
349    /// Inline and block math.
350    Math,
351    /// A leading `---`/`+++` frontmatter block.
352    Frontmatter,
353    /// Underline: `__text__` (overrides CommonMark strong).
354    Underline,
355    /// Insertions: `++text++`.
356    Insert,
357    /// Highlight / mark: `==text==`.
358    Highlight,
359    /// Subscript: `~text~`.
360    Subscript,
361    /// Superscript: `^text^`.
362    Superscript,
363    /// Spoilers: `||text||`.
364    Spoiler,
365    /// Emoji-style shortcodes: `:tada:`.
366    Shortcode,
367    /// Description / definition lists.
368    DescriptionList,
369    /// Wikilinks `[[…]]` with the given title order.
370    Wikilinks(WikiLinkOrder),
371    /// Markdown-compatible raw HTML containers such as `<details>`.
372    HtmlContainers,
373    /// MDX JSX (block and inline). Conflicts with raw HTML; pair with
374    /// `disable`-ing HTML or start from [`SyntaxOptions::mdx`].
375    MdxJsx,
376    /// MDX `{…}` expressions (block and inline).
377    MdxExpressions,
378    /// MDX ESM `import`/`export` lines.
379    MdxEsm,
380    /// The `:name` / `::name` / `:::name` directive family.
381    Directives,
382}
383
384impl Construct {
385    fn apply(self, c: &mut Constructs, on: bool) {
386        match self {
387            Construct::Table => c.gfm_table = on,
388            Construct::TaskList => c.gfm_task_list_item = on,
389            Construct::Strikethrough => c.gfm_strikethrough = on,
390            Construct::Autolink => {
391                c.gfm_autolink_literal = on;
392                c.relaxed_autolinks = on;
393            }
394            Construct::Alert => c.gfm_alert = on,
395            Construct::Footnotes => {
396                c.footnote_definition = on;
397                c.footnote_reference = on;
398                c.inline_footnote = on;
399            }
400            Construct::Math => {
401                c.math_block = on;
402                c.math_inline = on;
403            }
404            Construct::Frontmatter => c.frontmatter = on,
405            Construct::Underline => c.underline = on,
406            Construct::Insert => c.insert = on,
407            Construct::Highlight => c.highlight = on,
408            Construct::Subscript => c.subscript = on,
409            Construct::Superscript => c.superscript = on,
410            Construct::Spoiler => c.spoiler = on,
411            Construct::Shortcode => c.shortcode = on,
412            Construct::DescriptionList => c.description_list = on,
413            Construct::Wikilinks(order) => {
414                c.wikilink_title_after_pipe = on && matches!(order, WikiLinkOrder::TitleAfterPipe);
415                c.wikilink_title_before_pipe =
416                    on && matches!(order, WikiLinkOrder::TitleBeforePipe);
417            }
418            Construct::HtmlContainers => c.html_container = on,
419            Construct::MdxJsx => {
420                c.mdx_jsx_block = on;
421                c.mdx_jsx_inline = on;
422            }
423            Construct::MdxExpressions => {
424                c.mdx_expression_block = on;
425                c.mdx_expression_inline = on;
426            }
427            Construct::MdxEsm => c.mdx_esm = on,
428            Construct::Directives => {
429                c.directive_text = on;
430                c.directive_leaf = on;
431                c.directive_container = on;
432            }
433        }
434    }
435}
436
437/// A contradictory [`SyntaxOptions`] configuration, reported by
438/// [`SyntaxOptions::validate`].
439#[derive(Clone, Debug, Eq, PartialEq)]
440pub enum SyntaxConfigError {
441    /// MDX JSX and raw HTML were both enabled; they both claim `<`.
442    MdxHtmlConflict,
443    /// Both wikilink title orders (before- and after-pipe) were enabled.
444    WikilinkTitleOrderConflict,
445}
446
447impl SyntaxConfigError {
448    /// A human-readable description of the conflict.
449    pub fn message(&self) -> String {
450        match self {
451            Self::MdxHtmlConflict => "MDX JSX and raw HTML syntax cannot both be enabled".into(),
452            Self::WikilinkTitleOrderConflict => {
453                "wikilink title-before-pipe and title-after-pipe cannot both be enabled".into()
454            }
455        }
456    }
457}