Skip to main content

rich_ext/document/
mod.rs

1//! Documentation formats: Markdown, reStructuredText, Org, AsciiDoc and man
2//! pages, behind one [`DocumentFormat`] interface.
3//!
4//! Not in upstream `rich`, which renders Markdown only. Each format is a
5//! *provider*: an id (`"org"`), the file names it claims, a content sniffer
6//! that says how confident it is a text is in that format, and a renderer
7//! from source text to a renderable, with [`Diagnostic`]s for what it could
8//! not show as written. A [`DocumentRegistry`] holds providers, finds one by
9//! id, file name or content ([`DocumentRegistry::detect`]), and renders.
10//! Nothing here depends on an output backend: a renderable prints to a
11//! terminal, an HTML or SVG export, or any other console target alike.
12//!
13//! Markdown and reStructuredText wrap core's `Markdown` and
14//! [`rst::RestructuredText`](crate::rst::RestructuredText). Org, AsciiDoc and
15//! man pages parse into a small shared [model] (headings, paragraphs with
16//! inline spans, lists, code, tables, quotes, admonitions, definition lists,
17//! rules, image placeholders) that [`DocumentView`] draws with core's
18//! renderables and Markdown's theme styles, so the five formats look alike.
19//! The format-specific styles (TODO keywords, admonitions, drawers) are the
20//! theme keys in [`STYLES`].
21//!
22//! ```
23//! use rich::Console;
24//! use rich_ext::document::{DocumentRegistry, RenderOptions};
25//!
26//! let registry = DocumentRegistry::with_builtins();
27//! let detected = registry.detect(Some("notes.org"), "* Heading", 0.9).unwrap();
28//! assert_eq!(detected.format, "org");
29//!
30//! let rendered = registry
31//!     .render("= Title\n\nNOTE: Mind the gap.", "asciidoc", &RenderOptions::new())
32//!     .unwrap();
33//! let console = Console::builder().width(30).color_system(None).build();
34//! let out = console.render_to_string(rendered.renderable.as_ref());
35//! assert!(out.contains("Note") && out.contains("Mind the gap."));
36//! ```
37//!
38//! # Third-party formats
39//!
40//! Register any [`DocumentFormat`] with [`DocumentRegistry::register`]. A
41//! plugin cannot declare a document format through `PluginRegistrar` (that
42//! would need a new registrar method in `rich-plugin-api`); it can register a
43//! `SourceRenderer` with `renderer(name, …)`, and a host wraps that in a
44//! [`SourceRendererFormat`] (adding the extensions it claims) to put it in a
45//! registry. See `docs/PLUGINS.md`.
46
47pub mod asciidoc;
48pub mod man;
49pub mod model;
50pub mod org;
51mod render;
52mod scan;
53
54use std::fmt;
55use std::sync::Arc;
56
57use rich::{Renderable, Text};
58
59pub use model::{Block, Document, Inline};
60pub use render::{DocumentView, RenderOptions};
61
62/// Theme keys this module adds, chained into
63/// [`extended_theme`](crate::theme::extended_theme). The renderer falls back
64/// to these values when a console's theme lacks them, and reads the
65/// `markdown.*` keys for everything Markdown also draws.
66pub const STYLES: &[(&str, &str)] = &[
67    ("document.todo", "bold red"),
68    ("document.done", "bold green"),
69    ("document.priority", "bold yellow"),
70    ("document.tag", "dim"),
71    ("document.byline", "dim"),
72    ("document.caption", "dim italic"),
73    ("document.properties", "dim"),
74    ("document.properties.key", "bold"),
75    ("document.term", "bold"),
76    ("document.underline", "underline"),
77    ("document.callout", "bold reverse"),
78    ("document.checkbox", "bold"),
79    ("document.checkbox.checked", "bold green"),
80    ("document.checkbox.partial", "bold yellow"),
81    ("document.attribution", "italic"),
82    ("document.timestamp", "cyan"),
83    ("document.footnote", "dim"),
84    ("document.mark", "black on yellow"),
85    ("document.banner", "dim"),
86    ("document.aside", "dim"),
87    ("document.admonition", "bold"),
88    ("document.admonition.note", "bold cyan"),
89    ("document.admonition.tip", "bold green"),
90    ("document.admonition.important", "bold magenta"),
91    ("document.admonition.warning", "bold yellow"),
92    ("document.admonition.caution", "bold red"),
93];
94
95/// A content-detection confidence at or above which a format is a likely
96/// match: what `rich --format auto` routes on.
97pub const LIKELY: f32 = 0.6;
98
99/// A confidence at or above which a format is all but certain: what routes
100/// an extensionless file with no flag.
101pub const CERTAIN: f32 = 0.9;
102
103/// How serious a [`Diagnostic`] is.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
105pub enum Severity {
106    /// Something not drawn that a reader would not miss: a formatting
107    /// request, an export-only block.
108    Info,
109    /// Content not shown as written: an `include::` not followed, an
110    /// equation left out, a block that never closed.
111    Warning,
112}
113
114/// Something a parser could not show as written.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Diagnostic {
117    /// The 1-based source line, when known.
118    pub line: Option<usize>,
119    pub severity: Severity,
120    pub message: String,
121}
122
123impl fmt::Display for Diagnostic {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self.line {
126            Some(line) => write!(f, "line {line}: {}", self.message),
127            None => f.write_str(&self.message),
128        }
129    }
130}
131
132/// A parsed document and what the parser could not show.
133#[derive(Clone, Debug, Default, PartialEq)]
134pub struct Parsed {
135    pub document: Document,
136    pub diagnostics: Vec<Diagnostic>,
137}
138
139/// A rendered document: the renderable to print and the diagnostics.
140pub struct Rendered {
141    pub renderable: Box<dyn Renderable + Send + Sync>,
142    pub diagnostics: Vec<Diagnostic>,
143}
144
145impl fmt::Debug for Rendered {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.debug_struct("Rendered")
148            .field("diagnostics", &self.diagnostics)
149            .finish_non_exhaustive()
150    }
151}
152
153/// How a file name relates to a format.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum NameMatch {
156    /// Not this format's name.
157    No,
158    /// Possibly this format, if the content agrees: `ls.1` may be a man
159    /// page or a rotated log, `key.asc` AsciiDoc or a PGP signature.
160    Maybe,
161    /// This format's own extension (`.org`, `.adoc`).
162    Yes,
163}
164
165/// A documentation format: how to recognise it and how to draw it.
166pub trait DocumentFormat: Send + Sync {
167    /// The format's id: lowercase, stable, usable as a CLI value (`"org"`).
168    fn id(&self) -> &str;
169
170    /// The name people call it (`"Org"`).
171    fn name(&self) -> &str;
172
173    /// Other ids accepted for it (`"adoc"` for AsciiDoc).
174    fn aliases(&self) -> Vec<String> {
175        Vec::new()
176    }
177
178    /// The file extensions it claims, lowercase, without the dot.
179    fn extensions(&self) -> Vec<String> {
180        Vec::new()
181    }
182
183    /// How `file_name` (a base name) relates to this format. By default a
184    /// name whose extension is one of [`extensions`](Self::extensions) is
185    /// [`NameMatch::Yes`].
186    fn match_name(&self, file_name: &str) -> NameMatch {
187        match extension(file_name) {
188            Some(ext)
189                if self
190                    .extensions()
191                    .iter()
192                    .any(|own| own.eq_ignore_ascii_case(ext)) =>
193            {
194                NameMatch::Yes
195            }
196            _ => NameMatch::No,
197        }
198    }
199
200    /// How confident it is that `text` is in this format, from 0 to 1. See
201    /// [`LIKELY`] and [`CERTAIN`].
202    fn sniff(&self, _text: &str) -> f32 {
203        0.0
204    }
205
206    /// What the format supports (`"tables"`, `"admonitions"`, …), for
207    /// listings and for callers that want to know before rendering.
208    fn features(&self) -> Vec<String> {
209        Vec::new()
210    }
211
212    /// Parse `source` into the shared [model], for formats built on it.
213    fn parse(&self, _source: &str) -> Option<Parsed> {
214        None
215    }
216
217    /// Render `source`. The default draws [`parse`](Self::parse)'s document
218    /// with [`DocumentView`], or the source as plain text when the format
219    /// does not parse into the model.
220    fn render(&self, source: &str, options: &RenderOptions) -> Rendered {
221        match self.parse(source) {
222            Some(parsed) => {
223                let diagnostics = parsed.diagnostics.clone();
224                Rendered {
225                    renderable: Box::new(
226                        DocumentView::from_parsed(parsed).options(options.clone()),
227                    ),
228                    diagnostics,
229                }
230            }
231            None => Rendered {
232                renderable: Box::new(Text::new(source)),
233                diagnostics: Vec::new(),
234            },
235        }
236    }
237}
238
239/// The extension of a file name: after its last dot, unless that is the
240/// first character (`.bashrc` has none).
241pub fn extension(file_name: &str) -> Option<&str> {
242    let (stem, ext) = file_name.rsplit_once('.')?;
243    (!stem.is_empty() && !ext.is_empty()).then_some(ext)
244}
245
246/// The base name of a path or URL: its last segment, without a query or
247/// fragment.
248pub fn base_name(path: &str) -> &str {
249    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
250    name.split(['?', '#']).next().unwrap_or(name)
251}
252
253/// Why a format was chosen.
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub enum DetectedBy {
256    /// The file name's extension.
257    Name,
258    /// An extension that may mean this format, confirmed by the content.
259    NameAndContent,
260    /// The content alone.
261    Content,
262}
263
264/// A detected format.
265#[derive(Clone, Debug, PartialEq)]
266pub struct Detection {
267    /// The format's id.
268    pub format: String,
269    /// From 0 to 1; 1 for a format's own extension.
270    pub confidence: f32,
271    pub by: DetectedBy,
272}
273
274/// Why a format could not be registered or used.
275#[derive(Clone, Debug, PartialEq, Eq)]
276pub enum DocumentError {
277    /// No registered format has this id or alias.
278    Unknown { name: String, known: Vec<String> },
279    /// The id or alias is taken.
280    Conflict { name: String },
281    /// The id or alias is not a lowercase name (see `rich_plugin_api::is_valid_name`).
282    InvalidName { name: String },
283}
284
285impl fmt::Display for DocumentError {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        match self {
288            DocumentError::Unknown { name, known } => write!(
289                f,
290                "unknown document format {name:?}; known: {}",
291                known.join(", ")
292            ),
293            DocumentError::Conflict { name } => {
294                write!(f, "a document format is already registered as {name:?}")
295            }
296            DocumentError::InvalidName { name } => {
297                write!(f, "{name:?} is not a valid document format name")
298            }
299        }
300    }
301}
302
303impl std::error::Error for DocumentError {}
304
305/// Document formats by id, alias, file name and content.
306#[derive(Clone, Default)]
307pub struct DocumentRegistry {
308    formats: Vec<Arc<dyn DocumentFormat>>,
309}
310
311impl fmt::Debug for DocumentRegistry {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        f.debug_list()
314            .entries(self.formats.iter().map(|format| format.id().to_string()))
315            .finish()
316    }
317}
318
319impl DocumentRegistry {
320    /// An empty registry.
321    pub fn new() -> Self {
322        DocumentRegistry::default()
323    }
324
325    /// The built-in formats, in detection order: Markdown (with the
326    /// `markdown` feature), reStructuredText, Org, AsciiDoc and man pages.
327    pub fn with_builtins() -> Self {
328        let mut registry = DocumentRegistry::new();
329        for format in builtins() {
330            registry
331                .register(format)
332                .expect("the built-in formats have distinct names");
333        }
334        registry
335    }
336
337    /// Add `format`. Its id and aliases must be valid names that no
338    /// registered format uses.
339    pub fn register(&mut self, format: Arc<dyn DocumentFormat>) -> Result<(), DocumentError> {
340        let mut names = vec![format.id().to_string()];
341        names.extend(format.aliases());
342        for name in &names {
343            if !rich_plugin_api::is_valid_name(name) || name.chars().any(|c| c.is_uppercase()) {
344                return Err(DocumentError::InvalidName { name: name.clone() });
345            }
346            if self.get(name).is_some() {
347                return Err(DocumentError::Conflict { name: name.clone() });
348            }
349        }
350        self.formats.push(format);
351        Ok(())
352    }
353
354    /// The registered formats, in registration order.
355    pub fn formats(&self) -> &[Arc<dyn DocumentFormat>] {
356        &self.formats
357    }
358
359    /// The format with this id or alias (case-insensitive).
360    pub fn get(&self, name: &str) -> Option<Arc<dyn DocumentFormat>> {
361        let name = name.to_ascii_lowercase();
362        self.formats
363            .iter()
364            .find(|format| format.id() == name || format.aliases().contains(&name))
365            .cloned()
366    }
367
368    /// Every id, in registration order.
369    pub fn ids(&self) -> Vec<String> {
370        self.formats
371            .iter()
372            .map(|format| format.id().to_string())
373            .collect()
374    }
375
376    /// Each format's confidence that `text` is in it, most confident first
377    /// (ties keep registration order).
378    pub fn sniff(&self, text: &str) -> Vec<(String, f32)> {
379        let mut scores: Vec<(String, f32)> = self
380            .formats
381            .iter()
382            .map(|format| (format.id().to_string(), format.sniff(text).clamp(0.0, 1.0)))
383            .collect();
384        scores.sort_by(|a, b| b.1.total_cmp(&a.1));
385        scores
386    }
387
388    /// The format of a document, from its path (a file path or URL; `None`
389    /// for standard input) and its text:
390    ///
391    /// 1. A format's own extension wins outright (`notes.org`).
392    /// 2. An extension a format may use (`ls.1`, `key.asc`) needs the
393    ///    content to agree at [`LIKELY`] or above.
394    /// 3. Any other extension is never a document here: `main.c` stays
395    ///    source code whatever it contains.
396    /// 4. Without an extension, the content alone decides, when the most
397    ///    confident format reaches `threshold` and leads the next by 0.1.
398    pub fn detect(&self, path: Option<&str>, text: &str, threshold: f32) -> Option<Detection> {
399        if let Some(path) = path {
400            let name = base_name(path);
401            let mut maybe: Vec<&Arc<dyn DocumentFormat>> = Vec::new();
402            for format in &self.formats {
403                match format.match_name(name) {
404                    NameMatch::Yes => {
405                        return Some(Detection {
406                            format: format.id().to_string(),
407                            confidence: 1.0,
408                            by: DetectedBy::Name,
409                        });
410                    }
411                    NameMatch::Maybe => maybe.push(format),
412                    NameMatch::No => {}
413                }
414            }
415            if !maybe.is_empty() {
416                return maybe
417                    .into_iter()
418                    .map(|format| (format, format.sniff(text)))
419                    .filter(|(_, confidence)| *confidence >= LIKELY)
420                    .max_by(|a, b| a.1.total_cmp(&b.1))
421                    .map(|(format, confidence)| Detection {
422                        format: format.id().to_string(),
423                        confidence: confidence.min(1.0),
424                        by: DetectedBy::NameAndContent,
425                    });
426            }
427            if extension(name).is_some() {
428                return None;
429            }
430        }
431        let scores = self.sniff(text);
432        let (best, confidence) = scores.first()?.clone();
433        let runner_up = scores.get(1).map_or(0.0, |(_, score)| *score);
434        (confidence >= threshold && confidence - runner_up >= 0.1).then_some(Detection {
435            format: best,
436            confidence,
437            by: DetectedBy::Content,
438        })
439    }
440
441    /// Render `source` as the format named `format` (an id or alias).
442    pub fn render(
443        &self,
444        source: &str,
445        format: &str,
446        options: &RenderOptions,
447    ) -> Result<Rendered, DocumentError> {
448        let provider = self.get(format).ok_or_else(|| DocumentError::Unknown {
449            name: format.to_string(),
450            known: self.ids(),
451        })?;
452        Ok(provider.render(source, options))
453    }
454}
455
456/// The built-in providers, in detection order.
457pub fn builtins() -> Vec<Arc<dyn DocumentFormat>> {
458    let mut formats: Vec<Arc<dyn DocumentFormat>> = vec![
459        Arc::new(RstFormat),
460        Arc::new(OrgFormat),
461        Arc::new(AsciiDocFormat),
462        Arc::new(ManFormat),
463    ];
464    #[cfg(feature = "markdown")]
465    formats.insert(0, Arc::new(MarkdownFormat));
466    formats
467}
468
469fn strings(values: &[&str]) -> Vec<String> {
470    values.iter().map(|value| value.to_string()).collect()
471}
472
473/// Markdown, drawn by core's `Markdown` (a faithful port of upstream's).
474#[cfg(feature = "markdown")]
475#[derive(Clone, Copy, Debug, Default)]
476pub struct MarkdownFormat;
477
478#[cfg(feature = "markdown")]
479impl DocumentFormat for MarkdownFormat {
480    fn id(&self) -> &str {
481        "markdown"
482    }
483
484    fn name(&self) -> &str {
485        "Markdown"
486    }
487
488    fn aliases(&self) -> Vec<String> {
489        strings(&["md"])
490    }
491
492    fn extensions(&self) -> Vec<String> {
493        strings(&["md", "markdown"])
494    }
495
496    fn sniff(&self, text: &str) -> f32 {
497        sniff_markdown(text)
498    }
499
500    fn features(&self) -> Vec<String> {
501        strings(&[
502            "headings", "emphasis", "lists", "code", "tables", "quotes", "links", "images", "rules",
503        ])
504    }
505
506    fn render(&self, source: &str, options: &RenderOptions) -> Rendered {
507        let mut markdown = rich::markdown::Markdown::new(source).hyperlinks(options.hyperlinks);
508        if let Some(theme) = &options.code_theme {
509            markdown = markdown.code_theme(theme.as_str());
510        }
511        if let Some(justify) = options.justify {
512            markdown = markdown.justify(justify);
513        }
514        for fence in &options.fences {
515            markdown = markdown.fence_renderer(fence.clone());
516        }
517        Rendered {
518            renderable: Box::new(markdown),
519            diagnostics: Vec::new(),
520        }
521    }
522}
523
524/// reStructuredText, drawn as `rich-rst` draws it ([`crate::rst`]).
525#[derive(Clone, Copy, Debug, Default)]
526pub struct RstFormat;
527
528impl DocumentFormat for RstFormat {
529    fn id(&self) -> &str {
530        "rst"
531    }
532
533    fn name(&self) -> &str {
534        "reStructuredText"
535    }
536
537    fn aliases(&self) -> Vec<String> {
538        strings(&["rest", "restructuredtext"])
539    }
540
541    fn extensions(&self) -> Vec<String> {
542        strings(&["rst"])
543    }
544
545    fn sniff(&self, text: &str) -> f32 {
546        sniff_rst(text)
547    }
548
549    fn features(&self) -> Vec<String> {
550        strings(&[
551            "headings",
552            "emphasis",
553            "lists",
554            "code",
555            "tables",
556            "quotes",
557            "links",
558            "images",
559            "admonitions",
560            "definition-lists",
561            "field-lists",
562            "footnotes",
563        ])
564    }
565
566    fn render(&self, source: &str, options: &RenderOptions) -> Rendered {
567        let mut document = crate::rst::RestructuredText::new(source).default_lexer(
568            options
569                .default_language
570                .clone()
571                .unwrap_or_else(|| "python".into()),
572        );
573        if let Some(theme) = &options.code_theme {
574            document = document.code_theme(theme.as_str());
575        }
576        Rendered {
577            renderable: Box::new(document),
578            diagnostics: Vec::new(),
579        }
580    }
581}
582
583/// Org mode ([`org`]).
584#[derive(Clone, Copy, Debug, Default)]
585pub struct OrgFormat;
586
587impl DocumentFormat for OrgFormat {
588    fn id(&self) -> &str {
589        "org"
590    }
591
592    fn name(&self) -> &str {
593        "Org"
594    }
595
596    fn aliases(&self) -> Vec<String> {
597        strings(&["orgmode", "org-mode"])
598    }
599
600    fn extensions(&self) -> Vec<String> {
601        strings(&["org"])
602    }
603
604    fn sniff(&self, text: &str) -> f32 {
605        org::sniff(text)
606    }
607
608    fn features(&self) -> Vec<String> {
609        strings(&[
610            "headings",
611            "todo-keywords",
612            "priorities",
613            "tags",
614            "emphasis",
615            "lists",
616            "checkboxes",
617            "definition-lists",
618            "code",
619            "tables",
620            "quotes",
621            "links",
622            "images",
623            "drawers",
624            "keywords",
625            "footnotes",
626            "timestamps",
627            "admonitions",
628            "rules",
629        ])
630    }
631
632    fn parse(&self, source: &str) -> Option<Parsed> {
633        Some(org::parse(source))
634    }
635}
636
637/// AsciiDoc ([`asciidoc`]).
638#[derive(Clone, Copy, Debug, Default)]
639pub struct AsciiDocFormat;
640
641impl DocumentFormat for AsciiDocFormat {
642    fn id(&self) -> &str {
643        "asciidoc"
644    }
645
646    fn name(&self) -> &str {
647        "AsciiDoc"
648    }
649
650    fn aliases(&self) -> Vec<String> {
651        strings(&["adoc"])
652    }
653
654    fn extensions(&self) -> Vec<String> {
655        strings(&["adoc", "asciidoc"])
656    }
657
658    /// `.adoc` and `.asciidoc` are AsciiDoc; `.asc` and `.ad` are when the
659    /// content says so (`.asc` is also an armoured PGP file).
660    fn match_name(&self, file_name: &str) -> NameMatch {
661        match extension(file_name).map(str::to_ascii_lowercase).as_deref() {
662            Some("adoc" | "asciidoc") => NameMatch::Yes,
663            Some("asc" | "ad") => NameMatch::Maybe,
664            _ => NameMatch::No,
665        }
666    }
667
668    fn sniff(&self, text: &str) -> f32 {
669        asciidoc::sniff(text)
670    }
671
672    fn features(&self) -> Vec<String> {
673        strings(&[
674            "headings",
675            "attributes",
676            "emphasis",
677            "lists",
678            "checklists",
679            "definition-lists",
680            "callouts",
681            "code",
682            "tables",
683            "quotes",
684            "admonitions",
685            "sidebars",
686            "links",
687            "images",
688            "footnotes",
689            "rules",
690        ])
691    }
692
693    fn parse(&self, source: &str) -> Option<Parsed> {
694        Some(asciidoc::parse(source))
695    }
696}
697
698/// Man pages in roff: the man and mdoc macros ([`man`]).
699#[derive(Clone, Copy, Debug, Default)]
700pub struct ManFormat;
701
702impl DocumentFormat for ManFormat {
703    fn id(&self) -> &str {
704        "man"
705    }
706
707    fn name(&self) -> &str {
708        "man page"
709    }
710
711    fn aliases(&self) -> Vec<String> {
712        strings(&["roff", "troff", "groff", "nroff", "mdoc"])
713    }
714
715    fn extensions(&self) -> Vec<String> {
716        strings(&[
717            "man", "roff", "mdoc", "1", "2", "3", "4", "5", "6", "7", "8", "9",
718        ])
719    }
720
721    /// `.man`, `.roff` and `.mdoc` are man pages; a manual section (`.1` to
722    /// `.9`, optionally with a suffix such as `.3pm` or `.1ssl`) is one when
723    /// the content says so (`syslog.1` is a rotated log).
724    fn match_name(&self, file_name: &str) -> NameMatch {
725        let Some(ext) = extension(file_name).map(str::to_ascii_lowercase) else {
726            return NameMatch::No;
727        };
728        if matches!(ext.as_str(), "man" | "roff" | "mdoc") {
729            return NameMatch::Yes;
730        }
731        let mut chars = ext.chars();
732        let section = chars.next().is_some_and(|c| ('1'..='9').contains(&c));
733        let suffix: String = chars.collect();
734        let suffix_ok = suffix.len() <= 8
735            && suffix.chars().next().is_none_or(|c| c.is_ascii_lowercase())
736            && suffix.chars().all(|c| c.is_ascii_alphanumeric());
737        if section && suffix_ok {
738            NameMatch::Maybe
739        } else {
740            NameMatch::No
741        }
742    }
743
744    fn sniff(&self, text: &str) -> f32 {
745        man::sniff(text)
746    }
747
748    fn features(&self) -> Vec<String> {
749        strings(&[
750            "headings",
751            "paragraphs",
752            "fonts",
753            "tagged-paragraphs",
754            "lists",
755            "indentation",
756            "examples",
757            "links",
758            "tables",
759            "mdoc",
760            "conditionals",
761        ])
762    }
763
764    fn parse(&self, source: &str) -> Option<Parsed> {
765        Some(man::parse(source))
766    }
767}
768
769/// A plugin's `SourceRenderer` as a document format: the renderer draws the
770/// source; the id, name, extensions and an optional sniffer come from the
771/// host that wraps it.
772pub struct SourceRendererFormat {
773    id: String,
774    name: String,
775    extensions: Vec<String>,
776    renderer: Arc<dyn rich_plugin_api::SourceRenderer>,
777    sniffer: Option<Sniffer>,
778}
779
780/// A content sniffer: how confident it is a text is in the format.
781type Sniffer = Box<dyn Fn(&str) -> f32 + Send + Sync>;
782
783impl SourceRendererFormat {
784    /// Wrap `renderer` as the format `id`.
785    pub fn new(id: impl Into<String>, renderer: Arc<dyn rich_plugin_api::SourceRenderer>) -> Self {
786        let id = id.into();
787        SourceRendererFormat {
788            name: id.clone(),
789            id,
790            extensions: Vec::new(),
791            renderer,
792            sniffer: None,
793        }
794    }
795
796    /// The name people call the format.
797    #[must_use]
798    pub fn name(mut self, name: impl Into<String>) -> Self {
799        self.name = name.into();
800        self
801    }
802
803    /// The extensions it claims, without the dot.
804    #[must_use]
805    pub fn extensions(mut self, extensions: &[&str]) -> Self {
806        self.extensions = extensions
807            .iter()
808            .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase())
809            .collect();
810        self
811    }
812
813    /// A content sniffer (0 to 1).
814    #[must_use]
815    pub fn sniffer(mut self, sniffer: impl Fn(&str) -> f32 + Send + Sync + 'static) -> Self {
816        self.sniffer = Some(Box::new(sniffer));
817        self
818    }
819}
820
821impl DocumentFormat for SourceRendererFormat {
822    fn id(&self) -> &str {
823        &self.id
824    }
825
826    fn name(&self) -> &str {
827        &self.name
828    }
829
830    fn extensions(&self) -> Vec<String> {
831        self.extensions.clone()
832    }
833
834    fn sniff(&self, text: &str) -> f32 {
835        self.sniffer.as_ref().map_or(0.0, |sniff| sniff(text))
836    }
837
838    fn render(&self, source: &str, _options: &RenderOptions) -> Rendered {
839        match self.renderer.render(source) {
840            Ok(renderable) => Rendered {
841                renderable,
842                diagnostics: Vec::new(),
843            },
844            Err(error) => Rendered {
845                renderable: Box::new(Text::new(source)),
846                diagnostics: vec![Diagnostic {
847                    line: None,
848                    severity: Severity::Warning,
849                    message: format!("the {} renderer failed: {error}; shown as text", self.id),
850                }],
851            },
852        }
853    }
854}
855
856/// Markdown's tells. Capped below [`CERTAIN`]: Markdown-looking text is
857/// everywhere (a `#` comment, a `*` bullet), so content alone only ever
858/// makes it likely.
859fn sniff_markdown(text: &str) -> f32 {
860    let text = scan::sample(text);
861    if text.starts_with("#!") {
862        return 0.0;
863    }
864    let (mut fences, mut headings, mut links, mut emphasis, mut lists) = (0, 0, 0, 0, 0);
865    for line in text.lines().take(4000) {
866        let trimmed = line.trim_start();
867        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
868            fences += 1;
869        }
870        let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
871        if (1..=6).contains(&hashes) && trimmed[hashes..].starts_with(' ') && line.starts_with('#')
872        {
873            headings += 1;
874        }
875        if line.contains("](") && line.contains('[') {
876            links += 1;
877        }
878        if line.contains("**") || line.contains("`") {
879            emphasis += 1;
880        }
881        if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("1. ") {
882            lists += 1;
883        }
884    }
885    let mut score: f32 = 0.0;
886    let mut signals = 0;
887    for (present, weight) in [
888        (fences >= 2, 0.7),
889        (links > 0, 0.6),
890        (headings >= 2, 0.6),
891        (headings == 1, 0.45),
892        (emphasis > 0, 0.35),
893        (lists >= 2, 0.3),
894    ] {
895        if present {
896            score = score.max(weight);
897            signals += 1;
898        }
899    }
900    if signals > 1 {
901        score += 0.05 * (signals - 1) as f32;
902    }
903    score.min(0.8)
904}
905
906/// reStructuredText's tells: directives, title adornments, roles. Capped
907/// below [`CERTAIN`], as Markdown's are.
908fn sniff_rst(text: &str) -> f32 {
909    let text = scan::sample(text);
910    if text.starts_with("#!") {
911        return 0.0;
912    }
913    let lines: Vec<&str> = text.lines().take(4000).collect();
914    let (mut directives, mut adornments, mut roles, mut fields) = (0, 0, 0, 0);
915    for (index, line) in lines.iter().enumerate() {
916        let trimmed = line.trim_start();
917        if let Some(rest) = trimmed.strip_prefix(".. ")
918            && rest.contains("::")
919        {
920            directives += 1;
921        }
922        let first = line.chars().next();
923        let adornment = line.len() >= 3
924            && first.is_some_and(|c| "=-~^\"'`#*+".contains(c))
925            && line.chars().all(|c| Some(c) == first);
926        if adornment && index > 0 {
927            let title = lines[index - 1].trim_end();
928            if !title.is_empty() && title.chars().count() <= line.chars().count() {
929                adornments += 1;
930            }
931        }
932        if line.contains(":ref:`") || line.contains(":doc:`") || line.contains("``") {
933            roles += 1;
934        }
935        if trimmed.starts_with(':') && trimmed[1..].contains(": ") {
936            fields += 1;
937        }
938    }
939    let mut score: f32 = 0.0;
940    let mut signals = 0;
941    for (present, weight) in [
942        (directives > 0, 0.8),
943        (roles > 0, 0.5),
944        (adornments >= 2, 0.5),
945        (fields >= 2, 0.4),
946    ] {
947        if present {
948            score = score.max(weight);
949            signals += 1;
950        }
951    }
952    if signals > 1 {
953        score += 0.05 * (signals - 1) as f32;
954    }
955    score.min(0.85)
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    #[test]
963    fn builtins_are_found_by_id_alias_and_extension() {
964        let registry = DocumentRegistry::with_builtins();
965        assert_eq!(registry.get("adoc").unwrap().id(), "asciidoc");
966        assert_eq!(registry.get("ROFF").unwrap().id(), "man");
967        for (path, format) in [
968            ("notes.org", "org"),
969            ("guide.adoc", "asciidoc"),
970            ("README.rst", "rst"),
971            ("dir/ls.man", "man"),
972            ("https://example.com/a.ORG?raw=1", "org"),
973        ] {
974            let detected = registry.detect(Some(path), "", CERTAIN).unwrap();
975            assert_eq!(
976                (detected.format.as_str(), detected.by),
977                (format, DetectedBy::Name)
978            );
979        }
980    }
981
982    #[test]
983    fn a_section_extension_needs_roff_inside() {
984        let registry = DocumentRegistry::with_builtins();
985        let page = ".TH LS 1\n.SH NAME\nls \\- list\n";
986        let detected = registry.detect(Some("ls.1"), page, CERTAIN).unwrap();
987        assert_eq!(
988            (detected.format.as_str(), detected.by),
989            ("man", DetectedBy::NameAndContent)
990        );
991        assert_eq!(
992            registry.detect(Some("syslog.1"), "Jan 1 boot\n", CERTAIN),
993            None
994        );
995        assert_eq!(registry.detect(Some("report.2024"), page, CERTAIN), None);
996    }
997
998    #[test]
999    fn source_code_is_never_a_document() {
1000        let registry = DocumentRegistry::with_builtins();
1001        let looks_like_man = ".TH X 1\n.SH NAME\nx\n";
1002        assert_eq!(registry.detect(Some("main.c"), looks_like_man, 0.0), None);
1003        assert_eq!(registry.detect(Some("x.txt"), "#+TITLE: x\n", 0.0), None);
1004    }
1005
1006    #[test]
1007    fn content_decides_without_an_extension() {
1008        let registry = DocumentRegistry::with_builtins();
1009        let org = "#+TITLE: Notes\n* TODO Write\n";
1010        let detected = registry.detect(None, org, CERTAIN).unwrap();
1011        assert_eq!(
1012            (detected.format.as_str(), detected.by),
1013            ("org", DetectedBy::Content)
1014        );
1015        // Markdown never reaches CERTAIN on content alone.
1016        assert_eq!(
1017            registry.detect(Some("README"), "# Title\n\nSee [x](y).\n", CERTAIN),
1018            None
1019        );
1020        assert_eq!(
1021            registry
1022                .detect(
1023                    None,
1024                    "# Title\n\nSee [x](y).\n\n```\ncode\n```\n```\n",
1025                    LIKELY
1026                )
1027                .map(|d| d.format),
1028            Some("markdown".to_string())
1029        );
1030        assert_eq!(registry.detect(None, "just some text\n", LIKELY), None);
1031    }
1032
1033    #[test]
1034    fn registering_checks_names() {
1035        let mut registry = DocumentRegistry::with_builtins();
1036        assert_eq!(
1037            registry.register(Arc::new(OrgFormat)),
1038            Err(DocumentError::Conflict { name: "org".into() })
1039        );
1040        struct Bad;
1041        impl DocumentFormat for Bad {
1042            fn id(&self) -> &str {
1043                "Not Valid"
1044            }
1045            fn name(&self) -> &str {
1046                "bad"
1047            }
1048        }
1049        assert!(matches!(
1050            registry.register(Arc::new(Bad)),
1051            Err(DocumentError::InvalidName { .. })
1052        ));
1053    }
1054
1055    #[test]
1056    fn a_source_renderer_becomes_a_format() {
1057        struct Upper;
1058        impl rich_plugin_api::SourceRenderer for Upper {
1059            fn render(
1060                &self,
1061                source: &str,
1062            ) -> Result<Box<dyn Renderable + Send + Sync>, rich_plugin_api::PluginError>
1063            {
1064                Ok(Box::new(Text::new(source.to_uppercase())))
1065            }
1066        }
1067        let mut registry = DocumentRegistry::with_builtins();
1068        registry
1069            .register(Arc::new(
1070                SourceRendererFormat::new("shout", Arc::new(Upper)).extensions(&["shout"]),
1071            ))
1072            .unwrap();
1073        let detected = registry.detect(Some("a.shout"), "", CERTAIN).unwrap();
1074        assert_eq!(detected.format, "shout");
1075        let rendered = registry
1076            .render("hi", "shout", &RenderOptions::new())
1077            .unwrap();
1078        let console = rich::Console::builder()
1079            .width(10)
1080            .color_system(None)
1081            .build();
1082        assert_eq!(console.render_to_string(rendered.renderable.as_ref()), "HI");
1083    }
1084
1085    #[test]
1086    fn unknown_formats_name_the_known_ones() {
1087        let registry = DocumentRegistry::with_builtins();
1088        let error = registry
1089            .render("x", "textile", &RenderOptions::new())
1090            .unwrap_err();
1091        assert!(error.to_string().contains("org"));
1092    }
1093}