Skip to main content

panache_parser/
options.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4/// The flavor of Markdown to parse and format.
5/// Each flavor has a different set of default extensions enabled.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
9pub enum Flavor {
10    /// Standard Pandoc Markdown (default extensions enabled)
11    #[default]
12    Pandoc,
13    /// Quarto (Pandoc + Quarto-specific extensions)
14    Quarto,
15    /// R Markdown (Pandoc + R-specific extensions)
16    #[cfg_attr(feature = "serde", serde(rename = "rmarkdown"))]
17    RMarkdown,
18    /// GitHub Flavored Markdown
19    Gfm,
20    /// CommonMark
21    #[cfg_attr(feature = "serde", serde(alias = "commonmark"))]
22    CommonMark,
23    /// MultiMarkdown
24    #[cfg_attr(feature = "serde", serde(rename = "multimarkdown"))]
25    MultiMarkdown,
26    /// mdsvex (Svelte-flavored Markdown: CommonMark + Svelte template syntax)
27    #[cfg_attr(feature = "serde", serde(rename = "mdsvex"))]
28    Mdsvex,
29    /// MyST (CommonMark + Sphinx/MyST directives, roles, and targets)
30    #[cfg_attr(feature = "serde", serde(rename = "myst"))]
31    Myst,
32}
33
34/// Pandoc/Markdown extensions configuration.
35/// Each field represents a specific Pandoc extension.
36/// Extensions marked with a comment indicate implementation status.
37#[derive(Debug, Clone, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39#[cfg_attr(feature = "serde", serde(default))]
40#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct Extensions {
43    // ===== Block-level extensions =====
44
45    // Headings
46    /// Require blank line before headers (default: enabled)
47    pub blank_before_header: bool,
48    /// Full attribute syntax on headers {#id .class key=value}
49    pub header_attributes: bool,
50    /// Auto-generate identifiers from headings
51    pub auto_identifiers: bool,
52    /// Use GitHub's algorithm for auto-generated heading identifiers
53    pub gfm_auto_identifiers: bool,
54    /// Implicit header references ([Heading] links to header)
55    pub implicit_header_references: bool,
56
57    // Block quotes
58    /// Require blank line before blockquotes (default: enabled)
59    pub blank_before_blockquote: bool,
60
61    // Lists
62    /// Fancy list markers (roman numerals, letters, etc.)
63    pub fancy_lists: bool,
64    /// Start ordered lists at arbitrary numbers
65    pub startnum: bool,
66    /// Example lists with (@) markers
67    pub example_lists: bool,
68    /// GitHub-style task lists - [ ] and - [x]
69    pub task_lists: bool,
70    /// Term/definition syntax
71    pub definition_lists: bool,
72    /// Allow lists without a preceding blank line
73    pub lists_without_preceding_blankline: bool,
74    /// [NON-DEFAULT] Pandoc <= 2.0 list semantics: continuation paragraphs and
75    /// nested lists require four-space (one tab-width) indentation
76    pub four_space_rule: bool,
77
78    // Code blocks
79    /// Fenced code blocks with backticks
80    pub backtick_code_blocks: bool,
81    /// Fenced code blocks with tildes
82    pub fenced_code_blocks: bool,
83    /// Attributes on fenced code blocks {.language #id}
84    pub fenced_code_attributes: bool,
85    /// Executable code syntax (currently fenced chunks like ```{r} / ```{python})
86    pub executable_code: bool,
87    /// R Markdown inline executable code (`...`r ...)
88    pub rmarkdown_inline_code: bool,
89    /// Quarto inline executable code (`...`{r} ...)
90    pub quarto_inline_code: bool,
91    /// Attributes on inline code
92    pub inline_code_attributes: bool,
93
94    // Tables
95    /// Simple table syntax
96    pub simple_tables: bool,
97    /// Multiline cell content in tables
98    pub multiline_tables: bool,
99    /// Grid-style tables
100    pub grid_tables: bool,
101    /// Pipe tables (GitHub/PHP Markdown style)
102    pub pipe_tables: bool,
103    /// Table captions
104    pub table_captions: bool,
105
106    // Divs
107    /// Fenced divs ::: {.class}
108    pub fenced_divs: bool,
109    /// HTML <div> elements
110    pub native_divs: bool,
111
112    // Other block elements
113    /// Line blocks for poetry | prefix
114    pub line_blocks: bool,
115
116    // ===== Inline elements =====
117
118    // Emphasis
119    /// Underscores don't trigger emphasis in snake_case
120    pub intraword_underscores: bool,
121    /// Strikethrough ~~text~~
122    pub strikeout: bool,
123    /// Superscript and subscript ^super^ ~sub~
124    pub superscript: bool,
125    pub subscript: bool,
126
127    // Links
128    /// Inline links [text](url)
129    pub inline_links: bool,
130    /// Reference links [text][ref]
131    pub reference_links: bool,
132    /// Shortcut reference links [ref] without second []
133    pub shortcut_reference_links: bool,
134    /// Attributes on links [text](url){.class}
135    pub link_attributes: bool,
136    /// Automatic links <http://example.com>
137    pub autolinks: bool,
138
139    // Images
140    /// Inline images ![alt](url)
141    pub inline_images: bool,
142    /// Paragraph with just image becomes figure
143    pub implicit_figures: bool,
144
145    // Math
146    /// Dollar-delimited math $x$ and $$equation$$
147    pub tex_math_dollars: bool,
148    /// [NON-DEFAULT] GFM math: inline $`...`$ and fenced ``` math blocks
149    pub tex_math_gfm: bool,
150    /// [NON-DEFAULT] Single backslash math \(...\) and \[...\] (RMarkdown default)
151    pub tex_math_single_backslash: bool,
152    /// [NON-DEFAULT] Double backslash math \\(...\\) and \\[...\\]
153    pub tex_math_double_backslash: bool,
154
155    // Footnotes
156    /// Inline footnotes ^[text]
157    pub inline_footnotes: bool,
158    /// Reference footnotes `[^1]` (requires footnote parsing)
159    pub footnotes: bool,
160
161    // Citations
162    /// Citation syntax [@cite]
163    pub citations: bool,
164
165    // Spans
166    /// Bracketed spans [text]{.class}
167    pub bracketed_spans: bool,
168    /// HTML <span> elements
169    pub native_spans: bool,
170
171    // ===== Metadata =====
172    /// YAML metadata block
173    pub yaml_metadata_block: bool,
174    /// Pandoc title block (Title/Author/Date)
175    pub pandoc_title_block: bool,
176    /// [NON-DEFAULT] MultiMarkdown metadata/title block (Key: Value ...)
177    pub mmd_title_block: bool,
178
179    // ===== Raw content =====
180    /// Raw HTML blocks and inline
181    pub raw_html: bool,
182    /// Markdown inside HTML blocks
183    pub markdown_in_html_blocks: bool,
184    /// LaTeX commands and environments
185    pub raw_tex: bool,
186    /// Generic raw blocks with {=format} syntax
187    pub raw_attribute: bool,
188
189    // ===== Escapes and special characters =====
190    /// Backslash escapes any symbol
191    pub all_symbols_escapable: bool,
192    /// Backslash at line end = hard line break
193    pub escaped_line_breaks: bool,
194
195    // ===== NON-DEFAULT EXTENSIONS =====
196    // These are disabled by default in Pandoc
197    /// [NON-DEFAULT] Bare URLs become links
198    pub autolink_bare_uris: bool,
199    /// [NON-DEFAULT] Newline = <br>
200    pub hard_line_breaks: bool,
201    /// [NON-DEFAULT] Ignore soft breaks between two East Asian wide characters
202    pub east_asian_line_breaks: bool,
203    /// [NON-DEFAULT] MultiMarkdown style heading identifiers [my-id]
204    pub mmd_header_identifiers: bool,
205    /// [NON-DEFAULT] MultiMarkdown key=value attributes on reference defs
206    pub mmd_link_attributes: bool,
207    /// [NON-DEFAULT] GitHub/CommonMark alerts in blockquotes (`> [!NOTE]`)
208    pub alerts: bool,
209    /// [NON-DEFAULT] python-markdown admonitions (`!!! note`) with 4-space
210    /// indented content
211    pub python_markdown_admonitions: bool,
212    /// [NON-DEFAULT] pymdownx details: collapsible admonitions (`???`/`???+`)
213    pub pymdownx_details: bool,
214    /// [NON-DEFAULT] :emoji: syntax
215    pub emoji: bool,
216    /// [NON-DEFAULT] Highlighted ==text==
217    pub mark: bool,
218    /// [NON-DEFAULT] Pandoc wikilinks with title after pipe: `[[url|title]]`
219    pub wikilinks_title_after_pipe: bool,
220    /// [NON-DEFAULT] Pandoc wikilinks with title before pipe: `[[title|url]]`
221    pub wikilinks_title_before_pipe: bool,
222    /// [NON-DEFAULT] Allow whitespace between reference link brackets: `[foo] [bar]`
223    pub spaced_reference_links: bool,
224
225    // ===== Quarto-specific extensions =====
226    /// Quarto callout blocks (.callout-note, etc.)
227    pub quarto_callouts: bool,
228    /// Quarto cross-references @fig-id, @tbl-id
229    pub quarto_crossrefs: bool,
230    /// Quarto shortcodes {{< name args >}}
231    pub quarto_shortcodes: bool,
232    /// Bookdown references \@ref(label) and (\#label)
233    pub bookdown_references: bool,
234    /// Bookdown equation references in LaTeX math blocks (\#eq:label)
235    pub bookdown_equation_references: bool,
236
237    // ===== mdsvex-specific extensions =====
238    /// [NON-DEFAULT] Svelte template syntax: `{#if}`/`{:else}`/`{/each}` block
239    /// logic, `{@html ...}`/`{@const ...}` tags, and `{expr}` interpolation.
240    /// Parsed as opaque, lossless spans (content preserved verbatim).
241    pub svelte_template: bool,
242
243    // ===== MyST-specific extensions =====
244    /// [NON-DEFAULT] MyST directives: ```` ```{name} ```` (and, with
245    /// `myst_colon_fence`, `:::{name}`) blocks carrying `:key: value` options
246    /// and a markdown body.
247    pub myst_directives: bool,
248    /// [NON-DEFAULT] MyST inline roles: `` {name}`content` ``.
249    pub myst_roles: bool,
250    /// [NON-DEFAULT] MyST targets `(label)=` preceding a block.
251    pub myst_targets: bool,
252    /// [NON-DEFAULT] MyST inline substitutions `{{ name }}`.
253    pub myst_substitutions: bool,
254    /// [NON-DEFAULT] MyST line comments beginning with `%`.
255    pub myst_comments: bool,
256    /// [NON-DEFAULT] MyST colon-fence directive form `:::{name}` (off by default
257    /// even in MyST, matching `myst-parser`'s opt-in `colon_fence` extension).
258    pub myst_colon_fence: bool,
259    /// [NON-DEFAULT] MyST block breaks: a line of 3+ `+` markers (`+++`),
260    /// optionally carrying trailing cell metadata. `myst-parser` loads these via
261    /// `myst_block_plugin`, so they are on by default for `Flavor::Myst`.
262    pub myst_block_breaks: bool,
263}
264
265impl Default for Extensions {
266    fn default() -> Self {
267        Self::for_flavor(Flavor::default())
268    }
269}
270
271impl Extensions {
272    fn none_defaults() -> Self {
273        Self {
274            alerts: false,
275            all_symbols_escapable: false,
276            auto_identifiers: false,
277            autolink_bare_uris: false,
278            autolinks: false,
279            backtick_code_blocks: false,
280            blank_before_blockquote: false,
281            blank_before_header: false,
282            bookdown_references: false,
283            bookdown_equation_references: false,
284            bracketed_spans: false,
285            citations: false,
286            definition_lists: false,
287            lists_without_preceding_blankline: false,
288            emoji: false,
289            escaped_line_breaks: false,
290            example_lists: false,
291            executable_code: false,
292            rmarkdown_inline_code: false,
293            quarto_inline_code: false,
294            fancy_lists: false,
295            fenced_code_attributes: false,
296            fenced_code_blocks: false,
297            fenced_divs: false,
298            footnotes: false,
299            four_space_rule: false,
300            gfm_auto_identifiers: false,
301            grid_tables: false,
302            east_asian_line_breaks: false,
303            hard_line_breaks: false,
304            header_attributes: false,
305            implicit_figures: false,
306            implicit_header_references: false,
307            inline_code_attributes: false,
308            inline_footnotes: false,
309            inline_images: false,
310            inline_links: false,
311            intraword_underscores: false,
312            line_blocks: false,
313            link_attributes: false,
314            mark: false,
315            markdown_in_html_blocks: false,
316            mmd_header_identifiers: false,
317            mmd_link_attributes: false,
318            mmd_title_block: false,
319            multiline_tables: false,
320            native_divs: false,
321            native_spans: false,
322            pandoc_title_block: false,
323            pipe_tables: false,
324            python_markdown_admonitions: false,
325            pymdownx_details: false,
326            quarto_callouts: false,
327            quarto_crossrefs: false,
328            quarto_shortcodes: false,
329            raw_attribute: false,
330            raw_html: false,
331            raw_tex: false,
332            reference_links: false,
333            shortcut_reference_links: false,
334            simple_tables: false,
335            startnum: false,
336            strikeout: false,
337            subscript: false,
338            superscript: false,
339            svelte_template: false,
340            myst_directives: false,
341            myst_roles: false,
342            myst_targets: false,
343            myst_substitutions: false,
344            myst_comments: false,
345            myst_colon_fence: false,
346            myst_block_breaks: false,
347            table_captions: false,
348            task_lists: false,
349            tex_math_dollars: false,
350            tex_math_double_backslash: false,
351            tex_math_gfm: false,
352            tex_math_single_backslash: false,
353            wikilinks_title_after_pipe: false,
354            wikilinks_title_before_pipe: false,
355            spaced_reference_links: false,
356            yaml_metadata_block: false,
357        }
358    }
359
360    /// Get the default extension set for a given flavor.
361    pub fn for_flavor(flavor: Flavor) -> Self {
362        match flavor {
363            Flavor::Pandoc => Self::pandoc_defaults(),
364            Flavor::Quarto => Self::quarto_defaults(),
365            Flavor::RMarkdown => Self::rmarkdown_defaults(),
366            Flavor::Gfm => Self::gfm_defaults(),
367            Flavor::CommonMark => Self::commonmark_defaults(),
368            Flavor::MultiMarkdown => Self::multimarkdown_defaults(),
369            Flavor::Mdsvex => Self::mdsvex_defaults(),
370            Flavor::Myst => Self::myst_defaults(),
371        }
372    }
373
374    fn pandoc_defaults() -> Self {
375        Self {
376            // Block-level - enabled by default in Pandoc
377            auto_identifiers: true,
378            blank_before_blockquote: true,
379            blank_before_header: true,
380            gfm_auto_identifiers: false,
381            header_attributes: true,
382            implicit_header_references: true,
383
384            // Lists
385            definition_lists: true,
386            example_lists: true,
387            fancy_lists: true,
388            lists_without_preceding_blankline: false,
389            startnum: true,
390            task_lists: true,
391
392            // Code
393            backtick_code_blocks: true,
394            executable_code: false,
395            rmarkdown_inline_code: false,
396            quarto_inline_code: false,
397            fenced_code_attributes: true,
398            fenced_code_blocks: true,
399            inline_code_attributes: true,
400
401            // Tables
402            grid_tables: true,
403            multiline_tables: true,
404            pipe_tables: true,
405            simple_tables: true,
406            table_captions: true,
407
408            // Divs
409            fenced_divs: true,
410            native_divs: true,
411
412            // Other blocks
413            line_blocks: true,
414
415            // Inline
416            intraword_underscores: true,
417            strikeout: true,
418            subscript: true,
419            superscript: true,
420
421            // Links
422            autolinks: true,
423            inline_links: true,
424            link_attributes: true,
425            reference_links: true,
426            shortcut_reference_links: true,
427
428            // Images
429            implicit_figures: true,
430            inline_images: true,
431
432            // Math
433            tex_math_dollars: true,
434            tex_math_double_backslash: false,
435            tex_math_gfm: false,
436            tex_math_single_backslash: false,
437
438            // Footnotes
439            footnotes: true,
440            inline_footnotes: true,
441
442            // Citations
443            citations: true,
444
445            // Spans
446            bracketed_spans: true,
447            native_spans: true,
448
449            // Metadata
450            mmd_title_block: false,
451            pandoc_title_block: true,
452            yaml_metadata_block: true,
453
454            // Raw
455            markdown_in_html_blocks: false,
456            raw_attribute: true,
457            raw_html: true,
458            raw_tex: true,
459
460            // Escapes
461            all_symbols_escapable: true,
462            escaped_line_breaks: true,
463
464            // Non-default
465            alerts: false,
466            python_markdown_admonitions: false,
467            pymdownx_details: false,
468            autolink_bare_uris: false,
469            east_asian_line_breaks: false,
470            emoji: false,
471            four_space_rule: false,
472            hard_line_breaks: false,
473            mark: false,
474            mmd_header_identifiers: false,
475            mmd_link_attributes: false,
476
477            // Quarto/Bookdown-specific
478            bookdown_references: false,
479            bookdown_equation_references: false,
480            quarto_callouts: false,
481            quarto_crossrefs: false,
482            quarto_shortcodes: false,
483
484            // Wikilinks (opt-in, no flavor default)
485            wikilinks_title_after_pipe: false,
486            wikilinks_title_before_pipe: false,
487
488            // Spaced reference links (opt-in)
489            spaced_reference_links: false,
490
491            // mdsvex (opt-in, mdsvex flavor only)
492            svelte_template: false,
493
494            // MyST (opt-in, myst flavor only)
495            myst_directives: false,
496            myst_roles: false,
497            myst_targets: false,
498            myst_substitutions: false,
499            myst_comments: false,
500            myst_colon_fence: false,
501            myst_block_breaks: false,
502        }
503    }
504
505    fn quarto_defaults() -> Self {
506        let mut ext = Self::pandoc_defaults();
507
508        ext.executable_code = true;
509        ext.rmarkdown_inline_code = true;
510        ext.quarto_inline_code = true;
511        ext.quarto_callouts = true;
512        ext.quarto_crossrefs = true;
513        ext.quarto_shortcodes = true;
514
515        ext
516    }
517
518    fn rmarkdown_defaults() -> Self {
519        let mut ext = Self::pandoc_defaults();
520
521        ext.bookdown_references = true;
522        ext.bookdown_equation_references = true;
523        ext.executable_code = true;
524        ext.rmarkdown_inline_code = true;
525        ext.quarto_inline_code = false;
526        ext.tex_math_dollars = true;
527        ext.tex_math_single_backslash = true;
528
529        ext
530    }
531
532    fn gfm_defaults() -> Self {
533        let mut ext = Self::none_defaults();
534
535        ext.alerts = true;
536        ext.auto_identifiers = true;
537        ext.autolink_bare_uris = true;
538        ext.autolinks = true;
539        ext.backtick_code_blocks = true;
540        ext.emoji = true;
541        ext.fenced_code_blocks = true;
542        ext.footnotes = true;
543        ext.gfm_auto_identifiers = true;
544        ext.inline_images = true;
545        ext.inline_links = true;
546        ext.pipe_tables = true;
547        ext.raw_html = true;
548        ext.reference_links = true;
549        ext.shortcut_reference_links = true;
550        ext.strikeout = true;
551        ext.task_lists = true;
552        ext.tex_math_dollars = true;
553        ext.tex_math_gfm = true;
554        ext.yaml_metadata_block = true;
555
556        ext
557    }
558
559    fn commonmark_defaults() -> Self {
560        let mut ext = Self::none_defaults();
561        // CommonMark's core grammar is what pandoc's commonmark reader treats
562        // as "not extensions" — they're built into the reader. Panache's
563        // parser still gates each construct on its extension flag, so we have
564        // to enable the CommonMark-mandatory ones explicitly here.
565        //
566        // Notably absent: `all_symbols_escapable`. CommonMark only allows
567        // backslash escapes of ASCII punctuation, and panache's
568        // `all_symbols_escapable` flag widens that to any character — so it
569        // must stay off for CommonMark.
570        ext.autolinks = true;
571        ext.backtick_code_blocks = true;
572        ext.escaped_line_breaks = true;
573        ext.fenced_code_blocks = true;
574        ext.inline_images = true;
575        ext.inline_links = true;
576        ext.intraword_underscores = true;
577        ext.raw_html = true;
578        ext.reference_links = true;
579        ext.shortcut_reference_links = true;
580        ext
581    }
582
583    fn multimarkdown_defaults() -> Self {
584        let mut ext = Self::none_defaults();
585
586        ext.all_symbols_escapable = true;
587        ext.auto_identifiers = true;
588        ext.backtick_code_blocks = true;
589        ext.definition_lists = true;
590        ext.footnotes = true;
591        ext.implicit_figures = true;
592        ext.implicit_header_references = true;
593        ext.intraword_underscores = true;
594        ext.mmd_header_identifiers = true;
595        ext.mmd_link_attributes = true;
596        ext.mmd_title_block = true;
597        ext.pipe_tables = true;
598        ext.raw_attribute = true;
599        ext.raw_html = true;
600        ext.reference_links = true;
601        ext.shortcut_reference_links = true;
602        ext.subscript = true;
603        ext.superscript = true;
604        ext.tex_math_dollars = true;
605        ext.tex_math_double_backslash = true;
606
607        ext
608    }
609
610    fn mdsvex_defaults() -> Self {
611        // mdsvex (≤0.12.x) builds on `remark-parse@8`, the pre-micromark parser
612        // whose options default to `gfm: true`. So tables, strikethrough, bare
613        // autolinks, and task lists work out of the box with **no** plugins —
614        // confirmed by the official "Svex up your markdown" getting-started
615        // (which uses a pipe table) and by real plugin-free `svelte.config.js`
616        // setups. `remark-gfm` is only needed on *modern* remark, which mdsvex
617        // does not use. A CommonMark-only default would wrongly reflow those
618        // tables as prose.
619        //
620        // We therefore start from the GFM extension set (still the CommonMark
621        // *dialect* per `Dialect::for_flavor`, and still with every Pandoc
622        // attribute extension off, so `{` stays free for Svelte), then trim the
623        // extras that remark-parse@8's `gfm: true` does NOT include: footnotes
624        // (a separate, default-off option), math (needs remark-math), emoji
625        // (needs remark-gemoji), and GitHub alerts (postdates this remark).
626        let mut ext = Self::gfm_defaults();
627
628        ext.footnotes = false;
629        ext.tex_math_dollars = false;
630        ext.tex_math_gfm = false;
631        ext.emoji = false;
632        ext.alerts = false;
633
634        ext.svelte_template = true;
635
636        ext
637    }
638
639    fn myst_defaults() -> Self {
640        // MyST is a CommonMark superset (markdown-it based), so we start from
641        // the CommonMark extension set and layer MyST's always-on constructs on
642        // top. Per the project decision, only MyST's *core* constructs are on by
643        // default — directives, roles, targets, and comments. Every MyST markup
644        // extension (`dollarmath`, `amsmath`, `colon_fence`, `deflist`,
645        // `fieldlist`, `tasklist`, `substitution`, `attrs_*`, `linkify`,
646        // `strikethrough`, `smartquotes`) is opt-in, matching a bare
647        // `myst-parser` install. Users enable them individually via config.
648        let mut ext = Self::commonmark_defaults();
649
650        ext.myst_directives = true;
651        ext.myst_roles = true;
652        ext.myst_targets = true;
653        ext.myst_comments = true;
654        ext.myst_block_breaks = true;
655
656        // MyST is GFM-flavored, not bare CommonMark: `myst-parser` builds its
657        // markdown-it parser with `.enable("table")` and the footnote plugin on
658        // by default. The plugin is loaded as `footnote_plugin, inline=False`,
659        // so reference footnotes (`[^ref]`) are on but inline footnotes
660        // (`^[...]`) stay off, matching a bare `myst-parser` install. (The
661        // remaining markup extensions below stay opt-in.)
662        ext.pipe_tables = true;
663        ext.footnotes = true;
664        ext.inline_footnotes = false;
665
666        // `myst-parser`'s default parser loads `front_matter_plugin`, which
667        // treats a leading `---`-delimited block as YAML front matter. Without
668        // this, panache mis-parses it as a setext heading plus a thematic
669        // break. The plugin only fires at the document start; panache's
670        // `yaml_metadata_block` covers that leading case.
671        ext.yaml_metadata_block = true;
672
673        // Opt-in MyST extensions (off here, overridable via [extensions]):
674        // colon_fence, substitution, and the markup extensions that map onto
675        // existing flags (tex_math_dollars, definition_lists, task_lists,
676        // strikeout, footnotes, ...).
677        ext.myst_substitutions = false;
678        ext.myst_colon_fence = false;
679
680        ext
681    }
682
683    /// Merge user-specified extension overrides with flavor defaults.
684    ///
685    /// This is used to support partial extension overrides in config files.
686    /// For example, if a user specifies `flavor = "quarto"` and then sets
687    /// `[extensions] quarto-crossrefs = false`, we want all other extensions
688    /// to use Quarto defaults, not Pandoc defaults.
689    ///
690    /// # Arguments
691    /// * `user_overrides` - Map of extension names to their user-specified values
692    /// * `flavor` - The flavor to use for default values
693    ///
694    /// # Returns
695    /// A new Extensions struct with flavor defaults merged with user overrides
696    pub fn merge_with_flavor(user_overrides: HashMap<String, bool>, flavor: Flavor) -> Self {
697        let defaults = Self::for_flavor(flavor);
698        Self::merge_overrides(defaults, user_overrides)
699    }
700
701    /// Apply `user_overrides` on top of an already-resolved `Extensions`.
702    /// Unknown keys are silently ignored (mirrors the panache.toml loader).
703    /// Use this when overriding individual extensions on top of a config that
704    /// has already merged flavor defaults + file-based overrides (e.g. CLI
705    /// `-o extensions.<name>=<bool>`).
706    pub fn apply_overrides(&mut self, user_overrides: HashMap<String, bool>) {
707        *self = Self::merge_overrides(self.clone(), user_overrides);
708    }
709
710    fn merge_overrides(mut base: Extensions, user_overrides: HashMap<String, bool>) -> Self {
711        for (key, value) in user_overrides {
712            base.set_by_name(&key, value);
713        }
714        base
715    }
716}
717
718/// Define the canonical mapping between kebab-case extension names (as users
719/// write them in `[extensions]`) and the corresponding `Extensions` fields.
720/// Drives both the runtime setter and the public name list, so adding an
721/// extension means editing exactly one table.
722macro_rules! known_extensions {
723    ( $( $kebab:literal => $field:ident ),* $(,)? ) => {
724        impl Extensions {
725            /// Canonical kebab-case names accepted in `[extensions]`. Used by
726            /// the config loader's typo check and by the JSON Schema
727            /// generator. Only kebab-case is accepted; the snake_case aliases
728            /// were removed in 3.0.
729            pub const KNOWN_NAMES: &'static [&'static str] = &[ $($kebab),* ];
730
731            /// True if `name` is a known (kebab-case) extension key.
732            pub fn is_known_name(name: &str) -> bool {
733                Self::KNOWN_NAMES.iter().any(|k| *k == name)
734            }
735
736            /// Set the named extension on `self`, returning `true` if `name`
737            /// matched a known field. Only kebab-case is accepted.
738            fn set_by_name(&mut self, name: &str, value: bool) -> bool {
739                match name {
740                    $( $kebab => { self.$field = value; true } )*
741                    _ => false,
742                }
743            }
744        }
745    };
746}
747
748known_extensions! {
749    "blank-before-header" => blank_before_header,
750    "header-attributes" => header_attributes,
751    "auto-identifiers" => auto_identifiers,
752    "gfm-auto-identifiers" => gfm_auto_identifiers,
753    "implicit-header-references" => implicit_header_references,
754    "blank-before-blockquote" => blank_before_blockquote,
755    "fancy-lists" => fancy_lists,
756    "startnum" => startnum,
757    "example-lists" => example_lists,
758    "task-lists" => task_lists,
759    "definition-lists" => definition_lists,
760    "lists-without-preceding-blankline" => lists_without_preceding_blankline,
761    "four-space-rule" => four_space_rule,
762    "backtick-code-blocks" => backtick_code_blocks,
763    "fenced-code-blocks" => fenced_code_blocks,
764    "fenced-code-attributes" => fenced_code_attributes,
765    "executable-code" => executable_code,
766    "rmarkdown-inline-code" => rmarkdown_inline_code,
767    "quarto-inline-code" => quarto_inline_code,
768    "inline-code-attributes" => inline_code_attributes,
769    "simple-tables" => simple_tables,
770    "multiline-tables" => multiline_tables,
771    "grid-tables" => grid_tables,
772    "pipe-tables" => pipe_tables,
773    "table-captions" => table_captions,
774    "fenced-divs" => fenced_divs,
775    "native-divs" => native_divs,
776    "line-blocks" => line_blocks,
777    "intraword-underscores" => intraword_underscores,
778    "strikeout" => strikeout,
779    "superscript" => superscript,
780    "subscript" => subscript,
781    "inline-links" => inline_links,
782    "reference-links" => reference_links,
783    "shortcut-reference-links" => shortcut_reference_links,
784    "link-attributes" => link_attributes,
785    "autolinks" => autolinks,
786    "inline-images" => inline_images,
787    "implicit-figures" => implicit_figures,
788    "tex-math-dollars" => tex_math_dollars,
789    "tex-math-gfm" => tex_math_gfm,
790    "tex-math-single-backslash" => tex_math_single_backslash,
791    "tex-math-double-backslash" => tex_math_double_backslash,
792    "inline-footnotes" => inline_footnotes,
793    "footnotes" => footnotes,
794    "citations" => citations,
795    "bracketed-spans" => bracketed_spans,
796    "native-spans" => native_spans,
797    "yaml-metadata-block" => yaml_metadata_block,
798    "pandoc-title-block" => pandoc_title_block,
799    "mmd-title-block" => mmd_title_block,
800    "raw-html" => raw_html,
801    "markdown-in-html-blocks" => markdown_in_html_blocks,
802    "raw-tex" => raw_tex,
803    "raw-attribute" => raw_attribute,
804    "all-symbols-escapable" => all_symbols_escapable,
805    "escaped-line-breaks" => escaped_line_breaks,
806    "autolink-bare-uris" => autolink_bare_uris,
807    "hard-line-breaks" => hard_line_breaks,
808    "east-asian-line-breaks" => east_asian_line_breaks,
809    "mmd-header-identifiers" => mmd_header_identifiers,
810    "mmd-link-attributes" => mmd_link_attributes,
811    "alerts" => alerts,
812    "python-markdown-admonitions" => python_markdown_admonitions,
813    "pymdownx-details" => pymdownx_details,
814    "emoji" => emoji,
815    "mark" => mark,
816    "quarto-callouts" => quarto_callouts,
817    "quarto-crossrefs" => quarto_crossrefs,
818    "quarto-shortcodes" => quarto_shortcodes,
819    "bookdown-references" => bookdown_references,
820    "bookdown-equation-references" => bookdown_equation_references,
821    "wikilinks-title-after-pipe" => wikilinks_title_after_pipe,
822    "wikilinks-title-before-pipe" => wikilinks_title_before_pipe,
823    "spaced-reference-links" => spaced_reference_links,
824    "svelte-template" => svelte_template,
825    "myst-directives" => myst_directives,
826    "myst-roles" => myst_roles,
827    "myst-targets" => myst_targets,
828    "myst-substitutions" => myst_substitutions,
829    "myst-comments" => myst_comments,
830    "myst-colon-fence" => myst_colon_fence,
831    "myst-block-breaks" => myst_block_breaks,
832}
833
834#[cfg(test)]
835mod tests {
836    use super::{Dialect, Extensions, Flavor};
837    use std::collections::HashMap;
838
839    #[test]
840    fn merge_with_flavor_keeps_known_extension_overrides() {
841        let mut overrides = HashMap::new();
842        overrides.insert("intraword-underscores".to_string(), false);
843        let ext = Extensions::merge_with_flavor(overrides, Flavor::Pandoc);
844        assert!(!ext.intraword_underscores);
845    }
846
847    #[test]
848    fn merge_with_flavor_ignores_unknown_extension_overrides() {
849        let mut overrides = HashMap::new();
850        overrides.insert("smart".to_string(), true);
851        overrides.insert("smart-quotes".to_string(), true);
852        let ext = Extensions::merge_with_flavor(overrides, Flavor::Gfm);
853        assert!(ext.strikeout, "known defaults should remain intact");
854    }
855
856    #[test]
857    fn lists_without_preceding_blankline_defaults_false_for_pandoc_and_gfm() {
858        assert!(!Extensions::for_flavor(Flavor::Pandoc).lists_without_preceding_blankline);
859        assert!(!Extensions::for_flavor(Flavor::Gfm).lists_without_preceding_blankline);
860    }
861
862    #[test]
863    fn merge_with_flavor_accepts_lists_without_preceding_blankline_override() {
864        let mut overrides = HashMap::new();
865        overrides.insert("lists-without-preceding-blankline".to_string(), true);
866        let ext = Extensions::merge_with_flavor(overrides, Flavor::Pandoc);
867        assert!(ext.lists_without_preceding_blankline);
868    }
869
870    #[test]
871    fn four_space_rule_defaults_off_for_every_flavor() {
872        for flavor in [
873            Flavor::Pandoc,
874            Flavor::Quarto,
875            Flavor::RMarkdown,
876            Flavor::Gfm,
877            Flavor::CommonMark,
878            Flavor::MultiMarkdown,
879            Flavor::Mdsvex,
880            Flavor::Myst,
881        ] {
882            assert!(
883                !Extensions::for_flavor(flavor).four_space_rule,
884                "four_space_rule should be off by default for {flavor:?}"
885            );
886        }
887    }
888
889    #[test]
890    fn svelte_template_defaults_off_for_non_mdsvex_flavors() {
891        for flavor in [
892            Flavor::Pandoc,
893            Flavor::Quarto,
894            Flavor::RMarkdown,
895            Flavor::Gfm,
896            Flavor::CommonMark,
897            Flavor::MultiMarkdown,
898            Flavor::Myst,
899        ] {
900            assert!(
901                !Extensions::for_flavor(flavor).svelte_template,
902                "svelte_template should be off by default for {flavor:?}"
903            );
904        }
905    }
906
907    #[test]
908    fn myst_uses_commonmark_dialect() {
909        assert_eq!(Dialect::for_flavor(Flavor::Myst), Dialect::CommonMark);
910    }
911
912    #[test]
913    fn myst_defaults_enable_core_constructs_only() {
914        let ext = Extensions::for_flavor(Flavor::Myst);
915
916        // MyST core constructs are on by default.
917        assert!(ext.myst_directives);
918        assert!(ext.myst_roles);
919        assert!(ext.myst_targets);
920        assert!(ext.myst_comments);
921        assert!(ext.myst_block_breaks);
922
923        // MyST is a GFM-flavored superset, not bare CommonMark: markdown-it's
924        // `table` and footnote rules are on by default in `myst-parser`. The
925        // footnote plugin is loaded with `inline=False`, so reference footnotes
926        // are on but inline footnotes (`^[...]`) stay off.
927        assert!(ext.pipe_tables);
928        assert!(ext.footnotes);
929        assert!(!ext.inline_footnotes);
930
931        // `myst-parser` loads `front_matter_plugin` by default, so a leading
932        // `---` YAML block is metadata, not a setext heading + thematic break.
933        assert!(ext.yaml_metadata_block);
934
935        // MyST markup extensions are opt-in (off by default), matching a bare
936        // `myst-parser` install.
937        assert!(!ext.myst_colon_fence);
938        assert!(!ext.myst_substitutions);
939        assert!(!ext.tex_math_dollars);
940        assert!(!ext.definition_lists);
941        assert!(!ext.task_lists);
942        assert!(!ext.strikeout);
943
944        // CommonMark superset: CommonMark core on, Pandoc `{...}` constructs off
945        // so braces are free for directives/roles.
946        assert!(ext.inline_links);
947        assert!(ext.backtick_code_blocks);
948        assert!(!ext.fenced_divs);
949        assert!(!ext.bracketed_spans);
950        assert!(!ext.header_attributes);
951    }
952
953    #[test]
954    fn myst_core_constructs_off_for_other_flavors() {
955        for flavor in [
956            Flavor::Pandoc,
957            Flavor::Quarto,
958            Flavor::RMarkdown,
959            Flavor::Gfm,
960            Flavor::CommonMark,
961            Flavor::MultiMarkdown,
962            Flavor::Mdsvex,
963        ] {
964            let ext = Extensions::for_flavor(flavor);
965            assert!(
966                !ext.myst_directives
967                    && !ext.myst_roles
968                    && !ext.myst_targets
969                    && !ext.myst_comments
970                    && !ext.myst_block_breaks,
971                "MyST core constructs should be off by default for {flavor:?}"
972            );
973        }
974    }
975
976    #[test]
977    fn mdsvex_defaults_match_remark_parse_8_gfm() {
978        let ext = Extensions::for_flavor(Flavor::Mdsvex);
979
980        // The Svelte template layer is on; raw HTML and frontmatter pass through.
981        assert!(ext.svelte_template);
982        assert!(ext.raw_html);
983        assert!(ext.yaml_metadata_block);
984
985        // remark-parse@8 defaults to `gfm: true`, so these work with no plugins.
986        assert!(ext.pipe_tables);
987        assert!(ext.strikeout);
988        assert!(ext.task_lists);
989        assert!(ext.autolink_bare_uris);
990
991        // Extras that remark-parse@8's gfm does NOT include stay off.
992        assert!(!ext.footnotes);
993        assert!(!ext.tex_math_dollars);
994        assert!(!ext.emoji);
995        assert!(!ext.alerts);
996
997        // CommonMark dialect, so the Pandoc `{...}` attribute constructs are off
998        // — this is what frees `{` for Svelte template syntax.
999        assert_eq!(Dialect::for_flavor(Flavor::Mdsvex), Dialect::CommonMark);
1000        assert!(!ext.header_attributes);
1001        assert!(!ext.bracketed_spans);
1002        assert!(!ext.fenced_divs);
1003        assert!(!ext.raw_attribute);
1004        assert!(!ext.inline_code_attributes);
1005    }
1006
1007    #[test]
1008    fn merge_with_flavor_accepts_four_space_rule_override() {
1009        let mut overrides = HashMap::new();
1010        overrides.insert("four-space-rule".to_string(), true);
1011        let ext = Extensions::merge_with_flavor(overrides, Flavor::Pandoc);
1012        assert!(ext.four_space_rule);
1013    }
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1017#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1018pub enum PandocCompat {
1019    /// Alias for Panache's pinned newest supported Pandoc-compat behavior.
1020    ///
1021    /// This is intentionally NOT "floating upstream latest". It resolves to
1022    /// a concrete version that Panache has verified, and is bumped manually.
1023    #[cfg_attr(feature = "serde", serde(rename = "latest"))]
1024    Latest,
1025    /// Match Pandoc 3.7 behavior for ambiguous syntax edge cases.
1026    #[cfg_attr(
1027        feature = "serde",
1028        serde(rename = "3.7", alias = "3-7", alias = "v3.7", alias = "v3-7")
1029    )]
1030    V3_7,
1031    /// Match Pandoc 3.9 behavior for ambiguous syntax edge cases.
1032    #[default]
1033    #[cfg_attr(
1034        feature = "serde",
1035        serde(rename = "3.9", alias = "3-9", alias = "v3.9", alias = "v3-9")
1036    )]
1037    V3_9,
1038}
1039
1040impl PandocCompat {
1041    /// Pinned target for `latest`.
1042    pub const PINNED_LATEST: Self = Self::V3_9;
1043
1044    pub fn effective(self) -> Self {
1045        match self {
1046            Self::Latest => Self::PINNED_LATEST,
1047            other => other,
1048        }
1049    }
1050}
1051
1052/// Parser dialect — the underlying inline tokenization rule set.
1053///
1054/// Distinct from [`Flavor`]: `Flavor` is the user-facing identity (Pandoc,
1055/// Quarto, GFM, etc.) and selects extension defaults; `Dialect` is the
1056/// structural parser identity. Several flavors share a dialect — Quarto and
1057/// RMarkdown both use `Pandoc`; CommonMark and GFM both use `CommonMark`.
1058///
1059/// Use this for parser branches whose behavior is fundamentally different
1060/// between dialect families (e.g. unmatched backtick run handling). Per-flavor
1061/// feature toggles still belong on [`Extensions`].
1062#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1063#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1064#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
1065#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1066pub enum Dialect {
1067    /// Pandoc-markdown family. Default for Pandoc, Quarto, RMarkdown,
1068    /// MultiMarkdown.
1069    #[default]
1070    Pandoc,
1071    /// CommonMark family. Default for CommonMark and GFM.
1072    CommonMark,
1073}
1074
1075impl Dialect {
1076    /// Default dialect for a given user-facing flavor.
1077    pub fn for_flavor(flavor: Flavor) -> Self {
1078        match flavor {
1079            Flavor::CommonMark | Flavor::Gfm | Flavor::Mdsvex | Flavor::Myst => Dialect::CommonMark,
1080            Flavor::Pandoc | Flavor::Quarto | Flavor::RMarkdown | Flavor::MultiMarkdown => {
1081                Dialect::Pandoc
1082            }
1083        }
1084    }
1085}
1086
1087#[derive(Debug, Clone)]
1088#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1089#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case"))]
1090#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1091pub struct ParserOptions {
1092    pub flavor: Flavor,
1093    pub dialect: Dialect,
1094    pub extensions: Extensions,
1095    /// Compatibility target for ambiguous Pandoc behavior.
1096    pub pandoc_compat: PandocCompat,
1097    /// Additional cross-reference key prefixes (beyond the Quarto built-ins
1098    /// recognized by [`crate::parser::inlines::citations::is_quarto_crossref_key`])
1099    /// that should parse as cross-references rather than citations. Populated
1100    /// from the top-level `crossref-prefixes` config key so documents relying on
1101    /// crossref-injecting extensions (e.g. pseudocode's `@algo-`) don't have
1102    /// those references misclassified as citations.
1103    #[cfg_attr(feature = "serde", serde(default, alias = "crossref_prefixes"))]
1104    pub crossref_prefixes: Vec<String>,
1105    /// Document-level reference link label set, populated by the
1106    /// top-level `parse()` function when running CommonMark dialect and
1107    /// consulted by inline parsing's bracket resolution pass. `None`
1108    /// means "not pre-computed"; the inline pipeline then treats every
1109    /// reference-shaped bracket pair conservatively (current behavior),
1110    /// which is correct for the Pandoc dialect and a graceful
1111    /// degradation for embedded use cases that bypass `parse()`.
1112    ///
1113    /// Skipped by serde so config files don't try to (de)serialize a
1114    /// runtime cache.
1115    #[cfg_attr(feature = "serde", serde(skip))]
1116    pub refdef_labels: Option<Arc<HashSet<String>>>,
1117}
1118
1119impl Default for ParserOptions {
1120    fn default() -> Self {
1121        let flavor = Flavor::default();
1122        Self {
1123            flavor,
1124            dialect: Dialect::for_flavor(flavor),
1125            extensions: Extensions::for_flavor(flavor),
1126            pandoc_compat: PandocCompat::default(),
1127            crossref_prefixes: Vec::new(),
1128            refdef_labels: None,
1129        }
1130    }
1131}
1132
1133impl ParserOptions {
1134    pub fn effective_pandoc_compat(&self) -> PandocCompat {
1135        self.pandoc_compat.effective()
1136    }
1137}
1138
1139#[cfg(feature = "schema")]
1140impl schemars::JsonSchema for Flavor {
1141    fn schema_name() -> std::borrow::Cow<'static, str> {
1142        "Flavor".into()
1143    }
1144
1145    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1146        // Include serde aliases so the schema accepts every spelling the
1147        // parser accepts (e.g. `commonmark` alongside the kebab-case
1148        // `common-mark` canonical form).
1149        schemars::json_schema!({
1150            "type": "string",
1151            "description": "Markdown flavor to parse and format against.",
1152            "enum": [
1153                "pandoc",
1154                "quarto",
1155                "rmarkdown",
1156                "gfm",
1157                "common-mark",
1158                "commonmark",
1159                "multimarkdown",
1160                "mdsvex",
1161                "myst"
1162            ]
1163        })
1164    }
1165}
1166
1167#[cfg(feature = "schema")]
1168impl schemars::JsonSchema for PandocCompat {
1169    fn schema_name() -> std::borrow::Cow<'static, str> {
1170        "PandocCompat".into()
1171    }
1172
1173    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1174        schemars::json_schema!({
1175            "type": "string",
1176            "description": "Compatibility target for ambiguous Pandoc behavior.",
1177            "enum": [
1178                "latest",
1179                "3.7", "3-7", "v3.7", "v3-7",
1180                "3.9", "3-9", "v3.9", "v3-9"
1181            ]
1182        })
1183    }
1184}