|
|
|
@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|