diff --git a/src/dedup.rs b/src/extensions.rs similarity index 61% rename from src/dedup.rs rename to src/extensions.rs index b06fe0c..3aebfa2 100644 --- a/src/dedup.rs +++ b/src/extensions.rs @@ -18,3 +18,16 @@ impl Dedup for Vec { } } } + +/// Like `str::find`, but starts searching at `start`. +pub(crate) trait FindFrom { + fn find_from(&self, needle: &Self, start: usize) -> Option; +} + +impl FindFrom for str { + fn find_from(&self, needle: &Self, start: usize) -> Option { + self.get(start..) + .and_then(|s| s.find(needle)) + .map(|i| i + start) + } +} diff --git a/src/formatter.rs b/src/formatter.rs index 9859c1a..d1cfde4 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -1,108 +1,229 @@ //! Functions related to formatting and printing lines from a `Tokenizer`. -use std::io::{BufRead, Write}; - -use ansi_term::{ANSIString, ANSIStrings}; -use log::debug; - -use crate::config::Config; -use crate::error::TealdeerError::{self, WriteError}; -use crate::tokenizer::Tokenizer; +use crate::extensions::FindFrom; use crate::types::LineType; -fn highlight_command<'a>( - command: &'a str, - example_code: &'a str, - config: &Config, - parts: &mut Vec>, -) { - let mut code_part_end_pos = 0; - while let Some(command_start) = example_code[code_part_end_pos..].find(&command) { - let code_part = &example_code[code_part_end_pos..code_part_end_pos + command_start]; - parts.push(config.style.example_code.paint(code_part)); - if code_part_end_pos == 0 { - // Only highlight command names at the start of the line ... - parts.push(config.style.command_name.paint(command)); - } else { - let char_before_command = example_code - .chars() - .nth(code_part_end_pos + command_start - 1); - if char_before_command.filter(|c| c.is_whitespace()).is_some() { - // ... or when preceded by a whitespace character. - parts.push(config.style.command_name.paint(command)); - } else { - parts.push(config.style.example_code.paint(command)); - } - } +use log::debug; - code_part_end_pos += command_start + command.len(); - } - parts.push( - config - .style - .example_code - .paint(&example_code[code_part_end_pos..]), - ); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Represents a snippet from a page of a specific highlighting class. +pub enum PageSnippet<'a> { + CommandName(&'a str), + Variable(&'a str), + NormalCode(&'a str), + Description(&'a str), + Text(&'a str), + Linebreak, } -/// Format and highlight code examples including variables in {{ curly braces }}. -fn format_code(command: &str, text: &str, config: &Config) -> String { - let mut parts = Vec::new(); - for between_variables in text.split("}}") { - if let Some(variable_start) = between_variables.find("{{") { - let example_code = &between_variables[..variable_start]; - let example_variable = &between_variables[variable_start + 2..]; +impl<'a> PageSnippet<'a> { + pub fn is_empty(&self) -> bool { + use PageSnippet::*; - highlight_command(command, example_code, config, &mut parts); - parts.push(config.style.example_variable.paint(example_variable)); - } else { - highlight_command(command, between_variables, config, &mut parts); + match self { + CommandName(s) | Variable(s) | NormalCode(s) | Description(s) | Text(s) => s.is_empty(), + Linebreak => false, } } - - ANSIStrings(&parts).to_string() } -/// Print a token stream to an ANSI terminal. -pub fn print_lines( - writer: &mut T, - tokenizer: &mut Tokenizer, - config: &Config, -) -> Result<(), TealdeerError> +/// Parse the content of each line yielded by `lines` and yield `HighLightingSnippet`s accordingly. +pub fn highlight_lines( + lines: L, + process_snippet: &mut F, + keep_empty_lines: bool, +) -> Result<(), E> where - T: Write, - R: BufRead, + L: Iterator, + F: for<'snip> FnMut(PageSnippet<'snip>) -> Result<(), E>, { let mut command = String::new(); - while let Some(token) = tokenizer.next_token() { - match token { + for line in lines { + match line { LineType::Empty => { - if !config.display.compact { - writeln!(writer).map_err(|e| WriteError(e.to_string()))?; + if keep_empty_lines { + process_snippet(PageSnippet::Linebreak)?; } } LineType::Title(title) => { debug!("Ignoring title"); // This is safe as long as the parsed title is only the command, - // and tokenizer yields values in order of appearance. + // and the iterator yields values in order of appearance. command = title; debug!("Detected command name: {}", &command); } - LineType::Description(text) => { - writeln!(writer, " {}", config.style.description.paint(text)) - .map_err(|e| WriteError(e.to_string()))?; - } - LineType::ExampleText(text) => { - writeln!(writer, " {}", config.style.example_text.paint(text)) - .map_err(|e| WriteError(e.to_string()))?; - } + LineType::Description(text) => process_snippet(PageSnippet::Description(&text))?, + LineType::ExampleText(text) => process_snippet(PageSnippet::Text(&text))?, LineType::ExampleCode(text) => { - writeln!(writer, " {}", format_code(&command, &text, config)) - .map_err(|e| WriteError(e.to_string()))?; + process_snippet(PageSnippet::NormalCode(" "))?; + highlight_code(&command, &text, process_snippet)?; + process_snippet(PageSnippet::Linebreak)?; } + LineType::Other(text) => debug!("Unknown line type: {:?}", text), } } - writeln!(writer).map_err(|e| WriteError(e.to_string())) + process_snippet(PageSnippet::Linebreak)?; + Ok(()) +} + +/// Highlight code examples including user variables in {{ curly braces }}. +fn highlight_code<'a, E>( + command: &'a str, + text: &'a str, + process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>, +) -> Result<(), E> { + let variable_splits = text + .split("}}") + .map(|s| s.split_once("{{").unwrap_or((s, ""))); + for (code_segment, variable) in variable_splits { + highlight_code_segment(command, code_segment, process_snippet)?; + process_snippet(PageSnippet::Variable(variable))?; + } + Ok(()) +} + +/// Yields `NormalCode` and `CommandName` in alternating order according to the occurences of +/// `command_name` in `segment`. Variables are not detected here, see `highlight_code` +/// instead. +fn highlight_code_segment<'a, E>( + command_name: &'a str, + mut segment: &'a str, + process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>, +) -> Result<(), E> { + if !command_name.is_empty() { + let mut search_start = 0; + while let Some(match_start) = segment.find_from(command_name, search_start) { + let match_end = match_start + command_name.len(); + if is_freestanding_substring(segment, (match_start, match_end)) { + process_snippet(PageSnippet::NormalCode(&segment[..match_start]))?; + process_snippet(PageSnippet::CommandName(command_name))?; + segment = &segment[match_end..]; + search_start = 0; + } else { + search_start = segment[match_start..] + .char_indices() + .nth(1) + .map_or(segment.len(), |(i, _)| match_start + i); + } + } + } + process_snippet(PageSnippet::NormalCode(segment))?; + Ok(()) +} + +/// Checks whether the characters right before and after the substring (given by half-open index interval) are whitespace (if they exist). +fn is_freestanding_substring(surrouding: &str, substring: (usize, usize)) -> bool { + let (start, end) = substring; + // "okay" meaning or + let char_before_is_okay = surrouding[..start] + .chars() + .last() + .filter(|prev_char| !prev_char.is_whitespace()) + .is_none(); + let char_after_is_okay = surrouding[end..] + .chars() + .next() + .filter(|next_char| !next_char.is_whitespace()) + .is_none(); + char_before_is_okay && char_after_is_okay +} + +#[cfg(test)] +mod tests { + use super::*; + use PageSnippet::*; + + #[test] + fn test_is_freestanding_substring() { + assert!(is_freestanding_substring("I love tldr", (0, 1))); + assert!(is_freestanding_substring("I love tldr", (2, 6))); + assert!(is_freestanding_substring("I love tldr", (7, 11))); + + assert!(is_freestanding_substring("tldr", (0, 4))); + assert!(is_freestanding_substring("tldr ", (0, 4))); + assert!(is_freestanding_substring(" tldr", (1, 5))); + assert!(is_freestanding_substring(" tldr ", (1, 5))); + + assert!(!is_freestanding_substring("tldr", (1, 3))); + assert!(!is_freestanding_substring("tldr ", (1, 4))); + assert!(!is_freestanding_substring(" tldr", (1, 4))); + + assert!(is_freestanding_substring( + " épicé ", + (1, " épicé".len()) // note the missing trailing space + )); + assert!(!is_freestanding_substring( + " épicé ", + (1, " épic".len()) // note the missing trailing space and character + )); + } + + fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec> { + let mut yielded = Vec::new(); + let mut process_snippet = |snip: PageSnippet<'a>| { + if !snip.is_empty() { + yielded.push(snip); + } + Ok::<(), ()>(()) + }; + + highlight_code_segment(cmd, segment, &mut process_snippet) + .expect("highlight code segment failed"); + yielded + } + + #[test] + fn test_highlight_code_segment() { + assert!(run("make", "").is_empty()); + assert_eq!( + &run("make", "make all CC=clang -q"), + &[CommandName("make"), NormalCode(" all CC=clang -q")] + ); + assert_eq!( + &run("make", " make money --always-make"), + &[ + NormalCode(" "), + CommandName("make"), + NormalCode(" money --always-make") + ] + ); + assert_eq!( + &run("git commit", "git commit -m 'git commit'"), + &[CommandName("git commit"), NormalCode(" -m 'git commit'"),] + ); + } + + #[test] + fn test_i18n() { + assert_eq!( + &run("mäke", "mäke höhlenrätselbücher"), + &[CommandName("mäke"), NormalCode(" höhlenrätselbücher")] + ); + assert_eq!( + &run( + "Müll", + "1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich" + ), + &[ + NormalCode("1000 Gründe warum "), + CommandName("Müll"), + NormalCode(" heute größer ist als "), + CommandName("Müll"), + NormalCode(" früher, ärgerlich") + ] + ); + assert_eq!( + &run( + "übergang", + "die Zustandsübergangsfunktion übergang Änderungen", + ), + &[ + NormalCode("die Zustandsübergangsfunktion "), + CommandName("übergang"), + NormalCode(" Änderungen") + ], + ); + } } diff --git a/src/tokenizer.rs b/src/line_iterator.rs similarity index 53% rename from src/tokenizer.rs rename to src/line_iterator.rs index f9b3837..c1fbb0f 100644 --- a/src/tokenizer.rs +++ b/src/line_iterator.rs @@ -1,4 +1,4 @@ -//! Code to tokenize a `BufRead` instance into an iterator of `LineType`s. +//! Code to split a `BufRead` instance into an iterator of `LineType`s. use std::io::BufRead; @@ -15,13 +15,13 @@ pub enum TldrFormat { V2, } -/// A tokenizer is initialized with a `BufReader` instance that contains the -/// entire Tldr page. It then returns tokens as `Option`. +/// A `LineIterator` is initialized with a `BufReader` instance that contains the +/// entire Tldr page. It then implements `Iterator`. #[derive(Debug)] -pub struct Tokenizer { +pub struct LineIterator { /// An instance of `R: BufRead`. reader: R, - /// Whether the first line has already been tokenized or not. + /// Whether the first line has already been processed or not. first_line: bool, /// Buffer for the current line. Used internally. current_line: String, @@ -29,7 +29,7 @@ pub struct Tokenizer { format: TldrFormat, } -impl Tokenizer +impl LineIterator where R: BufRead, { @@ -41,38 +41,40 @@ where format: TldrFormat::Undecided, } } +} - pub fn next_token(&mut self) -> Option { +impl Iterator for LineIterator { + type Item = LineType; + + fn next(&mut self) -> Option { self.current_line.clear(); let bytes_read = self.reader.read_line(&mut self.current_line); match bytes_read { Ok(0) => None, Err(e) => { - warn!("Could not read line from token reader: {:?}", e); + warn!("Could not read line from reader: {:?}", e); None } Ok(_) => { // Handle new titles - if self.first_line && !self.current_line.starts_with('#') { - // It's the new format! Drop next line. - // (Hmm, is there a way to do this without an allocation?) - let mut devnull = String::new(); - if let Err(e) = self.reader.read_line(&mut devnull) { - warn!("Could not read line from token reader: {:?}", e); - return None; - } - self.first_line = false; - self.format = TldrFormat::V2; - return Some(LineType::Title(self.current_line.trim_end().to_string())); - } - if self.first_line { - // Clear `first_line` flag - self.first_line = false; - - // It's the old format. - self.format = TldrFormat::V1; + if self.current_line.starts_with('#') { + // It's the old format. + self.format = TldrFormat::V1; + } else { + // It's the new format! Drop next line. + // (Hmm, is there a way to do this without an allocation?) + let mut devnull = String::new(); + if let Err(e) = self.reader.read_line(&mut devnull) { + warn!("Could not read line from reader: {:?}", e); + return None; + } + self.first_line = false; + self.format = TldrFormat::V2; + return Some(LineType::Title(self.current_line.trim_end().to_string())); + } } + self.first_line = false; // Convert line to a `LineType` instance match self.format { @@ -87,26 +89,26 @@ where #[cfg(test)] mod test { - use super::Tokenizer; + use super::LineIterator; use crate::types::LineType; #[test] fn test_first_line_old_format() { let input = "# The Title\n\n"; - let mut tokenizer = Tokenizer::new(input.as_bytes()); - let title = tokenizer.next_token().unwrap(); + let mut lines = LineIterator::new(input.as_bytes()); + let title = lines.next().unwrap(); assert_eq!(title, LineType::Title("The Title".to_string())); - let empty = tokenizer.next_token().unwrap(); + let empty = lines.next().unwrap(); assert_eq!(empty, LineType::Empty); } #[test] fn test_first_line_new_format() { let input = "The Title\n=========\n\n"; - let mut tokenizer = Tokenizer::new(input.as_bytes()); - let title = tokenizer.next_token().unwrap(); + let mut lines = LineIterator::new(input.as_bytes()); + let title = lines.next().unwrap(); assert_eq!(title, LineType::Title("The Title".to_string())); - let empty = tokenizer.next_token().unwrap(); + let empty = lines.next().unwrap(); assert_eq!(empty, LineType::Empty); } } diff --git a/src/main.rs b/src/main.rs index 693b793..69011b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,16 +10,14 @@ #![deny(clippy::all)] #![warn(clippy::pedantic)] -#![allow(clippy::similar_names)] +#![allow(clippy::enum_glob_use)] #![allow(clippy::module_name_repetitions)] +#![allow(clippy::similar_names)] #![allow(clippy::too_many_lines)] -use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; +use std::env; use std::path::PathBuf; use std::process; -use std::{env, io::Write}; use ansi_term::{Color, Style}; use app_dirs::AppInfo; @@ -31,18 +29,18 @@ use serde_derive::Deserialize; mod cache; mod config; -mod dedup; mod error; +pub mod extensions; mod formatter; -mod tokenizer; +mod line_iterator; +mod output; mod types; use crate::cache::{Cache, PageLookupResult}; use crate::config::{get_config_dir, get_config_path, make_default_config, Config, MAX_CACHE_AGE}; -use crate::dedup::Dedup; use crate::error::TealdeerError::ConfigError; -use crate::formatter::print_lines; -use crate::tokenizer::Tokenizer; +use crate::extensions::Dedup; +use crate::output::print_page; use crate::types::{ColorOptions, OsType}; const NAME: &str = "tealdeer"; @@ -77,40 +75,6 @@ struct Args { flag_language: Option, } -/// Print page by path -fn print_page( - page: &PageLookupResult, - enable_markdown: bool, - config: &Config, -) -> Result<(), String> { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - - for path in page.paths() { - let file = File::open(path).map_err(|msg| format!("Could not open file: {}", msg))?; - let reader = BufReader::new(file); - - if enable_markdown { - // Print the raw markdown of the file. - for line in reader.lines() { - writeln!(handle, "{}", line.unwrap()) - .map_err(|_| "Could not write to stdout".to_string())?; - } - } else { - // Create tokenizer and print output - let mut tokenizer = Tokenizer::new(reader); - print_lines(&mut handle, &mut tokenizer, config) - .map_err(|e| format!("Could not write to stdout: {}", e.message()))?; - }; - } - - handle - .flush() - .map_err(|_| "Could not flush stdout".to_string())?; - - Ok(()) -} - /// Set up display pager #[cfg(not(target_os = "windows"))] fn configure_pager() { diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..25b9bc3 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,71 @@ +//! Functions for printing pages to the terminal + +use std::fs::File; +use std::io::{self, BufRead, BufReader, Write}; + +use crate::cache::PageLookupResult; +use crate::config::{Config, StyleConfig}; +use crate::error::TealdeerError::WriteError; +use crate::formatter::{highlight_lines, PageSnippet}; +use crate::line_iterator::LineIterator; + +/// Print page by path +pub fn print_page( + page: &PageLookupResult, + enable_markdown: bool, + config: &Config, +) -> Result<(), String> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + + for path in page.paths() { + let file = File::open(path).map_err(|msg| format!("Could not open file: {}", msg))?; + let reader = BufReader::new(file); + + if enable_markdown { + // Print the raw markdown of the file. + for line in reader.lines() { + writeln!(handle, "{}", line.unwrap()) + .map_err(|_| "Could not write to stdout".to_string())?; + } + } else { + let mut process_snippet = |snip: PageSnippet<'_>| { + if snip.is_empty() { + Ok(()) + } else { + print_snippet(&mut handle, snip, &config.style) + .map_err(|e| WriteError(e.to_string())) + } + }; + highlight_lines( + LineIterator::new(reader), + &mut process_snippet, + !config.display.compact, + ) + .map_err(|e| format!("Could not write to stdout: {}", e.message()))?; + }; + } + + handle + .flush() + .map_err(|_| "Could not flush stdout".to_string())?; + + Ok(()) +} + +fn print_snippet( + writer: &mut impl Write, + snip: PageSnippet<'_>, + style: &StyleConfig, +) -> Result<(), io::Error> { + use PageSnippet::*; + + match snip { + CommandName(s) => write!(writer, "{}", style.command_name.paint(s)), + Variable(s) => write!(writer, "{}", style.example_variable.paint(s)), + NormalCode(s) => write!(writer, "{}", style.example_code.paint(s)), + Description(s) => writeln!(writer, " {}", style.description.paint(s)), + Text(s) => writeln!(writer, " {}", style.example_text.paint(s)), + Linebreak => writeln!(writer), + } +} diff --git a/tests/chmod.ru.expected b/tests/chmod.ru.expected new file mode 100644 index 0000000..c7f92c2 --- /dev/null +++ b/tests/chmod.ru.expected @@ -0,0 +1,32 @@ + + Изменить права доступа файлу или папке. + Больше информации: . + + Дать [u]пользователю, который владеет файлом, права на его [x]исполнение: + + chmod u+x файл + + Дать права [u]пользователю права [r]чтения и [w]записи в файл/папку: + + chmod u+rw файл_или_папка + + Убрать права на [x]исполнение у [g]группы: + + chmod g-x файл + + Дать [a]всем пользователям права на [r]чтение и [x]исполенеие: + + chmod a+rx файл + + Дать [o]другим (не из группы владельцев файлом) такие же права как и у [g]группы: + + chmod o=g файл + + Убрать все права у [o]других: + + chmod o= файл + + Изменить права рекурсивно, дав [g]группе и [o]другим возможность [w]записи в папку: + + chmod -R g+w,o+w папка + diff --git a/tests/chmod.ru.md b/tests/chmod.ru.md new file mode 100644 index 0000000..4b92329 --- /dev/null +++ b/tests/chmod.ru.md @@ -0,0 +1,32 @@ +# chmod + +> Изменить права доступа файлу или папке. +> Больше информации: . + +- Дать [u]пользователю, который владеет файлом, права на его [x]исполнение: + +`chmod u+x {{файл}}` + +- Дать права [u]пользователю права [r]чтения и [w]записи в файл/папку: + +`chmod u+rw {{файл_или_папка}}` + +- Убрать права на [x]исполнение у [g]группы: + +`chmod g-x {{файл}}` + +- Дать [a]всем пользователям права на [r]чтение и [x]исполенеие: + +`chmod a+rx {{файл}}` + +- Дать [o]другим (не из группы владельцев файлом) такие же права как и у [g]группы: + +`chmod o=g {{файл}}` + +- Убрать все права у [o]других: + +`chmod o= {{файл}}` + +- Изменить права рекурсивно, дав [g]группе и [o]другим возможность [w]записи в папку: + +`chmod -R g+w,o+w {{папка}}` diff --git a/tests/inkscape-default.expected b/tests/inkscape-default.expected index 7350de7..da909f2 100644 --- a/tests/inkscape-default.expected +++ b/tests/inkscape-default.expected @@ -4,29 +4,29 @@ Open an SVG file in the Inkscape GUI: - inkscape filename.svg + inkscape filename.svg Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): - inkscape filename.svg -e filename.png + inkscape filename.svg -e filename.png Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): - inkscape filename.svg -e filename.png -w 600 -h 400 + inkscape filename.svg -e filename.png -w 600 -h 400 Export a single object, given its ID, into a bitmap: - inkscape filename.svg -i id -e object.png + inkscape filename.svg -i id -e object.png Export an SVG document to PDF, converting all texts to paths: - inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path + inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: - inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit + inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit Some invalid command just to test the correct highlighting of the command name: - inkscape --use-inkscape=v3.0 file + inkscape --use-inkscape=v3.0 file diff --git a/tests/lib.rs b/tests/lib.rs index 686fcaf..c0db85e 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -406,6 +406,16 @@ fn test_rendering_color_never() { ); } +#[test] +fn test_rendering_i18n() { + _test_correct_rendering( + include_str!("chmod.ru.md"), + "chmod.ru.md", + include_str!("chmod.ru.expected"), + "always", + ); +} + /// An end-to-end integration test for rendering with custom syntax config. #[test] fn test_correct_rendering_with_config() {