1pub 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
62pub 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
95pub const LIKELY: f32 = 0.6;
98
99pub const CERTAIN: f32 = 0.9;
102
103#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
105pub enum Severity {
106 Info,
109 Warning,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Diagnostic {
117 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#[derive(Clone, Debug, Default, PartialEq)]
134pub struct Parsed {
135 pub document: Document,
136 pub diagnostics: Vec<Diagnostic>,
137}
138
139pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum NameMatch {
156 No,
158 Maybe,
161 Yes,
163}
164
165pub trait DocumentFormat: Send + Sync {
167 fn id(&self) -> &str;
169
170 fn name(&self) -> &str;
172
173 fn aliases(&self) -> Vec<String> {
175 Vec::new()
176 }
177
178 fn extensions(&self) -> Vec<String> {
180 Vec::new()
181 }
182
183 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 fn sniff(&self, _text: &str) -> f32 {
203 0.0
204 }
205
206 fn features(&self) -> Vec<String> {
209 Vec::new()
210 }
211
212 fn parse(&self, _source: &str) -> Option<Parsed> {
214 None
215 }
216
217 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
239pub 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
246pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub enum DetectedBy {
256 Name,
258 NameAndContent,
260 Content,
262}
263
264#[derive(Clone, Debug, PartialEq)]
266pub struct Detection {
267 pub format: String,
269 pub confidence: f32,
271 pub by: DetectedBy,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq)]
276pub enum DocumentError {
277 Unknown { name: String, known: Vec<String> },
279 Conflict { name: String },
281 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#[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 pub fn new() -> Self {
322 DocumentRegistry::default()
323 }
324
325 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 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 pub fn formats(&self) -> &[Arc<dyn DocumentFormat>] {
356 &self.formats
357 }
358
359 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 pub fn ids(&self) -> Vec<String> {
370 self.formats
371 .iter()
372 .map(|format| format.id().to_string())
373 .collect()
374 }
375
376 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 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 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
456pub 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#[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#[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#[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#[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 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#[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 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
769pub 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
780type Sniffer = Box<dyn Fn(&str) -> f32 + Send + Sync>;
782
783impl SourceRendererFormat {
784 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 #[must_use]
798 pub fn name(mut self, name: impl Into<String>) -> Self {
799 self.name = name.into();
800 self
801 }
802
803 #[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 #[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
856fn 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
906fn 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 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}