From 9075aa4fc2f893c13baee91dc7c452f689024577 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Sat, 3 Aug 2024 14:29:24 -0700 Subject: [PATCH 1/2] chore: move code snippet parsing logic into builder --- src/custom.rs | 2 +- src/execute.rs | 4 +- src/markdown/code.rs | 297 ------------------ src/markdown/elements.rs | 325 +------------------ src/markdown/mod.rs | 1 - src/markdown/parse.rs | 49 +-- src/processing/builder.rs | 45 +-- src/processing/code.rs | 600 +++++++++++++++++++++++++++++++++++- src/processing/execution.rs | 3 +- src/render/highlighting.rs | 3 +- 10 files changed, 654 insertions(+), 675 deletions(-) delete mode 100644 src/markdown/code.rs diff --git a/src/custom.rs b/src/custom.rs index 494d6b4a..286f292a 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -1,7 +1,7 @@ use crate::{ input::user::KeyBinding, - markdown::elements::SnippetLanguage, media::{emulator::TerminalEmulator, kitty::KittyMode}, + processing::code::SnippetLanguage, GraphicsMode, }; use clap::ValueEnum; diff --git a/src/execute.rs b/src/execute.rs index c54bd24f..6d33fd50 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -2,7 +2,7 @@ use crate::{ custom::LanguageSnippetExecutionConfig, - markdown::elements::{Snippet, SnippetLanguage}, + processing::code::{Snippet, SnippetLanguage}, }; use once_cell::sync::Lazy; use os_pipe::PipeReader; @@ -244,7 +244,7 @@ impl ProcessStatus { #[cfg(test)] mod test { use super::*; - use crate::markdown::elements::SnippetAttributes; + use crate::processing::code::SnippetAttributes; #[test] fn shell_code_execution() { diff --git a/src/markdown/code.rs b/src/markdown/code.rs deleted file mode 100644 index 34f45d4f..00000000 --- a/src/markdown/code.rs +++ /dev/null @@ -1,297 +0,0 @@ -use super::elements::{ - Highlight, HighlightGroup, Percent, PercentParseError, Snippet, SnippetAttributes, SnippetLanguage, -}; -use comrak::nodes::NodeCodeBlock; -use strum::EnumDiscriminants; - -pub(crate) type ParseResult = Result; - -pub(crate) struct CodeBlockParser; - -impl CodeBlockParser { - pub(crate) fn parse(code_block: &NodeCodeBlock) -> ParseResult { - let (language, attributes) = Self::parse_block_info(&code_block.info)?; - let code = Snippet { contents: code_block.literal.clone(), language, attributes }; - Ok(code) - } - - fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> { - let (language, input) = Self::parse_language(input); - let attributes = Self::parse_attributes(input)?; - if attributes.auto_render && !language.supports_auto_render() { - return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering")); - } - if attributes.width.is_some() && !attributes.auto_render { - return Err(CodeBlockParseError::NotRenderSnippet("width")); - } - Ok((language, attributes)) - } - - fn parse_language(input: &str) -> (SnippetLanguage, &str) { - let token = Self::next_identifier(input); - // this always returns `Ok` given we fall back to `Unknown` if we don't know the language. - let language = token.parse().expect("language parsing"); - let rest = &input[token.len()..]; - (language, rest) - } - - fn parse_attributes(mut input: &str) -> ParseResult { - let mut attributes = SnippetAttributes::default(); - let mut processed_attributes = Vec::new(); - while let (Some(attribute), rest) = Self::parse_attribute(input)? { - let discriminant = AttributeDiscriminants::from(&attribute); - if processed_attributes.contains(&discriminant) { - return Err(CodeBlockParseError::DuplicateAttribute("duplicate attribute")); - } - match attribute { - Attribute::LineNumbers => attributes.line_numbers = true, - Attribute::Exec => attributes.execute = true, - Attribute::AutoRender => attributes.auto_render = true, - Attribute::HighlightedLines(lines) => attributes.highlight_groups = lines, - Attribute::Width(width) => attributes.width = Some(width), - }; - processed_attributes.push(discriminant); - input = rest; - } - if attributes.highlight_groups.is_empty() { - attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All])); - } - Ok(attributes) - } - - fn parse_attribute(input: &str) -> ParseResult<(Option, &str)> { - let input = Self::skip_whitespace(input); - let (attribute, input) = match input.chars().next() { - Some('+') => { - let token = Self::next_identifier(&input[1..]); - let attribute = match token { - "line_numbers" => Attribute::LineNumbers, - "exec" => Attribute::Exec, - "render" => Attribute::AutoRender, - token if token.starts_with("width:") => { - let value = input.split_once("+width:").unwrap().1; - let (width, input) = Self::parse_width(value)?; - return Ok((Some(Attribute::Width(width)), input)); - } - _ => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())), - }; - (Some(attribute), &input[token.len() + 1..]) - } - Some('{') => { - let (lines, input) = Self::parse_highlight_groups(&input[1..])?; - (Some(Attribute::HighlightedLines(lines)), input) - } - Some(_) => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())), - None => (None, input), - }; - Ok((attribute, input)) - } - - fn parse_highlight_groups(input: &str) -> ParseResult<(Vec, &str)> { - use CodeBlockParseError::InvalidHighlightedLines; - let Some((head, tail)) = input.split_once('}') else { - return Err(InvalidHighlightedLines("no enclosing '}'".into())); - }; - let head = head.trim(); - if head.is_empty() { - return Ok((Vec::new(), tail)); - } - - let mut highlight_groups = Vec::new(); - for group in head.split('|') { - let group = Self::parse_highlight_group(group)?; - highlight_groups.push(group); - } - Ok((highlight_groups, tail)) - } - - fn parse_highlight_group(input: &str) -> ParseResult { - let mut highlights = Vec::new(); - for piece in input.split(',') { - let piece = piece.trim(); - if piece == "all" { - highlights.push(Highlight::All); - continue; - } - match piece.split_once('-') { - Some((left, right)) => { - let left = Self::parse_number(left)?; - let right = Self::parse_number(right)?; - let right = right - .checked_add(1) - .ok_or_else(|| CodeBlockParseError::InvalidHighlightedLines(format!("{right} is too large")))?; - highlights.push(Highlight::Range(left..right)); - } - None => { - let number = Self::parse_number(piece)?; - highlights.push(Highlight::Single(number)); - } - } - } - Ok(HighlightGroup::new(highlights)) - } - - fn parse_number(input: &str) -> ParseResult { - input - .trim() - .parse() - .map_err(|_| CodeBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'"))) - } - - fn parse_width(input: &str) -> ParseResult<(Percent, &str)> { - let end_index = input.find(' ').unwrap_or(input.len()); - let value = input[0..end_index].parse().map_err(CodeBlockParseError::InvalidWidth)?; - Ok((value, &input[end_index..])) - } - - fn skip_whitespace(input: &str) -> &str { - input.trim_start_matches(' ') - } - - fn next_identifier(input: &str) -> &str { - match input.split_once(' ') { - Some((token, _)) => token, - None => input, - } - } -} - -#[derive(thiserror::Error, Debug)] -pub(crate) enum CodeBlockParseError { - #[error("invalid code attribute: {0}")] - InvalidToken(String), - - #[error("invalid highlighted lines: {0}")] - InvalidHighlightedLines(String), - - #[error("invalid width: {0}")] - InvalidWidth(PercentParseError), - - #[error("duplicate attribute: {0}")] - DuplicateAttribute(&'static str), - - #[error("language {0:?} does not support {1}")] - UnsupportedAttribute(SnippetLanguage, &'static str), - - #[error("attribute {0} can only be set in +render blocks")] - NotRenderSnippet(&'static str), -} - -#[derive(EnumDiscriminants)] -enum Attribute { - LineNumbers, - Exec, - AutoRender, - HighlightedLines(Vec), - Width(Percent), -} - -#[cfg(test)] -mod test { - use super::*; - use rstest::rstest; - use Highlight::*; - - fn parse_language(input: &str) -> SnippetLanguage { - let (language, _) = CodeBlockParser::parse_block_info(input).expect("parse failed"); - language - } - - fn try_parse_attributes(input: &str) -> Result { - let (_, attributes) = CodeBlockParser::parse_block_info(input)?; - Ok(attributes) - } - - fn parse_attributes(input: &str) -> SnippetAttributes { - try_parse_attributes(input).expect("parse failed") - } - - #[test] - fn unknown_language() { - assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string())); - } - - #[test] - fn no_attributes() { - assert_eq!(parse_language("rust"), SnippetLanguage::Rust); - } - - #[test] - fn one_attribute() { - let attributes = parse_attributes("bash +exec"); - assert!(attributes.execute); - assert!(!attributes.line_numbers); - } - - #[test] - fn two_attributes() { - let attributes = parse_attributes("bash +exec +line_numbers"); - assert!(attributes.execute); - assert!(attributes.line_numbers); - } - - #[test] - fn invalid_attributes() { - CodeBlockParser::parse_block_info("bash +potato").unwrap_err(); - CodeBlockParser::parse_block_info("bash potato").unwrap_err(); - } - - #[rstest] - #[case::no_end("{")] - #[case::number_no_end("{42")] - #[case::comma_nothing("{42,")] - #[case::brace_comma("{,}")] - #[case::range_no_end("{42-")] - #[case::range_end("{42-}")] - #[case::too_many_ranges("{42-3-5}")] - #[case::range_comma("{42-,")] - #[case::too_large("{65536}")] - #[case::too_large_end("{1-65536}")] - fn invalid_line_highlights(#[case] input: &str) { - let input = format!("bash {input}"); - CodeBlockParser::parse_block_info(&input).expect_err("parsed successfully"); - } - - #[test] - fn highlight_none() { - let attributes = parse_attributes("bash {}"); - assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]); - } - - #[test] - fn highlight_specific_lines() { - let attributes = parse_attributes("bash { 1, 2 , 3 }"); - assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]); - } - - #[test] - fn highlight_line_range() { - let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }"); - assert_eq!( - attributes.highlight_groups, - &[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])] - ); - } - - #[test] - fn multiple_groups() { - let attributes = parse_attributes("bash {1-3,5 |6-9}"); - assert_eq!(attributes.highlight_groups.len(), 2); - assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)])); - assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)])); - } - - #[test] - fn parse_width() { - let attributes = parse_attributes("mermaid +width:50% +render"); - assert!(attributes.auto_render); - assert_eq!(attributes.width, Some(Percent(50))); - } - - #[test] - fn invalid_width() { - try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded"); - try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded"); - try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded"); - } -} diff --git a/src/markdown/elements.rs b/src/markdown/elements.rs index 95877fd2..152db6fe 100644 --- a/src/markdown/elements.rs +++ b/src/markdown/elements.rs @@ -1,7 +1,5 @@ use crate::style::TextStyle; -use serde_with::DeserializeFromStr; -use std::{convert::Infallible, fmt::Write, iter, ops::Range, path::PathBuf, str::FromStr}; -use strum::EnumIter; +use std::{iter, path::PathBuf, str::FromStr}; use unicode_width::UnicodeWidthStr; /// A markdown element. @@ -31,7 +29,16 @@ pub(crate) enum MarkdownElement { List(Vec), /// A code snippet. - Snippet(Snippet), + Snippet { + /// The information line that specifies this code's language, attributes, etc. + info: String, + + /// The code in this snippet. + code: String, + + /// The position in the source file this snippet came from. + source_position: SourcePosition, + }, /// A table. Table(Table), @@ -173,237 +180,6 @@ pub(crate) enum ListItemType { OrderedPeriod, } -/// A code snippet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Snippet { - /// The snippet itself. - pub(crate) contents: String, - - /// The programming language this snippet is written in. - pub(crate) language: SnippetLanguage, - - /// The attributes used for snippet. - pub(crate) attributes: SnippetAttributes, -} - -impl Snippet { - pub(crate) fn visible_lines(&self) -> impl Iterator { - let prefix = self.language.hidden_line_prefix(); - self.contents.lines().filter(move |line| !prefix.is_some_and(|prefix| line.starts_with(prefix))) - } - - pub(crate) fn executable_contents(&self) -> String { - if let Some(prefix) = self.language.hidden_line_prefix() { - self.contents.lines().fold(String::new(), |mut output, line| { - let line = line.strip_prefix(prefix).unwrap_or(line); - let _ = writeln!(output, "{line}"); - output - }) - } else { - self.contents.to_owned() - } - } -} - -/// The language of a code snippet. -#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr)] -pub enum SnippetLanguage { - Ada, - Asp, - Awk, - Bash, - BatchFile, - C, - CMake, - Crontab, - CSharp, - Clojure, - Cpp, - Css, - DLang, - Diff, - Docker, - Dotenv, - Elixir, - Elm, - Erlang, - Fish, - Go, - Haskell, - Html, - Java, - JavaScript, - Json, - Kotlin, - Latex, - Lua, - Makefile, - Mermaid, - Markdown, - Nix, - Nushell, - OCaml, - Perl, - Php, - Protobuf, - Puppet, - Python, - R, - Ruby, - Rust, - RustScript, - Scala, - Shell, - Sql, - Swift, - Svelte, - Terraform, - TypeScript, - Typst, - Unknown(String), - Xml, - Yaml, - Vue, - Zig, - Zsh, -} - -impl SnippetLanguage { - pub(crate) fn supports_auto_render(&self) -> bool { - matches!(self, Self::Latex | Self::Typst | Self::Mermaid) - } - - pub(crate) fn hidden_line_prefix(&self) -> Option<&'static str> { - use SnippetLanguage::*; - match self { - Rust => Some("# "), - Python | Bash | Fish | Shell | Zsh | Kotlin | Java | JavaScript | TypeScript | C | Cpp | Go => Some("/// "), - _ => None, - } - } -} - -impl FromStr for SnippetLanguage { - type Err = Infallible; - - fn from_str(s: &str) -> Result { - use SnippetLanguage::*; - let language = match s { - "ada" => Ada, - "asp" => Asp, - "awk" => Awk, - "bash" => Bash, - "c" => C, - "cmake" => CMake, - "crontab" => Crontab, - "csharp" => CSharp, - "clojure" => Clojure, - "cpp" | "c++" => Cpp, - "css" => Css, - "d" => DLang, - "diff" => Diff, - "docker" => Docker, - "dotenv" => Dotenv, - "elixir" => Elixir, - "elm" => Elm, - "erlang" => Erlang, - "fish" => Fish, - "go" => Go, - "haskell" => Haskell, - "html" => Html, - "java" => Java, - "javascript" | "js" => JavaScript, - "json" => Json, - "kotlin" => Kotlin, - "latex" => Latex, - "lua" => Lua, - "make" => Makefile, - "markdown" => Markdown, - "mermaid" => Mermaid, - "nix" => Nix, - "nushell" | "nu" => Nushell, - "ocaml" => OCaml, - "perl" => Perl, - "php" => Php, - "protobuf" => Protobuf, - "puppet" => Puppet, - "python" => Python, - "r" => R, - "ruby" => Ruby, - "rust" => Rust, - "rust-script" => RustScript, - "scala" => Scala, - "shell" | "sh" => Shell, - "sql" => Sql, - "svelte" => Svelte, - "swift" => Swift, - "terraform" => Terraform, - "typescript" | "ts" => TypeScript, - "typst" => Typst, - "xml" => Xml, - "yaml" => Yaml, - "vue" => Vue, - "zig" => Zig, - "zsh" => Zsh, - other => Unknown(other.to_string()), - }; - Ok(language) - } -} - -/// Attributes for code snippets. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct SnippetAttributes { - /// Whether the snippet is marked as executable. - pub(crate) execute: bool, - - /// Whether a snippet is marked to be auto rendered. - /// - /// An auto rendered snippet is transformed during parsing, leading to some visual - /// representation of it being shown rather than the original code. - pub(crate) auto_render: bool, - - /// Whether the snippet should show line numbers. - pub(crate) line_numbers: bool, - - /// The groups of lines to highlight. - pub(crate) highlight_groups: Vec, - - /// The width of the generated image. - /// - /// Only valid for +render snippets. - pub(crate) width: Option, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct HighlightGroup(Vec); - -impl HighlightGroup { - pub(crate) fn new(highlights: Vec) -> Self { - Self(highlights) - } - - pub(crate) fn contains(&self, line_number: u16) -> bool { - for higlight in &self.0 { - match higlight { - Highlight::All => return true, - Highlight::Single(number) if number == &line_number => return true, - Highlight::Range(range) if range.contains(&line_number) => return true, - _ => continue, - }; - } - false - } -} - -/// A highlighted set of lines -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum Highlight { - All, - Single(u16), - Range(Range), -} - /// A table. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Table { @@ -471,82 +247,3 @@ pub enum PercentParseError { #[error("unexpected: '{0}'")] Trailer(String), } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn code_visible_lines_bash() { - let contents = r"echo 'hello world' -/// echo 'this was hidden' - -echo '/// is the prefix' -/// echo 'the prefix is /// ' -echo 'hello again' -" - .to_string(); - - let expected = vec!["echo 'hello world'", "", "echo '/// is the prefix'", "echo 'hello again'"]; - let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() }; - assert_eq!(expected, code.visible_lines().collect::>()); - } - - #[test] - fn code_visible_lines_rust() { - let contents = r##"# fn main() { -println!("Hello world"); -# // The prefix is # . -# } -"## - .to_string(); - - let expected = vec!["println!(\"Hello world\");"]; - let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; - assert_eq!(expected, code.visible_lines().collect::>()); - } - - #[test] - fn code_executable_contents_bash() { - let contents = r"echo 'hello world' -/// echo 'this was hidden' - -echo '/// is the prefix' -/// echo 'the prefix is /// ' -echo 'hello again' -" - .to_string(); - - let expected = r"echo 'hello world' -echo 'this was hidden' - -echo '/// is the prefix' -echo 'the prefix is /// ' -echo 'hello again' -" - .to_string(); - - let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() }; - assert_eq!(expected, code.executable_contents()); - } - - #[test] - fn code_executable_contents_rust() { - let contents = r##"# fn main() { -println!("Hello world"); -# // The prefix is # . -# } -"## - .to_string(); - - let expected = r##"fn main() { -println!("Hello world"); -// The prefix is # . -} -"## - .to_string(); - - let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; - assert_eq!(expected, code.executable_contents()); - } -} diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index ea4001a9..665f1c0c 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod code; pub(crate) mod elements; pub(crate) mod parse; pub(crate) mod text; diff --git a/src/markdown/parse.rs b/src/markdown/parse.rs index 0392f09a..6eeb8cd1 100644 --- a/src/markdown/parse.rs +++ b/src/markdown/parse.rs @@ -1,9 +1,6 @@ -use super::{code::CodeBlockParseError, elements::SourcePosition}; +use super::elements::SourcePosition; use crate::{ - markdown::{ - code::CodeBlockParser, - elements::{ListItem, ListItemType, MarkdownElement, ParagraphElement, Table, TableRow, Text, TextBlock}, - }, + markdown::elements::{ListItem, ListItemType, MarkdownElement, ParagraphElement, Table, TableRow, Text, TextBlock}, style::TextStyle, }; use comrak::{ @@ -77,13 +74,13 @@ impl<'a> MarkdownParser<'a> { | MarkdownElement::SetexHeading { .. } | MarkdownElement::Heading { .. } | MarkdownElement::Paragraph(_) - | MarkdownElement::Image { .. } | MarkdownElement::List(_) - | MarkdownElement::Snippet(_) | MarkdownElement::Table(_) | MarkdownElement::ThematicBreak | MarkdownElement::BlockQuote(_) => continue, - MarkdownElement::Comment { source_position, .. } => source_position, + MarkdownElement::Comment { source_position, .. } + | MarkdownElement::Snippet { source_position, .. } + | MarkdownElement::Image { source_position, .. } => source_position, }; *position = position.offset_lines(lines_offset); } @@ -167,9 +164,11 @@ impl<'a> MarkdownParser<'a> { if !block.fenced { return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos)); } - let code = - CodeBlockParser::parse(block).map_err(|e| ParseErrorKind::InvalidCodeBlock(e).with_sourcepos(sourcepos))?; - Ok(MarkdownElement::Snippet(code)) + Ok(MarkdownElement::Snippet { + info: block.info.clone(), + code: block.literal.clone(), + source_position: sourcepos.into(), + }) } fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult { @@ -437,9 +436,6 @@ pub(crate) enum ParseErrorKind { /// We don't support unfenced code blocks. UnfencedCodeBlock, - /// A code block contains invalid attributes. - InvalidCodeBlock(CodeBlockParseError), - /// An internal parsing error. Internal(String), } @@ -452,7 +448,6 @@ impl Display for ParseErrorKind { write!(f, "unsupported structure in {container}: {element}") } Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"), - Self::InvalidCodeBlock(error) => write!(f, "invalid code block: {error}"), Self::Internal(message) => write!(f, "internal error: {message}"), } } @@ -516,7 +511,6 @@ impl Identifier for NodeValue { #[cfg(test)] mod test { use super::*; - use crate::markdown::elements::SnippetLanguage; use rstest::rstest; use std::path::Path; @@ -667,29 +661,14 @@ another", fn code_block() { let parsed = parse_single( r" -```rust +```rust +exec let q = 42; ```` ", ); - let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") }; - assert_eq!(code.language, SnippetLanguage::Rust); - assert_eq!(code.contents, "let q = 42;\n"); - assert!(!code.attributes.execute); - } - - #[test] - fn executable_code_block() { - let parsed = parse_single( - r" -```bash +exec -echo hi mom -```` -", - ); - let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") }; - assert_eq!(code.language, SnippetLanguage::Bash); - assert!(code.attributes.execute); + let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!("not a code block: {parsed:?}") }; + assert_eq!(info, "rust +exec"); + assert_eq!(code, "let q = 42;\n"); } #[test] diff --git a/src/processing/builder.rs b/src/processing/builder.rs index b87703b4..6b7b8f6f 100644 --- a/src/processing/builder.rs +++ b/src/processing/builder.rs @@ -1,11 +1,15 @@ -use super::{code::CodeLine, execution::SnippetExecutionDisabledOperation, modals::KeyBindingsModalBuilder}; +use super::{ + code::{CodeBlockParseError, CodeBlockParser, CodeLine, Highlight, HighlightGroup, Snippet, SnippetLanguage}, + execution::SnippetExecutionDisabledOperation, + modals::KeyBindingsModalBuilder, +}; use crate::{ custom::{KeyBindingsConfig, OptionsConfig}, execute::SnippetExecutor, markdown::{ elements::{ - Highlight, HighlightGroup, ListItem, ListItemType, MarkdownElement, ParagraphElement, Percent, - PercentParseError, Snippet, SnippetLanguage, SourcePosition, Table, TableRow, Text, TextBlock, + ListItem, ListItemType, MarkdownElement, ParagraphElement, Percent, PercentParseError, SourcePosition, + Table, TableRow, Text, TextBlock, }, text::WeightedTextBlock, }, @@ -256,7 +260,7 @@ impl<'a> PresentationBuilder<'a> { MarkdownElement::Heading { level, text } => self.push_heading(level, text), MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?, MarkdownElement::List(elements) => self.push_list(elements), - MarkdownElement::Snippet(code) => self.push_code(code)?, + MarkdownElement::Snippet { info, code, source_position } => self.push_code(info, code, source_position)?, MarkdownElement::Table(table) => self.push_table(table), MarkdownElement::ThematicBreak => self.process_thematic_break(), MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, @@ -712,16 +716,17 @@ impl<'a> PresentationBuilder<'a> { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text)))); } - fn push_code(&mut self, code: Snippet) -> Result<(), BuildError> { - // TODO this needs to be the only way to diff things - self.push_differ(code.contents.clone()); + fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> Result<(), BuildError> { + let snippet = CodeBlockParser::parse(info, code) + .map_err(|error| BuildError::InvalidCode { line: source_position.start.line + 1, error })?; + self.push_differ(snippet.contents.clone()); - if code.attributes.auto_render { - return self.push_rendered_code(code); + if snippet.attributes.auto_render { + return self.push_rendered_code(snippet); } - let lines = CodePreparer::new(&self.theme).prepare(&code); + let lines = CodePreparer::new(&self.theme).prepare(&snippet); let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0); - let (lines, context) = self.highlight_lines(&code, lines, block_length); + let (lines, context) = self.highlight_lines(&snippet, lines, block_length); for line in lines { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line))); } @@ -729,9 +734,9 @@ impl<'a> PresentationBuilder<'a> { if self.options.allow_mutations && context.borrow().groups.len() > 1 { self.chunk_mutators.push(Box::new(HighlightMutator::new(context))); } - if code.attributes.execute { + if snippet.attributes.execute { if self.options.enable_snippet_execution { - self.push_code_execution(code, block_length)?; + self.push_code_execution(snippet, block_length)?; } else { let operation = SnippetExecutionDisabledOperation::new( self.theme.execution_output.status.failure, @@ -978,6 +983,9 @@ pub enum BuildError { #[error("invalid theme: {0}")] InvalidTheme(#[from] LoadThemeError), + #[error("invalid code at line {line}: {error}")] + InvalidCode { line: usize, error: CodeBlockParseError }, + #[error("invalid code highlighter theme: '{0}'")] InvalidCodeTheme(String), @@ -1175,7 +1183,6 @@ impl AsRenderOperations for Differ { #[cfg(test)] mod test { use super::*; - use crate::markdown::elements::SnippetAttributes; use rstest::rstest; fn build_presentation(elements: Vec) -> Presentation { @@ -1599,11 +1606,11 @@ mod test { #[case::enabled(true)] #[case::disabled(false)] fn snippet_execution(#[case] enabled: bool) { - let element = MarkdownElement::Snippet(Snippet { - contents: "".into(), - language: SnippetLanguage::Rust, - attributes: SnippetAttributes { execute: true, ..Default::default() }, - }); + let element = MarkdownElement::Snippet { + info: "rust +exec".into(), + code: "".into(), + source_position: Default::default(), + }; let options = PresentationBuilderOptions { enable_snippet_execution: enabled, ..Default::default() }; let presentation = build_presentation_with_options(vec![element], options); let slide = presentation.iter_slides().next().unwrap(); diff --git a/src/processing/code.rs b/src/processing/code.rs index 557888d4..2882ec15 100644 --- a/src/processing/code.rs +++ b/src/processing/code.rs @@ -1,7 +1,7 @@ use super::padding::NumberPadder; use crate::{ markdown::{ - elements::{HighlightGroup, Snippet}, + elements::{Percent, PercentParseError}, text::{WeightedText, WeightedTextBlock}, }, presentation::{AsRenderOperations, BlockLine, ChunkMutator, RenderOperation}, @@ -13,7 +13,9 @@ use crate::{ theme::{Alignment, CodeBlockStyle}, PresentationTheme, }; -use std::{cell::RefCell, rc::Rc}; +use serde_with::DeserializeFromStr; +use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, rc::Rc, str::FromStr}; +use strum::{EnumDiscriminants, EnumIter}; use unicode_width::UnicodeWidthStr; pub(crate) struct CodePreparer<'a> { @@ -190,11 +192,438 @@ impl ChunkMutator for HighlightMutator { } } +pub(crate) type ParseResult = Result; + +pub(crate) struct CodeBlockParser; + +impl CodeBlockParser { + pub(crate) fn parse(info: String, code: String) -> ParseResult { + let (language, attributes) = Self::parse_block_info(&info)?; + let code = Snippet { contents: code, language, attributes }; + Ok(code) + } + + fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> { + let (language, input) = Self::parse_language(input); + let attributes = Self::parse_attributes(input)?; + if attributes.auto_render && !language.supports_auto_render() { + return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering")); + } + if attributes.width.is_some() && !attributes.auto_render { + return Err(CodeBlockParseError::NotRenderSnippet("width")); + } + Ok((language, attributes)) + } + + fn parse_language(input: &str) -> (SnippetLanguage, &str) { + let token = Self::next_identifier(input); + // this always returns `Ok` given we fall back to `Unknown` if we don't know the language. + let language = token.parse().expect("language parsing"); + let rest = &input[token.len()..]; + (language, rest) + } + + fn parse_attributes(mut input: &str) -> ParseResult { + let mut attributes = SnippetAttributes::default(); + let mut processed_attributes = Vec::new(); + while let (Some(attribute), rest) = Self::parse_attribute(input)? { + let discriminant = AttributeDiscriminants::from(&attribute); + if processed_attributes.contains(&discriminant) { + return Err(CodeBlockParseError::DuplicateAttribute("duplicate attribute")); + } + match attribute { + Attribute::LineNumbers => attributes.line_numbers = true, + Attribute::Exec => attributes.execute = true, + Attribute::AutoRender => attributes.auto_render = true, + Attribute::HighlightedLines(lines) => attributes.highlight_groups = lines, + Attribute::Width(width) => attributes.width = Some(width), + }; + processed_attributes.push(discriminant); + input = rest; + } + if attributes.highlight_groups.is_empty() { + attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All])); + } + Ok(attributes) + } + + fn parse_attribute(input: &str) -> ParseResult<(Option, &str)> { + let input = Self::skip_whitespace(input); + let (attribute, input) = match input.chars().next() { + Some('+') => { + let token = Self::next_identifier(&input[1..]); + let attribute = match token { + "line_numbers" => Attribute::LineNumbers, + "exec" => Attribute::Exec, + "render" => Attribute::AutoRender, + token if token.starts_with("width:") => { + let value = input.split_once("+width:").unwrap().1; + let (width, input) = Self::parse_width(value)?; + return Ok((Some(Attribute::Width(width)), input)); + } + _ => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())), + }; + (Some(attribute), &input[token.len() + 1..]) + } + Some('{') => { + let (lines, input) = Self::parse_highlight_groups(&input[1..])?; + (Some(Attribute::HighlightedLines(lines)), input) + } + Some(_) => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())), + None => (None, input), + }; + Ok((attribute, input)) + } + + fn parse_highlight_groups(input: &str) -> ParseResult<(Vec, &str)> { + use CodeBlockParseError::InvalidHighlightedLines; + let Some((head, tail)) = input.split_once('}') else { + return Err(InvalidHighlightedLines("no enclosing '}'".into())); + }; + let head = head.trim(); + if head.is_empty() { + return Ok((Vec::new(), tail)); + } + + let mut highlight_groups = Vec::new(); + for group in head.split('|') { + let group = Self::parse_highlight_group(group)?; + highlight_groups.push(group); + } + Ok((highlight_groups, tail)) + } + + fn parse_highlight_group(input: &str) -> ParseResult { + let mut highlights = Vec::new(); + for piece in input.split(',') { + let piece = piece.trim(); + if piece == "all" { + highlights.push(Highlight::All); + continue; + } + match piece.split_once('-') { + Some((left, right)) => { + let left = Self::parse_number(left)?; + let right = Self::parse_number(right)?; + let right = right + .checked_add(1) + .ok_or_else(|| CodeBlockParseError::InvalidHighlightedLines(format!("{right} is too large")))?; + highlights.push(Highlight::Range(left..right)); + } + None => { + let number = Self::parse_number(piece)?; + highlights.push(Highlight::Single(number)); + } + } + } + Ok(HighlightGroup::new(highlights)) + } + + fn parse_number(input: &str) -> ParseResult { + input + .trim() + .parse() + .map_err(|_| CodeBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'"))) + } + + fn parse_width(input: &str) -> ParseResult<(Percent, &str)> { + let end_index = input.find(' ').unwrap_or(input.len()); + let value = input[0..end_index].parse().map_err(CodeBlockParseError::InvalidWidth)?; + Ok((value, &input[end_index..])) + } + + fn skip_whitespace(input: &str) -> &str { + input.trim_start_matches(' ') + } + + fn next_identifier(input: &str) -> &str { + match input.split_once(' ') { + Some((token, _)) => token, + None => input, + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum CodeBlockParseError { + #[error("invalid code attribute: {0}")] + InvalidToken(String), + + #[error("invalid highlighted lines: {0}")] + InvalidHighlightedLines(String), + + #[error("invalid width: {0}")] + InvalidWidth(PercentParseError), + + #[error("duplicate attribute: {0}")] + DuplicateAttribute(&'static str), + + #[error("language {0:?} does not support {1}")] + UnsupportedAttribute(SnippetLanguage, &'static str), + + #[error("attribute {0} can only be set in +render blocks")] + NotRenderSnippet(&'static str), +} + +#[derive(EnumDiscriminants)] +enum Attribute { + LineNumbers, + Exec, + AutoRender, + HighlightedLines(Vec), + Width(Percent), +} + +/// A code snippet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Snippet { + /// The snippet itself. + pub(crate) contents: String, + + /// The programming language this snippet is written in. + pub(crate) language: SnippetLanguage, + + /// The attributes used for snippet. + pub(crate) attributes: SnippetAttributes, +} + +impl Snippet { + pub(crate) fn visible_lines(&self) -> impl Iterator { + let prefix = self.language.hidden_line_prefix(); + self.contents.lines().filter(move |line| !prefix.is_some_and(|prefix| line.starts_with(prefix))) + } + + pub(crate) fn executable_contents(&self) -> String { + if let Some(prefix) = self.language.hidden_line_prefix() { + self.contents.lines().fold(String::new(), |mut output, line| { + let line = line.strip_prefix(prefix).unwrap_or(line); + let _ = writeln!(output, "{line}"); + output + }) + } else { + self.contents.to_owned() + } + } +} + +/// The language of a code snippet. +#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr)] +pub enum SnippetLanguage { + Ada, + Asp, + Awk, + Bash, + BatchFile, + C, + CMake, + Crontab, + CSharp, + Clojure, + Cpp, + Css, + DLang, + Diff, + Docker, + Dotenv, + Elixir, + Elm, + Erlang, + Fish, + Go, + Haskell, + Html, + Java, + JavaScript, + Json, + Kotlin, + Latex, + Lua, + Makefile, + Mermaid, + Markdown, + Nix, + Nushell, + OCaml, + Perl, + Php, + Protobuf, + Puppet, + Python, + R, + Ruby, + Rust, + RustScript, + Scala, + Shell, + Sql, + Swift, + Svelte, + Terraform, + TypeScript, + Typst, + Unknown(String), + Xml, + Yaml, + Vue, + Zig, + Zsh, +} + +impl SnippetLanguage { + pub(crate) fn supports_auto_render(&self) -> bool { + matches!(self, Self::Latex | Self::Typst | Self::Mermaid) + } + + pub(crate) fn hidden_line_prefix(&self) -> Option<&'static str> { + use SnippetLanguage::*; + match self { + Rust => Some("# "), + Python | Bash | Fish | Shell | Zsh | Kotlin | Java | JavaScript | TypeScript | C | Cpp | Go => Some("/// "), + _ => None, + } + } +} + +impl FromStr for SnippetLanguage { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + use SnippetLanguage::*; + let language = match s { + "ada" => Ada, + "asp" => Asp, + "awk" => Awk, + "bash" => Bash, + "c" => C, + "cmake" => CMake, + "crontab" => Crontab, + "csharp" => CSharp, + "clojure" => Clojure, + "cpp" | "c++" => Cpp, + "css" => Css, + "d" => DLang, + "diff" => Diff, + "docker" => Docker, + "dotenv" => Dotenv, + "elixir" => Elixir, + "elm" => Elm, + "erlang" => Erlang, + "fish" => Fish, + "go" => Go, + "haskell" => Haskell, + "html" => Html, + "java" => Java, + "javascript" | "js" => JavaScript, + "json" => Json, + "kotlin" => Kotlin, + "latex" => Latex, + "lua" => Lua, + "make" => Makefile, + "markdown" => Markdown, + "mermaid" => Mermaid, + "nix" => Nix, + "nushell" | "nu" => Nushell, + "ocaml" => OCaml, + "perl" => Perl, + "php" => Php, + "protobuf" => Protobuf, + "puppet" => Puppet, + "python" => Python, + "r" => R, + "ruby" => Ruby, + "rust" => Rust, + "rust-script" => RustScript, + "scala" => Scala, + "shell" | "sh" => Shell, + "sql" => Sql, + "svelte" => Svelte, + "swift" => Swift, + "terraform" => Terraform, + "typescript" | "ts" => TypeScript, + "typst" => Typst, + "xml" => Xml, + "yaml" => Yaml, + "vue" => Vue, + "zig" => Zig, + "zsh" => Zsh, + other => Unknown(other.to_string()), + }; + Ok(language) + } +} + +/// Attributes for code snippets. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SnippetAttributes { + /// Whether the snippet is marked as executable. + pub(crate) execute: bool, + + /// Whether a snippet is marked to be auto rendered. + /// + /// An auto rendered snippet is transformed during parsing, leading to some visual + /// representation of it being shown rather than the original code. + pub(crate) auto_render: bool, + + /// Whether the snippet should show line numbers. + pub(crate) line_numbers: bool, + + /// The groups of lines to highlight. + pub(crate) highlight_groups: Vec, + + /// The width of the generated image. + /// + /// Only valid for +render snippets. + pub(crate) width: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct HighlightGroup(Vec); + +impl HighlightGroup { + pub(crate) fn new(highlights: Vec) -> Self { + Self(highlights) + } + + pub(crate) fn contains(&self, line_number: u16) -> bool { + for higlight in &self.0 { + match higlight { + Highlight::All => return true, + Highlight::Single(number) if number == &line_number => return true, + Highlight::Range(range) if range.contains(&line_number) => return true, + _ => continue, + }; + } + false + } +} + +/// A highlighted set of lines +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Highlight { + All, + Single(u16), + Range(Range), +} + #[cfg(test)] mod test { - use crate::markdown::elements::{SnippetAttributes, SnippetLanguage}; - use super::*; + use rstest::rstest; + use Highlight::*; + + fn parse_language(input: &str) -> SnippetLanguage { + let (language, _) = CodeBlockParser::parse_block_info(input).expect("parse failed"); + language + } + + fn try_parse_attributes(input: &str) -> Result { + let (_, attributes) = CodeBlockParser::parse_block_info(input)?; + Ok(attributes) + } + + fn parse_attributes(input: &str) -> SnippetAttributes { + try_parse_attributes(input).expect("parse failed") + } #[test] fn code_with_line_numbers() { @@ -220,4 +649,167 @@ mod test { assert_eq!(&line.prefix, &format!("{line_number} ")); } } + + #[test] + fn unknown_language() { + assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string())); + } + + #[test] + fn no_attributes() { + assert_eq!(parse_language("rust"), SnippetLanguage::Rust); + } + + #[test] + fn one_attribute() { + let attributes = parse_attributes("bash +exec"); + assert!(attributes.execute); + assert!(!attributes.line_numbers); + } + + #[test] + fn two_attributes() { + let attributes = parse_attributes("bash +exec +line_numbers"); + assert!(attributes.execute); + assert!(attributes.line_numbers); + } + + #[test] + fn invalid_attributes() { + CodeBlockParser::parse_block_info("bash +potato").unwrap_err(); + CodeBlockParser::parse_block_info("bash potato").unwrap_err(); + } + + #[rstest] + #[case::no_end("{")] + #[case::number_no_end("{42")] + #[case::comma_nothing("{42,")] + #[case::brace_comma("{,}")] + #[case::range_no_end("{42-")] + #[case::range_end("{42-}")] + #[case::too_many_ranges("{42-3-5}")] + #[case::range_comma("{42-,")] + #[case::too_large("{65536}")] + #[case::too_large_end("{1-65536}")] + fn invalid_line_highlights(#[case] input: &str) { + let input = format!("bash {input}"); + CodeBlockParser::parse_block_info(&input).expect_err("parsed successfully"); + } + + #[test] + fn highlight_none() { + let attributes = parse_attributes("bash {}"); + assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]); + } + + #[test] + fn highlight_specific_lines() { + let attributes = parse_attributes("bash { 1, 2 , 3 }"); + assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]); + } + + #[test] + fn highlight_line_range() { + let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }"); + assert_eq!( + attributes.highlight_groups, + &[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])] + ); + } + + #[test] + fn multiple_groups() { + let attributes = parse_attributes("bash {1-3,5 |6-9}"); + assert_eq!(attributes.highlight_groups.len(), 2); + assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)])); + assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)])); + } + + #[test] + fn parse_width() { + let attributes = parse_attributes("mermaid +width:50% +render"); + assert!(attributes.auto_render); + assert_eq!(attributes.width, Some(Percent(50))); + } + + #[test] + fn invalid_width() { + try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded"); + try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded"); + try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded"); + } + + #[test] + fn code_visible_lines_bash() { + let contents = r"echo 'hello world' +/// echo 'this was hidden' + +echo '/// is the prefix' +/// echo 'the prefix is /// ' +echo 'hello again' +" + .to_string(); + + let expected = vec!["echo 'hello world'", "", "echo '/// is the prefix'", "echo 'hello again'"]; + let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() }; + assert_eq!(expected, code.visible_lines().collect::>()); + } + + #[test] + fn code_visible_lines_rust() { + let contents = r##"# fn main() { +println!("Hello world"); +# // The prefix is # . +# } +"## + .to_string(); + + let expected = vec!["println!(\"Hello world\");"]; + let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; + assert_eq!(expected, code.visible_lines().collect::>()); + } + + #[test] + fn code_executable_contents_bash() { + let contents = r"echo 'hello world' +/// echo 'this was hidden' + +echo '/// is the prefix' +/// echo 'the prefix is /// ' +echo 'hello again' +" + .to_string(); + + let expected = r"echo 'hello world' +echo 'this was hidden' + +echo '/// is the prefix' +echo 'the prefix is /// ' +echo 'hello again' +" + .to_string(); + + let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() }; + assert_eq!(expected, code.executable_contents()); + } + + #[test] + fn code_executable_contents_rust() { + let contents = r##"# fn main() { +println!("Hello world"); +# // The prefix is # . +# } +"## + .to_string(); + + let expected = r##"fn main() { +println!("Hello world"); +// The prefix is # . +} +"## + .to_string(); + + let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; + assert_eq!(expected, code.executable_contents()); + } } diff --git a/src/processing/execution.rs b/src/processing/execution.rs index 6155c522..dbfe0778 100644 --- a/src/processing/execution.rs +++ b/src/processing/execution.rs @@ -3,10 +3,11 @@ use crate::{ ansi::AnsiSplitter, execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor}, markdown::{ - elements::{Snippet, Text, TextBlock}, + elements::{Text, TextBlock}, text::WeightedTextBlock, }, presentation::{AsRenderOperations, BlockLine, RenderAsync, RenderAsyncState, RenderOperation}, + processing::code::Snippet, render::properties::WindowSize, style::{Colors, TextStyle}, theme::{Alignment, ExecutionStatusBlockStyle}, diff --git a/src/render/highlighting.rs b/src/render/highlighting.rs index 93a012e9..cb91c7ac 100644 --- a/src/render/highlighting.rs +++ b/src/render/highlighting.rs @@ -1,5 +1,6 @@ use crate::{ - markdown::elements::{SnippetLanguage, Text, TextBlock}, + markdown::elements::{Text, TextBlock}, + processing::code::SnippetLanguage, style::{Color, TextStyle}, theme::CodeBlockStyle, }; From 39782d072e270dee5914d98f297da1f9e0e9c8ad Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Sat, 3 Aug 2024 15:09:55 -0700 Subject: [PATCH 2/2] feat: allow including external code snippets --- src/processing/builder.rs | 59 +++++++++++++++++++++++++++++++++----- src/processing/code.rs | 21 +++++++------- src/render/highlighting.rs | 1 + src/resource.rs | 23 +++++++++++++-- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/processing/builder.rs b/src/processing/builder.rs index 6b7b8f6f..88d114e1 100644 --- a/src/processing/builder.rs +++ b/src/processing/builder.rs @@ -1,5 +1,5 @@ use super::{ - code::{CodeBlockParseError, CodeBlockParser, CodeLine, Highlight, HighlightGroup, Snippet, SnippetLanguage}, + code::{CodeBlockParser, CodeLine, ExternalFile, Highlight, HighlightGroup, Snippet, SnippetLanguage}, execution::SnippetExecutionDisabledOperation, modals::KeyBindingsModalBuilder, }; @@ -717,12 +717,15 @@ impl<'a> PresentationBuilder<'a> { } fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> Result<(), BuildError> { - let snippet = CodeBlockParser::parse(info, code) - .map_err(|error| BuildError::InvalidCode { line: source_position.start.line + 1, error })?; + let mut snippet = CodeBlockParser::parse(info, code) + .map_err(|e| BuildError::InvalidCode { line: source_position.start.line + 1, error: e.to_string() })?; + if matches!(snippet.language, SnippetLanguage::File) { + snippet = self.load_external_snippet(snippet, source_position.clone())?; + } self.push_differ(snippet.contents.clone()); if snippet.attributes.auto_render { - return self.push_rendered_code(snippet); + return self.push_rendered_code(snippet, source_position); } let lines = CodePreparer::new(&self.theme).prepare(&snippet); let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0); @@ -748,14 +751,39 @@ impl<'a> PresentationBuilder<'a> { Ok(()) } - fn push_rendered_code(&mut self, code: Snippet) -> Result<(), BuildError> { + fn load_external_snippet( + &mut self, + mut code: Snippet, + source_position: SourcePosition, + ) -> Result { + // TODO clean up this repeated thing + let line = source_position.start.line + 1; + let file: ExternalFile = + serde_yaml::from_str(&code.contents).map_err(|e| BuildError::InvalidCode { line, error: e.to_string() })?; + let path = file.path; + let path_display = path.display(); + let contents = self + .resources + .external_snippet(&path) + .map_err(|e| BuildError::InvalidCode { line, error: format!("failed to load {path_display}: {e}") })?; + code.language = file.language; + code.contents = contents; + Ok(code) + } + + fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> Result<(), BuildError> { let Snippet { contents, language, attributes } = code; let error_holder = self.presentation_state.async_error_holder(); let request = match language { SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()), SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()), SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()), - _ => panic!("language {language:?} should not be renderable"), + _ => { + return Err(BuildError::InvalidCode { + line: source_position.start.line + 1, + error: format!("language {language:?} doesn't support rendering"), + })?; + } }; let operation = self.third_party.render(request, &self.theme, error_holder, self.slides.len() + 1, attributes.width)?; @@ -984,7 +1012,7 @@ pub enum BuildError { InvalidTheme(#[from] LoadThemeError), #[error("invalid code at line {line}: {error}")] - InvalidCode { line: usize, error: CodeBlockParseError }, + InvalidCode { line: usize, error: String }, #[error("invalid code highlighter theme: '{0}'")] InvalidCodeTheme(String), @@ -1659,4 +1687,21 @@ mod test { PresentationBuilder::parse_image_attributes(input, "", Default::default()).expect("failed to parse"); assert_eq!(attributes.width, expectation.map(Percent)); } + + #[test] + fn external_snippet() { + let temp = tempfile::NamedTempFile::new().expect("failed to create tempfile"); + let path = temp.path().file_name().expect("no file name").to_string_lossy(); + let code = format!( + r" +path: {path} +language: rust" + ); + let elements = vec![MarkdownElement::Snippet { + info: "file +line_numbers +exec".into(), + code, + source_position: Default::default(), + }]; + build_presentation(elements); + } } diff --git a/src/processing/code.rs b/src/processing/code.rs index 2882ec15..81c12d91 100644 --- a/src/processing/code.rs +++ b/src/processing/code.rs @@ -13,8 +13,9 @@ use crate::{ theme::{Alignment, CodeBlockStyle}, PresentationTheme, }; +use serde::Deserialize; use serde_with::DeserializeFromStr; -use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, rc::Rc, str::FromStr}; +use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr}; use strum::{EnumDiscriminants, EnumIter}; use unicode_width::UnicodeWidthStr; @@ -206,9 +207,6 @@ impl CodeBlockParser { fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> { let (language, input) = Self::parse_language(input); let attributes = Self::parse_attributes(input)?; - if attributes.auto_render && !language.supports_auto_render() { - return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering")); - } if attributes.width.is_some() && !attributes.auto_render { return Err(CodeBlockParseError::NotRenderSnippet("width")); } @@ -358,9 +356,6 @@ pub enum CodeBlockParseError { #[error("duplicate attribute: {0}")] DuplicateAttribute(&'static str), - #[error("language {0:?} does not support {1}")] - UnsupportedAttribute(SnippetLanguage, &'static str), - #[error("attribute {0} can only be set in +render blocks")] NotRenderSnippet(&'static str), } @@ -428,6 +423,7 @@ pub enum SnippetLanguage { Elixir, Elm, Erlang, + File, Fish, Go, Haskell, @@ -470,10 +466,6 @@ pub enum SnippetLanguage { } impl SnippetLanguage { - pub(crate) fn supports_auto_render(&self) -> bool { - matches!(self, Self::Latex | Self::Typst | Self::Mermaid) - } - pub(crate) fn hidden_line_prefix(&self) -> Option<&'static str> { use SnippetLanguage::*; match self { @@ -508,6 +500,7 @@ impl FromStr for SnippetLanguage { "elixir" => Elixir, "elm" => Elm, "erlang" => Erlang, + "file" => File, "fish" => Fish, "go" => Go, "haskell" => Haskell, @@ -605,6 +598,12 @@ pub(crate) enum Highlight { Range(Range), } +#[derive(Debug, Deserialize)] +pub(crate) struct ExternalFile { + pub(crate) path: PathBuf, + pub(crate) language: SnippetLanguage, +} + #[cfg(test)] mod test { use super::*; diff --git a/src/render/highlighting.rs b/src/render/highlighting.rs index cb91c7ac..8843fcb5 100644 --- a/src/render/highlighting.rs +++ b/src/render/highlighting.rs @@ -120,6 +120,7 @@ impl CodeHighlighter { Elixir => "ex", Elm => "elm", Erlang => "erl", + File => "txt", Fish => "fish", Go => "go", Haskell => "hs", diff --git a/src/resource.rs b/src/resource.rs index 513702da..9b6d42cc 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -4,7 +4,7 @@ use crate::{ }; use std::{ collections::HashMap, - io, + fs, io, path::{Path, PathBuf}, }; @@ -16,6 +16,7 @@ pub struct Resources { base_path: PathBuf, images: HashMap, themes: HashMap, + external_snippets: HashMap, image_registry: ImageRegistry, } @@ -24,7 +25,13 @@ impl Resources { /// /// Any relative paths will be assumed to be relative to the given base. pub fn new>(base_path: P, image_registry: ImageRegistry) -> Self { - Self { base_path: base_path.into(), images: Default::default(), themes: Default::default(), image_registry } + Self { + base_path: base_path.into(), + images: Default::default(), + themes: Default::default(), + external_snippets: Default::default(), + image_registry, + } } /// Get the image at the given path. @@ -51,6 +58,18 @@ impl Resources { Ok(theme) } + /// Get the external snippet at the given path. + pub(crate) fn external_snippet>(&mut self, path: P) -> io::Result { + let path = self.base_path.join(path); + if let Some(contents) = self.external_snippets.get(&path) { + return Ok(contents.clone()); + } + + let contents = fs::read_to_string(&path)?; + self.external_snippets.insert(path, contents.clone()); + Ok(contents) + } + /// Clears all resources. pub(crate) fn clear(&mut self) { self.images.clear();