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. `&`) 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}