use log::debug;
use crate::{config::Indent, extensions::FindFrom, types::LineType};
#[derive(Debug, Clone, Copy, Eq)]
pub enum PageSnippet<T> {
CommandName(T),
Placeholder(T),
PlaceholderVariants { short: T, long: T },
NormalCode(T),
Description(T),
Text(T),
Title(T),
Indent(usize),
Linebreak,
}
#[cfg_attr(not(test), allow(dead_code))]
impl<T> PageSnippet<T> {
pub fn map<F, U>(self, f: F) -> PageSnippet<U>
where
F: Fn(T) -> U,
{
match self {
PageSnippet::CommandName(s) => PageSnippet::CommandName(f(s)),
PageSnippet::Placeholder(s) => PageSnippet::Placeholder(f(s)),
PageSnippet::PlaceholderVariants { short, long } => PageSnippet::PlaceholderVariants {
short: f(short),
long: f(long),
},
PageSnippet::NormalCode(s) => PageSnippet::NormalCode(f(s)),
PageSnippet::Description(s) => PageSnippet::Description(f(s)),
PageSnippet::Text(s) => PageSnippet::Text(f(s)),
PageSnippet::Title(s) => PageSnippet::Title(f(s)),
PageSnippet::Indent(n) => PageSnippet::Indent(n),
PageSnippet::Linebreak => PageSnippet::Linebreak,
}
}
}
impl<T: PartialEq<U>, U> PartialEq<PageSnippet<U>> for PageSnippet<T> {
fn eq(&self, other: &PageSnippet<U>) -> bool {
match (self, other) {
(PageSnippet::CommandName(s), PageSnippet::CommandName(t))
| (PageSnippet::Placeholder(s), PageSnippet::Placeholder(t))
| (PageSnippet::NormalCode(s), PageSnippet::NormalCode(t))
| (PageSnippet::Description(s), PageSnippet::Description(t))
| (PageSnippet::Text(s), PageSnippet::Text(t))
| (PageSnippet::Title(s), PageSnippet::Title(t)) => s == t,
(
PageSnippet::PlaceholderVariants {
short: left_short,
long: left_long,
},
PageSnippet::PlaceholderVariants {
short: right_short,
long: right_long,
},
) => left_short == right_short && left_long == right_long,
(PageSnippet::Indent(n), PageSnippet::Indent(m)) => n == m,
(PageSnippet::Linebreak, PageSnippet::Linebreak) => true,
_ => false,
}
}
}
impl PageSnippet<&str> {
pub fn is_empty(&self) -> bool {
use PageSnippet::*;
match self {
CommandName(s) | Placeholder(s) | NormalCode(s) | Description(s) | Text(s)
| Title(s) => s.is_empty(),
PageSnippet::PlaceholderVariants { short, long } => short.is_empty() && long.is_empty(),
Indent(n) => *n == 0,
Linebreak => false,
}
}
}
pub fn highlight_lines<L, F, E>(
lines: L,
process_snippet: &mut F,
keep_empty_lines: bool,
show_title: bool,
indent: Indent,
) -> Result<(), E>
where
L: Iterator<Item = LineType>,
F: for<'snip> FnMut(PageSnippet<&'snip str>) -> Result<(), E>,
{
let mut command = String::new();
for line in lines {
match line {
LineType::Empty => {
if keep_empty_lines {
process_snippet(PageSnippet::Linebreak)?;
}
}
LineType::Title(title) => {
if show_title {
process_snippet(PageSnippet::Linebreak)?;
process_snippet(PageSnippet::Indent(indent.base))?;
process_snippet(PageSnippet::Title(&title))?;
process_snippet(PageSnippet::Linebreak)?;
} else {
debug!("Ignoring title");
}
command = title;
debug!("Detected command name: {command}");
}
LineType::Description(text) => {
process_snippet(PageSnippet::Indent(indent.base))?;
process_snippet(PageSnippet::Description(&text))?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::ExampleText(text) => {
process_snippet(PageSnippet::Indent(indent.base))?;
process_snippet(PageSnippet::Text(&text))?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::ExampleCode(text) => {
process_snippet(PageSnippet::Indent(indent.command))?;
highlight_code(&command, &text, process_snippet)?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::Other(text) => debug!("Unknown line type: {text:?}"),
}
}
process_snippet(PageSnippet::Linebreak)?;
Ok(())
}
fn highlight_code<E>(
command: &str,
mut text: &str,
process_snippet: &mut impl FnMut(PageSnippet<&str>) -> Result<(), E>,
) -> Result<(), E> {
let replace_escaped = |s: &str| s.replace(r"\{\{", "{{").replace(r"\}\}", "}}");
loop {
let Some(start_marker) = find_marker(text, "{{", r"\{\{") else {
break;
};
let Some(mut end_marker) = find_marker(&text[start_marker + 2..], "}}", r"\}\}") else {
break;
};
end_marker += start_marker + 2;
while end_marker + 2 < text.len() && text.as_bytes()[end_marker + 2] == b'}' {
end_marker += 1;
}
let placeholder_content = &text[start_marker + 2..end_marker];
if start_marker > 0 {
highlight_code_segment(
command,
&replace_escaped(&text[..start_marker]),
process_snippet,
)?;
}
let placeholder_content = replace_escaped(placeholder_content);
if let Some(s) = placeholder_content.strip_prefix('[')
&& let Some(s) = s.strip_suffix(']')
&& let Some((short, long)) = s.split_once('|')
{
process_snippet(PageSnippet::PlaceholderVariants { short, long })?;
} else {
process_snippet(PageSnippet::Placeholder(&placeholder_content))?;
}
text = &text[end_marker + 2..];
}
if !text.is_empty() {
highlight_code_segment(command, &replace_escaped(text), process_snippet)?;
}
Ok(())
}
fn find_marker(s: &str, marker: &str, forbidden_prefix: &str) -> Option<usize> {
let mut search_start = 0;
loop {
let marker_index = s.find_from(marker, search_start)?;
let overlaps_with_prefix = (forbidden_prefix.len() <= marker_index + 1) && {
let prefix_start = marker_index + 1 - forbidden_prefix.len();
&s.as_bytes()[prefix_start..=marker_index] == forbidden_prefix.as_bytes()
};
if !overlaps_with_prefix {
return Some(marker_index);
}
search_start = marker_index + 1;
}
}
fn highlight_code_segment<'a, E>(
command_name: &'a str,
mut segment: &'a str,
process_snippet: &mut impl FnMut(PageSnippet<&'a str>) -> Result<(), E>,
) -> Result<(), E> {
if !command_name.is_empty() {
let mut search_start = 0;
while let Some(match_start) = segment.find_from(command_name, search_start) {
let match_end = match_start + command_name.len();
if is_freestanding_substring(segment, (match_start, match_end)) {
process_snippet(PageSnippet::NormalCode(&segment[..match_start]))?;
process_snippet(PageSnippet::CommandName(command_name))?;
segment = &segment[match_end..];
search_start = 0;
} else {
search_start = segment[match_start..]
.char_indices()
.nth(1)
.map_or(segment.len(), |(i, _)| match_start + i);
}
}
}
process_snippet(PageSnippet::NormalCode(segment))?;
Ok(())
}
fn is_freestanding_substring(surrounding: &str, substring: (usize, usize)) -> bool {
let (start, end) = substring;
let char_before_is_okay = surrounding[..start]
.chars()
.last()
.is_none_or(char::is_whitespace);
let char_after_is_okay = surrounding[end..]
.chars()
.next()
.is_none_or(char::is_whitespace);
char_before_is_okay && char_after_is_okay
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_freestanding_substring() {
assert!(is_freestanding_substring("I love tldr", (0, 1)));
assert!(is_freestanding_substring("I love tldr", (2, 6)));
assert!(is_freestanding_substring("I love tldr", (7, 11)));
assert!(is_freestanding_substring("tldr", (0, 4)));
assert!(is_freestanding_substring("tldr ", (0, 4)));
assert!(is_freestanding_substring(" tldr", (1, 5)));
assert!(is_freestanding_substring(" tldr ", (1, 5)));
assert!(!is_freestanding_substring("tldr", (1, 3)));
assert!(!is_freestanding_substring("tldr ", (1, 4)));
assert!(!is_freestanding_substring(" tldr", (1, 4)));
assert!(is_freestanding_substring(
" épicé ",
(1, " épicé".len()) ));
assert!(!is_freestanding_substring(
" épicé ",
(1, " épic".len()) ));
}
fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec<PageSnippet<String>> {
let mut yielded = Vec::new();
let mut process_snippet = |snip: PageSnippet<&str>| {
if !snip.is_empty() {
yielded.push(snip.map(str::to_string));
}
Ok::<(), ()>(())
};
highlight_code(cmd, segment, &mut process_snippet).expect("highlight code segment failed");
yielded
}
mod highlight_code_segment {
use super::*;
use PageSnippet::*;
#[test]
fn test_highlight_code_segment() {
assert!(run("make", "").is_empty());
assert_eq!(
&run("make", "make all CC=clang -q"),
&[CommandName("make"), NormalCode(" all CC=clang -q")]
);
assert_eq!(
&run("make", " make money --always-make"),
&[
NormalCode(" "),
CommandName("make"),
NormalCode(" money --always-make")
]
);
assert_eq!(
&run("git commit", "git commit -m 'git commit'"),
&[CommandName("git commit"), NormalCode(" -m 'git commit'"),]
);
}
#[test]
fn test_i18n() {
assert_eq!(
&run("mäke", "mäke höhlenrätselbücher"),
&[CommandName("mäke"), NormalCode(" höhlenrätselbücher")]
);
assert_eq!(
&run(
"Müll",
"1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich"
),
&[
NormalCode("1000 Gründe warum "),
CommandName("Müll"),
NormalCode(" heute größer ist als "),
CommandName("Müll"),
NormalCode(" früher, ärgerlich")
]
);
assert_eq!(
&run(
"übergang",
"die Zustandsübergangsfunktion übergang Änderungen",
),
&[
NormalCode("die Zustandsübergangsfunktion "),
CommandName("übergang"),
NormalCode(" Änderungen")
],
);
}
#[test]
fn test_empty_command() {
let segment = "some code";
let snippets = [NormalCode(segment)];
assert_eq!(run("", segment), snippets);
assert_eq!(run(" ", segment), snippets);
assert_eq!(run(" \t ", segment), snippets);
}
}
mod placeholders {
use super::*;
use PageSnippet::*;
#[test]
fn placeholder_vs_escaped() {
assert_eq!(
run("ping", "ping {{example.com}}"),
[
CommandName("ping"),
NormalCode(" "),
Placeholder("example.com"),
],
);
assert_eq!(
run(
"docker inspect",
r"docker inspect --format '\{\{range.NetworkSettings.Networks\}\}\{\{.IPAddress\}\}\{\{end\}\}' {{container}}"
),
[
CommandName("docker inspect"),
NormalCode(
" --format '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "
),
Placeholder("container"),
],
);
assert_eq!(
run("mount", r"mount \\{{computer_name}}\{{share_name}} Z:"),
[
CommandName("mount"),
NormalCode(r" \\"),
Placeholder("computer_name"),
NormalCode(r"\"),
Placeholder("share_name"),
NormalCode(" Z:"),
],
);
assert_eq!(run("", r"\{"), [NormalCode(r"\{")]);
assert_eq!(run("", r"\{{a"), [NormalCode(r"\{{a")]);
assert_eq!(run("", r"\{{a}}"), [NormalCode(r"\"), Placeholder("a")]);
assert_eq!(run("", r"{{\}\}}"), [NormalCode("{{}}}")]);
}
#[test]
fn outer_precedence() {
assert_eq!(
run("git stash", "git stash show --patch {{stash@{0}}}"),
[
CommandName("git stash"),
NormalCode(" show --patch "),
Placeholder("stash@{0}"),
],
);
assert_eq!(
run("rg", "rg {{}}}"),
[CommandName("rg"), NormalCode(" "), Placeholder("}")]
);
assert_eq!(run("", "{{{}}}"), [Placeholder("{}")]);
assert_eq!(run("", "{{{{}}}"), [Placeholder("{{}")]);
assert_eq!(run("", "{{{}}}}"), [Placeholder("{}}")]);
}
#[test]
fn escaped_inside_placeholder() {
assert_eq!(
run(
"playerctl",
r#"playerctl metadata {{[-f|--format]}} "{{Now playing: \{\{artist\}\} - \{\{album\}\} - \{\{title\}\}}}""#
),
[
CommandName("playerctl"),
NormalCode(" metadata "),
PlaceholderVariants {
short: "-f",
long: "--format"
},
NormalCode(" \""),
Placeholder("Now playing: {{artist}} - {{album}} - {{title}}"),
NormalCode("\""),
],
);
}
#[test]
fn placeholder_inside_escaped() {
assert_eq!(
run("test", r"test \{\{{{var}} normal\}\}"),
[
CommandName("test"),
NormalCode(" {{"),
Placeholder("var"),
NormalCode(" normal}}"),
],
);
}
#[test]
fn prefix_check_character_boundary() {
assert_eq!("Ä".len(), 2);
assert_eq!(run("", r"Äxx{{x}}"), [NormalCode("Äxx"), Placeholder("x")],);
}
}
mod placeholder_variants {
use super::*;
use PageSnippet::*;
#[test]
fn missing_marker() {
assert_eq!(
run("foo", "{{[short|long]}}"),
[PlaceholderVariants {
short: "short",
long: "long"
}]
);
assert_eq!(run("foo", "{{short|long]}}"), [Placeholder("short|long]")]);
assert_eq!(run("foo", "{{[short|long}}"), [Placeholder("[short|long")]);
assert_eq!(run("foo", "{{[shortlong]}}"), [Placeholder("[shortlong]")]);
}
#[test]
fn command_name_interaction() {
for name in ["[", "]", "|"] {
assert_eq!(
run(name, "{{[short|long]}}"),
[PlaceholderVariants {
short: "short",
long: "long"
}]
);
}
}
#[test]
fn empty_variant() {
for name in ["[", "]", "|"] {
assert_eq!(
run(name, "{{[|long]}}"),
[PlaceholderVariants {
short: "",
long: "long"
}]
);
assert_eq!(
run(name, "{{[short|]}}"),
[PlaceholderVariants {
short: "short",
long: ""
}]
);
assert_eq!(run(name, "{{[|]}}"), [] as [PageSnippet::<String>; 0]);
}
}
}
}