summaryrefslogtreecommitdiffstats
path: root/src/render/markdown.rs
diff options
context:
space:
mode:
authorsigoden <sigoden@gmail.com>2023-10-30 10:07:01 +0800
committerGitHub <noreply@github.com>2023-10-30 10:07:01 +0800
commitb276dfedd2d13de9918b9b87a3d543e7b5eca95e (patch)
tree33e1f864ef38e45ebc297e15ce519ee8105b8757 /src/render/markdown.rs
parent632a6fcc0bfb5b33858cd0fa3876e43ce2073cb3 (diff)
downloadaichat-b276dfedd2d13de9918b9b87a3d543e7b5eca95e.tar.gz
feat: support textwrap (#171)
* feat: support textwrap * improve cmd_render_stream * done cmd_render_stream * add `-w` alias to `--wrap` * done repl_render_stream * add `config.wrap_code` * remove cached config.wrap_width * fix unxpected duplicate lines on kitty * refactor markdown render * improve render * fix test
Diffstat (limited to 'src/render/markdown.rs')
-rw-r--r--src/render/markdown.rs272
1 files changed, 213 insertions, 59 deletions
diff --git a/src/render/markdown.rs b/src/render/markdown.rs
index 66bb601..4866f3d 100644
--- a/src/render/markdown.rs
+++ b/src/render/markdown.rs
@@ -1,4 +1,6 @@
+use anyhow::{anyhow, Context, Result};
use crossterm::style::{Color, Stylize};
+use crossterm::terminal;
use lazy_static::lazy_static;
use std::collections::HashMap;
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme};
@@ -23,115 +25,175 @@ lazy_static! {
#[allow(clippy::module_name_repetitions)]
pub struct MarkdownRender {
+ options: RenderOptions,
syntax_set: SyntaxSet,
- md_theme: Theme,
- code_color: Color,
+ md_theme: Option<Theme>,
+ code_color: Option<Color>,
md_syntax: SyntaxReference,
code_syntax: Option<SyntaxReference>,
prev_line_type: LineType,
+ wrap_width: Option<u16>,
}
impl MarkdownRender {
- pub fn new(light_theme: bool) -> Self {
- let syntax_set: SyntaxSet =
- bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes binary");
- let md_theme: Theme = if light_theme {
- bincode::deserialize_from(MD_THEME_LIGHT).expect("invalid theme binary")
- } else {
- bincode::deserialize_from(MD_THEME).expect("invalid theme binary")
+ pub fn init(options: RenderOptions) -> Result<Self> {
+ let syntax_set: SyntaxSet = bincode::deserialize_from(SYNTAXES)
+ .with_context(|| "MarkdownRender: invalid syntaxes binary")?;
+
+ let md_theme: Option<Theme> = match (options.highlight, options.light_theme) {
+ (false, _) => None,
+ (true, false) => Some(
+ bincode::deserialize_from(MD_THEME)
+ .with_context(|| "MarkdownRender: invalid theme binary")?,
+ ),
+ (true, true) => Some(
+ bincode::deserialize_from(MD_THEME_LIGHT)
+ .expect("MarkdownRender: invalid theme binary"),
+ ),
};
- let code_color = get_code_color(&md_theme);
+ let code_color = md_theme.as_ref().map(get_code_color);
let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone();
let line_type = LineType::Normal;
- Self {
+ let wrap_width = match options.wrap.as_deref() {
+ None => None,
+ Some(value) => match terminal::size() {
+ Ok((columns, _)) => {
+ if value == "auto" {
+ Some(columns)
+ } else {
+ let value = value
+ .parse::<u16>()
+ .map_err(|_| anyhow!("Invalid wrap value"))?;
+ Some(columns.min(value))
+ }
+ }
+ Err(_) => None,
+ },
+ };
+ Ok(Self {
syntax_set,
md_theme,
code_color,
md_syntax,
code_syntax: None,
prev_line_type: line_type,
- }
+ wrap_width,
+ options,
+ })
+ }
+
+ pub(crate) const fn is_code(&self) -> bool {
+ matches!(
+ self.prev_line_type,
+ LineType::CodeBegin | LineType::CodeInner
+ )
}
- pub fn render_block(&mut self, src: &str) -> String {
- src.split('\n')
- .map(|line| {
- self.render_line_impl(line)
- .unwrap_or_else(|| line.to_string())
- })
+ pub fn render(&mut self, text: &str) -> String {
+ text.split('\n')
+ .map(|line| self.render_line_mut(line))
.collect::<Vec<String>>()
.join("\n")
}
pub fn render_line(&self, line: &str) -> String {
- let output = if self.is_code_block() && detect_code_block(line).is_none() {
- self.render_code_line(line)
+ let (_, code_syntax, is_code) = self.check_line(line);
+ if is_code {
+ self.highlint_code_line(line, &code_syntax)
} else {
- self.render_line_inner(line, &self.md_syntax)
- };
- output.unwrap_or_else(|| line.to_string())
+ self.highligh_line(line, &self.md_syntax, false)
+ }
}
- pub const fn is_code_block(&self) -> bool {
- matches!(
- self.prev_line_type,
- LineType::CodeBegin | LineType::CodeInner
- )
+ fn render_line_mut(&mut self, line: &str) -> String {
+ let (line_type, code_syntax, is_code) = self.check_line(line);
+ let output = if is_code {
+ self.highlint_code_line(line, &code_syntax)
+ } else {
+ self.highligh_line(line, &self.md_syntax, false)
+ };
+ self.prev_line_type = line_type;
+ self.code_syntax = code_syntax;
+ output
}
- fn render_line_impl(&mut self, line: &str) -> Option<String> {
+ fn check_line(&self, line: &str) -> (LineType, Option<SyntaxReference>, bool) {
+ let mut line_type = self.prev_line_type;
+ let mut code_syntax = self.code_syntax.clone();
+ let mut is_code = false;
if let Some(lang) = detect_code_block(line) {
- match self.prev_line_type {
+ match line_type {
LineType::Normal | LineType::CodeEnd => {
- self.prev_line_type = LineType::CodeBegin;
- self.code_syntax = if lang.is_empty() {
+ line_type = LineType::CodeBegin;
+ code_syntax = if lang.is_empty() {
None
} else {
self.find_syntax(&lang).cloned()
};
}
LineType::CodeBegin | LineType::CodeInner => {
- self.prev_line_type = LineType::CodeEnd;
- self.code_syntax = None;
+ line_type = LineType::CodeEnd;
+ code_syntax = None;
}
}
- self.render_line_inner(line, &self.md_syntax)
} else {
- match self.prev_line_type {
- LineType::Normal => self.render_line_inner(line, &self.md_syntax),
+ match line_type {
+ LineType::Normal => {}
LineType::CodeEnd => {
- self.prev_line_type = LineType::Normal;
- self.render_line_inner(line, &self.md_syntax)
+ line_type = LineType::Normal;
}
LineType::CodeBegin => {
- if self.code_syntax.is_none() {
+ if code_syntax.is_none() {
if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) {
- self.code_syntax = Some(syntax.clone());
+ code_syntax = Some(syntax.clone());
}
}
- self.prev_line_type = LineType::CodeInner;
- self.render_code_line(line)
+ line_type = LineType::CodeInner;
+ is_code = true;
+ }
+ LineType::CodeInner => {
+ is_code = true;
}
- LineType::CodeInner => self.render_code_line(line),
}
}
+ (line_type, code_syntax, is_code)
}
- fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<String> {
+ fn highligh_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
- let trimed_line = &line[ws.len()..];
- let mut highlighter = HighlightLines::new(syntax, &self.md_theme);
- let ranges = highlighter
- .highlight_line(trimed_line, &self.syntax_set)
- .ok()?;
- Some(format!("{ws}{}", as_terminal_escaped(&ranges)))
- }
-
- fn render_code_line(&self, line: &str) -> Option<String> {
- self.code_syntax.as_ref().map_or_else(
- || Some(format!("{}", line.with(self.code_color))),
- |syntax| self.render_line_inner(line, syntax),
- )
+ let trimed_line: &str = &line[ws.len()..];
+ let mut line_highlighted = None;
+ if let Some(theme) = &self.md_theme {
+ let mut highlighter = HighlightLines::new(syntax, theme);
+ if let Ok(ranges) = highlighter.highlight_line(trimed_line, &self.syntax_set) {
+ line_highlighted = Some(format!("{ws}{}", as_terminal_escaped(&ranges)))
+ }
+ }
+ let line = line_highlighted.unwrap_or_else(|| line.into());
+ self.wrap_line(line, is_code)
+ }
+
+ fn highlint_code_line(&self, line: &str, code_syntax: &Option<SyntaxReference>) -> String {
+ if let Some(syntax) = code_syntax {
+ self.highligh_line(line, syntax, true)
+ } else {
+ let line = match self.code_color {
+ Some(color) => line.with(color).to_string(),
+ None => line.to_string(),
+ };
+ self.wrap_line(line, true)
+ }
+ }
+
+ fn wrap_line(&self, line: String, is_code: bool) -> String {
+ if let Some(width) = self.wrap_width {
+ if is_code && !self.options.wrap_code {
+ return line;
+ }
+ textwrap::wrap(&line, width as usize).join("\n")
+ } else {
+ line
+ }
}
fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {
@@ -146,6 +208,30 @@ impl MarkdownRender {
}
}
+#[derive(Debug, Clone, Default)]
+pub struct RenderOptions {
+ pub highlight: bool,
+ pub light_theme: bool,
+ pub wrap: Option<String>,
+ pub wrap_code: bool,
+}
+
+impl RenderOptions {
+ pub(crate) fn new(
+ highlight: bool,
+ light_theme: bool,
+ wrap: Option<String>,
+ wrap_code: bool,
+ ) -> Self {
+ Self {
+ highlight,
+ light_theme,
+ wrap,
+ wrap_code,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineType {
Normal,
@@ -222,6 +308,44 @@ fn get_code_color(theme: &Theme) -> Color {
mod tests {
use super::*;
+ const TEXT: &str = r#"
+To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file:
+
+```rust
+use std::fs::File;
+
+fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
+ todo!()
+}
+```
+"#;
+ const TEXT_NO_WRAP_CODE: &str = r#"
+To unzip a file in Rust, you can use the `zip` crate. Here's an example code
+that shows how to unzip a file:
+
+```rust
+use std::fs::File;
+
+fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
+ todo!()
+}
+```
+"#;
+
+ const TEXT_WRAP_ALL: &str = r#"
+To unzip a file in Rust, you can use the `zip` crate. Here's an example code
+that shows how to unzip a file:
+
+```rust
+use std::fs::File;
+
+fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn
+std::error::Error>> {
+ todo!()
+}
+```
+"#;
+
#[test]
fn test_assets() {
let syntax_set: SyntaxSet =
@@ -233,7 +357,37 @@ mod tests {
#[test]
fn test_render() {
- let render = MarkdownRender::new(true);
+ let options = RenderOptions::default();
+ let render = MarkdownRender::init(options).unwrap();
assert!(render.find_syntax("csharp").is_some());
}
+
+ #[test]
+ fn no_theme() {
+ let options = RenderOptions::default();
+ let mut render = MarkdownRender::init(options).unwrap();
+ let output = render.render(TEXT);
+ assert_eq!(TEXT, output);
+ }
+
+ #[test]
+ fn no_wrap_code() {
+ let options = RenderOptions::default();
+ let mut render = MarkdownRender::init(options).unwrap();
+ render.wrap_width = Some(80);
+ let output = render.render(TEXT);
+ assert_eq!(TEXT_NO_WRAP_CODE, output);
+ }
+
+ #[test]
+ fn wrap_all() {
+ let options = RenderOptions {
+ wrap_code: true,
+ ..Default::default()
+ };
+ let mut render = MarkdownRender::init(options).unwrap();
+ render.wrap_width = Some(80);
+ let output = render.render(TEXT);
+ assert_eq!(TEXT_WRAP_ALL, output);
+ }
}