1use std::fmt::Write as _;
16
17use termimad::MadSkin;
18use termimad::crossterm::style::Attribute;
19use termimad::minimad::Alignment;
20
21use crate::render::image::Inline;
22use crate::render::style::{Painter, Palette};
23
24const MARGIN: &str = "\u{258f} ";
26
27#[must_use]
32pub fn render(body: &str, width: usize) -> String {
33 skin().text(body, Some(width.max(20))).to_string()
34}
35
36#[must_use]
45pub fn quoted(body: &str, width: usize, paint: Painter, images: &Inline) -> String {
46 let bar = paint.paint(MARGIN, Palette::untrusted());
47 let inner = width.saturating_sub(MARGIN.chars().count());
48 let mut out = String::with_capacity(body.len() * 2);
49
50 if images.is_empty() {
51 push_rendered(&mut out, body, inner, &bar);
52 return out;
53 }
54
55 let mut run = String::new();
59 for line in body.lines() {
60 if let Some(picture) = image_reference(line).and_then(|(_, url)| images.get(url)) {
61 push_rendered(&mut out, &run, inner, &bar);
62 run.clear();
63 let _ = write!(out, "{bar}{}", picture.escape);
64 let _ = writeln!(
67 out,
68 "{bar}{}",
69 paint.paint(&picture.caption, Palette::label())
70 );
71 } else {
72 run.push_str(line);
73 run.push('\n');
74 }
75 }
76 push_rendered(&mut out, &run, inner, &bar);
77
78 out
79}
80
81fn push_rendered(out: &mut String, body: &str, inner: usize, bar: &str) {
82 if body.trim().is_empty() {
83 return;
84 }
85 for line in render(body, inner).lines() {
86 let _ = writeln!(out, "{bar}{}", line.trim_end());
89 }
90}
91
92#[must_use]
98pub fn image_reference(line: &str) -> Option<(&str, &str)> {
99 let line = line.trim();
100 let rest = line.strip_prefix("?;
102 let url = rest.strip_suffix(')')?;
103 (!url.is_empty() && !url.contains(char::is_whitespace)).then_some((alt, url))
104}
105
106#[must_use]
108pub fn image_references(body: &str) -> Vec<(&str, &str)> {
109 body.lines().filter_map(image_reference).collect()
110}
111
112fn skin() -> MadSkin {
120 let mut skin = MadSkin::default();
121 for header in &mut skin.headers {
124 header.align = Alignment::Left;
125 }
126 skin.headers[0].add_attr(Attribute::Bold);
127 skin
128}
129
130#[cfg(test)]
131#[allow(clippy::expect_used)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn markup_becomes_style_not_syntax() {
138 let out = render("**loud**", 40);
139 assert!(out.contains("loud"));
140 assert!(!out.contains("**"));
141 }
142
143 #[test]
144 fn every_line_of_a_quoted_block_carries_the_margin() {
145 let out = quoted(
146 "# Title\n\nbody text\n",
147 40,
148 Painter::plain(),
149 &Inline::default(),
150 );
151 assert!(out.lines().count() >= 2);
152 assert!(out.lines().all(|line| line.starts_with(MARGIN)));
153 }
154
155 #[test]
158 fn a_blank_line_inside_the_block_is_still_marked() {
159 let out = quoted("one\n\ntwo", 40, Painter::plain(), &Inline::default());
160 assert!(out.lines().any(|line| line.trim_end() == MARGIN.trim_end()));
161 }
162
163 fn picture() -> Inline {
164 let mut inline = Inline::default();
165 inline.insert(
166 "/ajax/v2/attachments/29?inline=true".to_owned(),
167 crate::render::image::Picture {
168 escape: "<PICTURE>\n".to_owned(),
169 caption: "screenshot.png".to_owned(),
170 },
171 );
172 inline
173 }
174
175 #[test]
178 fn a_picture_replaces_the_reference_that_names_it() {
179 let body = "before\n\n\n\nafter";
180 let out = quoted(body, 60, Painter::plain(), &picture());
181
182 let lines: Vec<&str> = out.lines().collect();
183 let at = lines
184 .iter()
185 .position(|line| line.contains("<PICTURE>"))
186 .expect("the picture was not drawn");
187
188 assert!(lines[..at].iter().any(|line| line.contains("before")));
189 assert!(lines[at..].iter().any(|line| line.contains("after")));
190 assert!(lines[at + 1].contains("screenshot.png"));
192 assert!(lines[at + 1].starts_with(MARGIN));
193 assert!(!out.contains("!["));
195 }
196
197 #[test]
200 fn an_unmatched_reference_is_left_alone() {
201 let body = "";
202 let out = quoted(body, 60, Painter::plain(), &picture());
203 assert!(out.contains("shot"));
204 assert!(!out.contains("<PICTURE>"));
205 }
206
207 #[test]
208 fn only_a_whole_line_reference_is_a_picture() {
209 assert_eq!(
210 image_reference(""),
211 Some(("alt", "/a/b.png"))
212 );
213 assert_eq!(image_reference("  "), Some(("", "/a/b.png")));
214 assert_eq!(image_reference("see  here"), None);
216 assert_eq!(image_reference("[link](/a/b)"), None);
217 assert_eq!(image_reference("![alt]()"), None);
218 }
219
220 #[test]
221 fn lines_do_not_carry_trailing_padding() {
222 let out = quoted("short", 60, Painter::plain(), &Inline::default());
223 assert!(out.lines().all(|line| !line.ends_with(' ')));
224 }
225}