render.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. use std::fmt::Write as FmtWrite;
  2. use std::io::{self, Write};
  3. use std::thread;
  4. use std::time::Duration;
  5. use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
  6. use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
  7. use crossterm::terminal::{Clear, ClearType};
  8. use crossterm::{execute, queue};
  9. use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
  10. use syntect::easy::HighlightLines;
  11. use syntect::highlighting::{Theme, ThemeSet};
  12. use syntect::parsing::SyntaxSet;
  13. use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
  14. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  15. pub struct ColorTheme {
  16. heading: Color,
  17. emphasis: Color,
  18. strong: Color,
  19. inline_code: Color,
  20. link: Color,
  21. quote: Color,
  22. spinner_active: Color,
  23. spinner_done: Color,
  24. spinner_failed: Color,
  25. }
  26. impl Default for ColorTheme {
  27. fn default() -> Self {
  28. Self {
  29. heading: Color::Cyan,
  30. emphasis: Color::Magenta,
  31. strong: Color::Yellow,
  32. inline_code: Color::Green,
  33. link: Color::Blue,
  34. quote: Color::DarkGrey,
  35. spinner_active: Color::Blue,
  36. spinner_done: Color::Green,
  37. spinner_failed: Color::Red,
  38. }
  39. }
  40. }
  41. #[derive(Debug, Default, Clone, PartialEq, Eq)]
  42. pub struct Spinner {
  43. frame_index: usize,
  44. }
  45. impl Spinner {
  46. const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  47. #[must_use]
  48. pub fn new() -> Self {
  49. Self::default()
  50. }
  51. pub fn tick(
  52. &mut self,
  53. label: &str,
  54. theme: &ColorTheme,
  55. out: &mut impl Write,
  56. ) -> io::Result<()> {
  57. let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()];
  58. self.frame_index += 1;
  59. queue!(
  60. out,
  61. SavePosition,
  62. MoveToColumn(0),
  63. Clear(ClearType::CurrentLine),
  64. SetForegroundColor(theme.spinner_active),
  65. Print(format!("{frame} {label}")),
  66. ResetColor,
  67. RestorePosition
  68. )?;
  69. out.flush()
  70. }
  71. pub fn finish(
  72. &mut self,
  73. label: &str,
  74. theme: &ColorTheme,
  75. out: &mut impl Write,
  76. ) -> io::Result<()> {
  77. self.frame_index = 0;
  78. execute!(
  79. out,
  80. MoveToColumn(0),
  81. Clear(ClearType::CurrentLine),
  82. SetForegroundColor(theme.spinner_done),
  83. Print(format!("✔ {label}\n")),
  84. ResetColor
  85. )?;
  86. out.flush()
  87. }
  88. pub fn fail(
  89. &mut self,
  90. label: &str,
  91. theme: &ColorTheme,
  92. out: &mut impl Write,
  93. ) -> io::Result<()> {
  94. self.frame_index = 0;
  95. execute!(
  96. out,
  97. MoveToColumn(0),
  98. Clear(ClearType::CurrentLine),
  99. SetForegroundColor(theme.spinner_failed),
  100. Print(format!("✘ {label}\n")),
  101. ResetColor
  102. )?;
  103. out.flush()
  104. }
  105. }
  106. #[derive(Debug, Default, Clone, PartialEq, Eq)]
  107. struct RenderState {
  108. emphasis: usize,
  109. strong: usize,
  110. quote: usize,
  111. list: usize,
  112. }
  113. impl RenderState {
  114. fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
  115. if self.strong > 0 {
  116. format!("{}", text.bold().with(theme.strong))
  117. } else if self.emphasis > 0 {
  118. format!("{}", text.italic().with(theme.emphasis))
  119. } else if self.quote > 0 {
  120. format!("{}", text.with(theme.quote))
  121. } else {
  122. text.to_string()
  123. }
  124. }
  125. }
  126. #[derive(Debug)]
  127. pub struct TerminalRenderer {
  128. syntax_set: SyntaxSet,
  129. syntax_theme: Theme,
  130. color_theme: ColorTheme,
  131. }
  132. impl Default for TerminalRenderer {
  133. fn default() -> Self {
  134. let syntax_set = SyntaxSet::load_defaults_newlines();
  135. let syntax_theme = ThemeSet::load_defaults()
  136. .themes
  137. .remove("base16-ocean.dark")
  138. .unwrap_or_default();
  139. Self {
  140. syntax_set,
  141. syntax_theme,
  142. color_theme: ColorTheme::default(),
  143. }
  144. }
  145. }
  146. impl TerminalRenderer {
  147. #[must_use]
  148. pub fn new() -> Self {
  149. Self::default()
  150. }
  151. #[must_use]
  152. pub fn color_theme(&self) -> &ColorTheme {
  153. &self.color_theme
  154. }
  155. #[must_use]
  156. pub fn render_markdown(&self, markdown: &str) -> String {
  157. let mut output = String::new();
  158. let mut state = RenderState::default();
  159. let mut code_language = String::new();
  160. let mut code_buffer = String::new();
  161. let mut in_code_block = false;
  162. for event in Parser::new_ext(markdown, Options::all()) {
  163. self.render_event(
  164. event,
  165. &mut state,
  166. &mut output,
  167. &mut code_buffer,
  168. &mut code_language,
  169. &mut in_code_block,
  170. );
  171. }
  172. output.trim_end().to_string()
  173. }
  174. fn render_event(
  175. &self,
  176. event: Event<'_>,
  177. state: &mut RenderState,
  178. output: &mut String,
  179. code_buffer: &mut String,
  180. code_language: &mut String,
  181. in_code_block: &mut bool,
  182. ) {
  183. match event {
  184. Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
  185. Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
  186. Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
  187. Event::End(TagEnd::BlockQuote(..) | TagEnd::Item)
  188. | Event::SoftBreak
  189. | Event::HardBreak => output.push('\n'),
  190. Event::Start(Tag::List(_)) => state.list += 1,
  191. Event::End(TagEnd::List(..)) => {
  192. state.list = state.list.saturating_sub(1);
  193. output.push('\n');
  194. }
  195. Event::Start(Tag::Item) => Self::start_item(state, output),
  196. Event::Start(Tag::CodeBlock(kind)) => {
  197. *in_code_block = true;
  198. *code_language = match kind {
  199. CodeBlockKind::Indented => String::from("text"),
  200. CodeBlockKind::Fenced(lang) => lang.to_string(),
  201. };
  202. code_buffer.clear();
  203. self.start_code_block(code_language, output);
  204. }
  205. Event::End(TagEnd::CodeBlock) => {
  206. self.finish_code_block(code_buffer, code_language, output);
  207. *in_code_block = false;
  208. code_language.clear();
  209. code_buffer.clear();
  210. }
  211. Event::Start(Tag::Emphasis) => state.emphasis += 1,
  212. Event::End(TagEnd::Emphasis) => state.emphasis = state.emphasis.saturating_sub(1),
  213. Event::Start(Tag::Strong) => state.strong += 1,
  214. Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
  215. Event::Code(code) => {
  216. let _ = write!(
  217. output,
  218. "{}",
  219. format!("`{code}`").with(self.color_theme.inline_code)
  220. );
  221. }
  222. Event::Rule => output.push_str("---\n"),
  223. Event::Text(text) => {
  224. self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
  225. }
  226. Event::Html(html) | Event::InlineHtml(html) => output.push_str(&html),
  227. Event::FootnoteReference(reference) => {
  228. let _ = write!(output, "[{reference}]");
  229. }
  230. Event::TaskListMarker(done) => output.push_str(if done { "[x] " } else { "[ ] " }),
  231. Event::InlineMath(math) | Event::DisplayMath(math) => output.push_str(&math),
  232. Event::Start(Tag::Link { dest_url, .. }) => {
  233. let _ = write!(
  234. output,
  235. "{}",
  236. format!("[{dest_url}]")
  237. .underlined()
  238. .with(self.color_theme.link)
  239. );
  240. }
  241. Event::Start(Tag::Image { dest_url, .. }) => {
  242. let _ = write!(
  243. output,
  244. "{}",
  245. format!("[image:{dest_url}]").with(self.color_theme.link)
  246. );
  247. }
  248. Event::Start(
  249. Tag::Paragraph
  250. | Tag::Table(..)
  251. | Tag::TableHead
  252. | Tag::TableRow
  253. | Tag::TableCell
  254. | Tag::MetadataBlock(..)
  255. | _,
  256. )
  257. | Event::End(
  258. TagEnd::Link
  259. | TagEnd::Image
  260. | TagEnd::Table
  261. | TagEnd::TableHead
  262. | TagEnd::TableRow
  263. | TagEnd::TableCell
  264. | TagEnd::MetadataBlock(..)
  265. | _,
  266. ) => {}
  267. }
  268. }
  269. fn start_heading(&self, level: u8, output: &mut String) {
  270. output.push('\n');
  271. let prefix = match level {
  272. 1 => "# ",
  273. 2 => "## ",
  274. 3 => "### ",
  275. _ => "#### ",
  276. };
  277. let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
  278. }
  279. fn start_quote(&self, state: &mut RenderState, output: &mut String) {
  280. state.quote += 1;
  281. let _ = write!(output, "{}", "│ ".with(self.color_theme.quote));
  282. }
  283. fn start_item(state: &RenderState, output: &mut String) {
  284. output.push_str(&" ".repeat(state.list.saturating_sub(1)));
  285. output.push_str("• ");
  286. }
  287. fn start_code_block(&self, code_language: &str, output: &mut String) {
  288. if !code_language.is_empty() {
  289. let _ = writeln!(
  290. output,
  291. "{}",
  292. format!("╭─ {code_language}").with(self.color_theme.heading)
  293. );
  294. }
  295. }
  296. fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
  297. output.push_str(&self.highlight_code(code_buffer, code_language));
  298. if !code_language.is_empty() {
  299. let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
  300. }
  301. output.push_str("\n\n");
  302. }
  303. fn push_text(
  304. &self,
  305. text: &str,
  306. state: &RenderState,
  307. output: &mut String,
  308. code_buffer: &mut String,
  309. in_code_block: bool,
  310. ) {
  311. if in_code_block {
  312. code_buffer.push_str(text);
  313. } else {
  314. output.push_str(&state.style_text(text, &self.color_theme));
  315. }
  316. }
  317. #[must_use]
  318. pub fn highlight_code(&self, code: &str, language: &str) -> String {
  319. let syntax = self
  320. .syntax_set
  321. .find_syntax_by_token(language)
  322. .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
  323. let mut syntax_highlighter = HighlightLines::new(syntax, &self.syntax_theme);
  324. let mut colored_output = String::new();
  325. for line in LinesWithEndings::from(code) {
  326. match syntax_highlighter.highlight_line(line, &self.syntax_set) {
  327. Ok(ranges) => {
  328. colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
  329. }
  330. Err(_) => colored_output.push_str(line),
  331. }
  332. }
  333. colored_output
  334. }
  335. pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
  336. let rendered_markdown = self.render_markdown(markdown);
  337. for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
  338. write!(out, "{chunk}")?;
  339. out.flush()?;
  340. thread::sleep(Duration::from_millis(8));
  341. }
  342. writeln!(out)
  343. }
  344. }
  345. #[cfg(test)]
  346. mod tests {
  347. use super::{Spinner, TerminalRenderer};
  348. fn strip_ansi(input: &str) -> String {
  349. let mut output = String::new();
  350. let mut chars = input.chars().peekable();
  351. while let Some(ch) = chars.next() {
  352. if ch == '\u{1b}' {
  353. if chars.peek() == Some(&'[') {
  354. chars.next();
  355. for next in chars.by_ref() {
  356. if next.is_ascii_alphabetic() {
  357. break;
  358. }
  359. }
  360. }
  361. } else {
  362. output.push(ch);
  363. }
  364. }
  365. output
  366. }
  367. #[test]
  368. fn renders_markdown_with_styling_and_lists() {
  369. let terminal_renderer = TerminalRenderer::new();
  370. let markdown_output = terminal_renderer
  371. .render_markdown("# Heading\n\nThis is **bold** and *italic*.\n\n- item\n\n`code`");
  372. assert!(markdown_output.contains("Heading"));
  373. assert!(markdown_output.contains("• item"));
  374. assert!(markdown_output.contains("code"));
  375. assert!(markdown_output.contains('\u{1b}'));
  376. }
  377. #[test]
  378. fn highlights_fenced_code_blocks() {
  379. let terminal_renderer = TerminalRenderer::new();
  380. let markdown_output =
  381. terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
  382. let plain_text = strip_ansi(&markdown_output);
  383. assert!(plain_text.contains("╭─ rust"));
  384. assert!(plain_text.contains("fn hi"));
  385. assert!(markdown_output.contains('\u{1b}'));
  386. }
  387. #[test]
  388. fn spinner_advances_frames() {
  389. let terminal_renderer = TerminalRenderer::new();
  390. let mut spinner = Spinner::new();
  391. let mut out = Vec::new();
  392. spinner
  393. .tick("Working", terminal_renderer.color_theme(), &mut out)
  394. .expect("tick succeeds");
  395. spinner
  396. .tick("Working", terminal_renderer.color_theme(), &mut out)
  397. .expect("tick succeeds");
  398. let output = String::from_utf8_lossy(&out);
  399. assert!(output.contains("Working"));
  400. }
  401. }