use std::collections::HashSet;
use std::sync::Arc;
use rich::{FenceRenderer, Renderable};
use rich_ext::document::{
base_name, DocumentFormat, DocumentRegistry, ManFormat, NameMatch, RenderOptions, Severity,
CERTAIN, LIKELY,
};
use super::{controls, Cli, Mode};
fn mode_for(id: &str) -> Option<Mode> {
Some(match id {
"markdown" => Mode::Markdown,
"rst" => Mode::Rst,
"org" => Mode::Org,
"asciidoc" => Mode::AsciiDoc,
"man" => Mode::Man,
_ => return None,
})
}
pub(crate) fn format_mode(value: &str) -> Option<Mode> {
let registry = DocumentRegistry::with_builtins();
let format = registry.get(value)?;
mode_for(format.id())
}
pub(crate) const FORMAT_USAGE: &str = "--format requires auto, json, yaml, toml, xml, ini or env, \
or a document format: markdown, rst, org, asciidoc or man";
pub(crate) fn bare_host(resource: &str) -> bool {
super::is_url(resource)
&& resource
.split_once("://")
.is_none_or(|(_, rest)| !rest.split(['?', '#']).next().unwrap_or("").contains('/'))
}
pub(crate) fn by_content(resource: Option<&str>, content: &str, auto: bool) -> Option<Mode> {
let registry = DocumentRegistry::with_builtins();
let path = resource.filter(|resource| *resource != "-");
let detection = match path {
Some(url) if super::is_url(url) => {
if bare_host(url) {
return None;
}
registry.detect(Some(url), content, f32::INFINITY)
}
Some(path) => registry.detect(Some(path), content, if auto { LIKELY } else { CERTAIN }),
None if auto => registry.detect(None, content, LIKELY),
None => None,
}?;
mode_for(&detection.format)
}
pub(crate) fn render(
mode: Mode,
content: &str,
cli: &Cli,
fences: Option<Arc<dyn FenceRenderer>>,
) -> Box<dyn Renderable> {
let id = match mode {
Mode::Org => "org",
Mode::AsciiDoc => "asciidoc",
_ => "man",
};
let mut options = RenderOptions::new().hyperlinks(cli.hyperlinks);
if let Some(lexer) = &cli.source.lexer {
options = options.default_language(lexer.as_str());
}
if let Some(fences) = fences {
options = options.fence_renderer(fences);
}
let rendered = DocumentRegistry::with_builtins()
.render(content, id, &options)
.expect("a built-in format");
let quiet = cli.report_format == super::ReportFormat::Json || super::watch::capturing();
if !quiet {
let name = cli
.resource
.as_deref()
.filter(|resource| *resource != "-")
.unwrap_or("<stdin>");
warn(name, &rendered.diagnostics);
}
rendered.renderable
}
const MAX_WARNINGS: usize = 10;
fn warn(name: &str, diagnostics: &[rich_ext::document::Diagnostic]) {
let mut seen = HashSet::new();
let warnings: Vec<_> = diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == Severity::Warning)
.filter(|diagnostic| seen.insert(diagnostic.message.clone()))
.collect();
for diagnostic in warnings.iter().take(MAX_WARNINGS) {
let location = match diagnostic.line {
Some(line) => format!("{name}:{line}"),
None => name.to_string(),
};
let message = format!("rich: warning: {location}: {}", diagnostic.message);
eprintln!("{}", controls::shown(&message));
}
if warnings.len() > MAX_WARNINGS {
eprintln!(
"rich: warning: {}: {} more warnings",
controls::shown(name),
warnings.len() - MAX_WARNINGS
);
}
}
pub(crate) fn compressed_man_page(resource: &str) -> Option<String> {
let name = base_name(resource);
let stem = [".gz", ".bz2", ".xz", ".zst", ".Z"]
.iter()
.find_map(|suffix| name.strip_suffix(suffix))?;
(ManFormat.match_name(stem) != NameMatch::No).then(|| {
format!(
"{resource} is a compressed man page, which rich does not decode; \
try: zcat {resource} | rich --man -"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_names_map_to_modes() {
assert_eq!(format_mode("org"), Some(Mode::Org));
assert_eq!(format_mode("ADOC"), Some(Mode::AsciiDoc));
assert_eq!(format_mode("roff"), Some(Mode::Man));
assert_eq!(format_mode("md"), Some(Mode::Markdown));
assert_eq!(format_mode("rst"), Some(Mode::Rst));
assert_eq!(format_mode("json"), None);
assert_eq!(format_mode("auto"), None);
}
#[test]
fn content_routes_only_where_the_name_allows() {
let page = ".TH LS 1\n.SH NAME\nls \\- list\n";
assert_eq!(by_content(Some("ls.1"), page, false), Some(Mode::Man));
assert_eq!(by_content(Some("syslog.1"), "boot ok\n", false), None);
assert_eq!(by_content(Some("main.c"), page, true), None);
assert_eq!(by_content(Some("LS"), page, false), Some(Mode::Man));
assert_eq!(by_content(None, page, false), None);
assert_eq!(by_content(Some("-"), page, true), Some(Mode::Man));
assert_eq!(
by_content(Some("https://x.org/ls.1"), page, false),
Some(Mode::Man)
);
assert_eq!(by_content(Some("https://x.org/ls"), page, true), None);
assert_eq!(by_content(Some("https://example.org"), page, true), None);
}
#[test]
fn bare_hosts_have_no_extension() {
assert!(bare_host("https://example.org"));
assert!(bare_host("https://example.org?x=1"));
assert!(!bare_host("https://example.org/notes.org"));
assert!(!bare_host("notes.org"));
}
#[test]
fn compressed_man_pages_get_a_hint() {
let message = compressed_man_page("/usr/share/man/man1/ls.1.gz").unwrap();
assert!(message.contains("zcat /usr/share/man/man1/ls.1.gz | rich --man -"));
assert!(compressed_man_page("ls.1.xz").is_some());
assert_eq!(compressed_man_page("archive.tar.gz"), None);
assert_eq!(compressed_man_page("ls.1"), None);
}
}