From 6006244739b4775cbb13fb841eb004148eaa5b3b Mon Sep 17 00:00:00 2001 From: Kyler Clay Date: Tue, 13 May 2025 20:22:25 -0400 Subject: [PATCH] more unit tests, better highlighting --- src/parse/lex.rs | 27 ++-- src/parse/mod.rs | 2 - src/prompt/highlight.rs | 131 ++++++++++++++++-- src/tests/highlight.rs | 27 ++++ src/tests/mod.rs | 2 + src/tests/script.rs | 52 +++++++ .../fern__tests__error__unclosed_subsh.snap | 4 +- test_scripts/cmdsub.sh | 1 + test_scripts/hello.sh | 1 + test_scripts/multiline.sh | 4 + 10 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 src/tests/highlight.rs create mode 100644 src/tests/script.rs create mode 100755 test_scripts/cmdsub.sh create mode 100755 test_scripts/hello.sh create mode 100644 test_scripts/multiline.sh diff --git a/src/parse/lex.rs b/src/parse/lex.rs index 15dab8c..d5bbdcd 100644 --- a/src/parse/lex.rs +++ b/src/parse/lex.rs @@ -427,7 +427,7 @@ impl LexStream { '$' if chars.peek() == Some(&'(') => { pos += 2; chars.next(); - let mut paren_count = 0; + let mut paren_count = 1; let paren_pos = pos; while let Some(ch) = chars.next() { match ch { @@ -444,7 +444,7 @@ impl LexStream { ')' => { pos += 1; paren_count -= 1; - if paren_count >= 0 { + if paren_count <= 0 { break } } @@ -461,34 +461,38 @@ impl LexStream { ) ) } + let mut cmdsub_tk = self.get_token(self.cursor..pos, TkRule::Str); + cmdsub_tk.flags |= TkFlags::IS_CMDSUB; + self.cursor = pos; + return Ok(cmdsub_tk) } '(' if self.next_is_cmd() => { - let mut paren_stack = vec!['(']; + pos += 1; + let mut paren_count = 1; let paren_pos = pos; while let Some(ch) = chars.next() { - pos += ch.len_utf8(); match ch { '\\' => { + pos += 1; if let Some(next_ch) = chars.next() { pos += next_ch.len_utf8(); } } '(' => { pos += 1; - paren_stack.push(ch); + paren_count += 1; } ')' => { pos += 1; - paren_stack.pop(); - if paren_stack.is_empty() { + paren_count -= 1; + if paren_count <= 0 { break } } - _ => continue + _ => pos += ch.len_utf8() } } - if !paren_stack.is_empty() && !self.flags.contains(LexFlags::LEX_UNFINISHED) { - self.cursor = pos; + if paren_count != 0 && !self.flags.contains(LexFlags::LEX_UNFINISHED) { return Err( ShErr::full( ShErrKind::ParseErr, @@ -502,6 +506,7 @@ impl LexStream { subsh_tk.flags |= TkFlags::IS_SUBSH; self.cursor = pos; self.set_next_is_cmd(true); + flog!(DEBUG, "returning subsh tk"); return Ok(subsh_tk) } '{' if pos == self.cursor && self.next_is_cmd() => { @@ -666,6 +671,8 @@ impl LexStream { impl Iterator for LexStream { type Item = ShResult; fn next(&mut self) -> Option { + flog!(DEBUG,self.cursor); + flog!(DEBUG,self.source.len()); assert!(self.cursor <= self.source.len()); // We are at the end of the input if self.cursor == self.source.len() { diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 40e28e5..0fe9f50 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -64,7 +64,6 @@ impl ParsedSrc { pub fn parse_src(&mut self) -> Result<(),Vec> { let mut tokens = vec![]; for lex_result in LexStream::new(self.src.clone(), LexFlags::empty()) { - flog!(DEBUG, lex_result); match lex_result { Ok(token) => tokens.push(token), Err(error) => return Err(vec![error]) @@ -1417,7 +1416,6 @@ impl Iterator for ParseStream { } } let result = self.parse_cmd_list(); - flog!(DEBUG, result); match result { Ok(Some(node)) => { Some(Ok(node)) diff --git a/src/prompt/highlight.rs b/src/prompt/highlight.rs index a19dadf..82f6ff5 100644 --- a/src/prompt/highlight.rs +++ b/src/prompt/highlight.rs @@ -1,4 +1,4 @@ -use std::{env, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, sync::Arc}; +use std::{env, mem, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, sync::Arc}; use crate::builtin::BUILTINS; use rustyline::highlight::Highlighter; @@ -26,24 +26,48 @@ impl FernHighlighter { pub fn highlight_subsh(&self, token: Tk) -> String { if token.flags.contains(TkFlags::IS_SUBSH) { let raw = token.as_str(); - let body = &raw[1..raw.len() - 1]; - let sub_hl = FernHighlighter::new(body.to_string()); - let body_highlighted = sub_hl.hl_input(); - let open_paren = "(".styled(Style::BrightBlue); - let close_paren = ")".styled(Style::BrightBlue); - format!("{open_paren}{body_highlighted}{close_paren}") + Self::hl_subsh_raw(raw) } else if token.flags.contains(TkFlags::IS_CMDSUB) { let raw = token.as_str(); - let body = &raw[2..raw.len() - 1]; - let sub_hl = FernHighlighter::new(body.to_string()); - let body_highlighted = sub_hl.hl_input(); - let dollar_paren = "$(".styled(Style::BrightBlue); - let close_paren = ")".styled(Style::BrightBlue); - format!("{dollar_paren}{body_highlighted}{close_paren}") + Self::hl_cmdsub_raw(raw) } else { unreachable!() } } + pub fn hl_subsh_raw(raw: &str) -> String { + let mut body = &raw[1..]; + let mut closed = false; + if body.ends_with(')') { + body = &body[..body.len() - 1]; + closed = true; + } + let sub_hl = FernHighlighter::new(body.to_string()); + let body_highlighted = sub_hl.hl_input(); + let open_paren = "(".styled(Style::BrightBlue); + let close_paren = ")".styled(Style::BrightBlue); + let mut result = format!("{open_paren}{body_highlighted}"); + if closed { + result.push_str(&close_paren); + } + result + } + pub fn hl_cmdsub_raw(raw: &str) -> String { + let mut body = &raw[2..]; + let mut closed = false; + if body.ends_with(')') { + body = &body[..body.len() - 1]; + closed = true; + } + let sub_hl = FernHighlighter::new(body.to_string()); + let body_highlighted = sub_hl.hl_input(); + let dollar_paren = "$(".styled(Style::BrightBlue); + let close_paren = ")".styled(Style::BrightBlue); + let mut result = format!("{dollar_paren}{body_highlighted}"); + if closed { + result.push_str(&close_paren); + } + result + } pub fn hl_command(&self, token: Tk) -> String { let raw = token.as_str(); let paths = env::var("PATH") @@ -78,11 +102,82 @@ impl FernHighlighter { raw.styled(Style::Bold | Style::Red) } } + pub fn hl_dquote(&self, token: Tk) -> String { + let raw = token.as_str(); + let mut chars = raw.chars().peekable(); + const YELLOW: &str = "\x1b[33m"; + const RESET: &str = "\x1b[0m"; + let mut result = String::new(); + let mut dquote_count = 0; + + result.push_str(YELLOW); + + while let Some(ch) = chars.next() { + match ch { + '\\' => { + result.push(ch); + if let Some(ch) = chars.next() { + result.push(ch); + } + } + '"' => { + dquote_count += 1; + result.push(ch); + if dquote_count >= 2 { + break + } + } + '$' if chars.peek() == Some(&'(') => { + let mut raw_cmd_sub = String::new(); + raw_cmd_sub.push(ch); + raw_cmd_sub.push(chars.next().unwrap()); + let mut cmdsub_count = 1; + + while let Some(cmdsub_ch) = chars.next() { + match cmdsub_ch { + '\\' => { + raw_cmd_sub.push(cmdsub_ch); + if let Some(ch) = chars.next() { + raw_cmd_sub.push(ch); + } + } + '$' if chars.peek() == Some(&'(') => { + cmdsub_count += 1; + raw_cmd_sub.push(cmdsub_ch); + raw_cmd_sub.push(chars.next().unwrap()); + } + ')' => { + cmdsub_count -= 1; + raw_cmd_sub.push(cmdsub_ch); + if cmdsub_count <= 0 { + let styled = Self::hl_cmdsub_raw(&mem::take(&mut raw_cmd_sub)); + result.push_str(&styled); + result.push_str(YELLOW); + break + } + } + _ => raw_cmd_sub.push(cmdsub_ch) + } + } + if !raw_cmd_sub.is_empty() { + let styled = Self::hl_cmdsub_raw(&mem::take(&mut raw_cmd_sub)); + result.push_str(&styled); + result.push_str(YELLOW); + } + } + _ => result.push(ch) + } + } + + result.push_str(RESET); + + result + } pub fn hl_input(&self) -> String { let mut output = self.input.clone(); // TODO: properly implement highlighting for unfinished input - let lex_results = LexStream::new(Arc::new(output.clone()), LexFlags::empty()); + let lex_results = LexStream::new(Arc::new(output.clone()), LexFlags::LEX_UNFINISHED); let mut tokens = vec![]; for result in lex_results { @@ -107,6 +202,9 @@ impl FernHighlighter { if token.flags.contains(TkFlags::IS_CMD) { let styled = self.hl_command(token.clone()); output.replace_range(token.span.start..token.span.end, &styled); + } else if is_dquote(&token) { + let styled = self.hl_dquote(token.clone()); + output.replace_range(token.span.start..token.span.end, &styled); } else { output.replace_range(token.span.start..token.span.end, &token.to_string()); } @@ -143,3 +241,8 @@ impl Highlighter for FernReadline { true } } + +fn is_dquote(token: &Tk) -> bool { + let raw = token.as_str(); + raw.starts_with('"') +} diff --git a/src/tests/highlight.rs b/src/tests/highlight.rs new file mode 100644 index 0000000..71f9653 --- /dev/null +++ b/src/tests/highlight.rs @@ -0,0 +1,27 @@ + +use insta::assert_snapshot; + +use crate::prompt::highlight::FernHighlighter; + +use super::super::*; + +#[test] +fn highlight_simple() { + let line = "echo foo bar"; + let styled = FernHighlighter::new(line.to_string()).hl_input(); + assert_snapshot!(styled) +} + +#[test] +fn highlight_cmd_sub() { + let line = "echo foo $(echo bar)"; + let styled = FernHighlighter::new(line.to_string()).hl_input(); + assert_snapshot!(styled) +} + +#[test] +fn highlight_cmd_sub_in_dquotes() { + let line = "echo \"foo $(echo bar) biz\""; + let styled = FernHighlighter::new(line.to_string()).hl_input(); + assert_snapshot!(styled) +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index a7764b0..2c5083b 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -24,6 +24,8 @@ pub mod expand; pub mod term; pub mod error; pub mod getopt; +pub mod script; +pub mod highlight; /// Unsafe to use outside of tests pub fn get_nodes(input: &str, filter: F1) -> Vec diff --git a/src/tests/script.rs b/src/tests/script.rs new file mode 100644 index 0000000..d91ba2f --- /dev/null +++ b/src/tests/script.rs @@ -0,0 +1,52 @@ +use std::process::{self, Output}; + +use pretty_assertions::assert_eq; + +use super::super::*; +fn get_script_output(name: &str, args: &[&str]) -> Output { + // Resolve the path to the fern binary. + // Do not question me. + let mut fern_path = env::current_exe() + .expect("Failed to get test executable"); // The path to the test executable + fern_path.pop(); // Hocus pocus + fern_path.pop(); + fern_path.push("fern"); // Abra Kadabra + + if !fern_path.is_file() { + fern_path.pop(); + fern_path.pop(); + fern_path.push("release"); + fern_path.push("fern"); + } + + if !fern_path.is_file() { + panic!("where the hell is the binary") + } + + process::Command::new(fern_path) // Alakazam + .arg(name) + .args(args) + .output() + .expect("Failed to run script") +} +#[test] +fn script_hello_world() { + let output = get_script_output("./test_scripts/hello.sh", &[]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "Hello, World!") +} +#[test] +fn script_cmdsub() { + let output = get_script_output("./test_scripts/cmdsub.sh", &[]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "foo Hello bar") +} +#[test] +fn script_multiline() { + let output = get_script_output("./test_scripts/multiline.sh", &[]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "foo\nbar\nbiz\nbuzz") +} diff --git a/src/tests/snapshots/fern__tests__error__unclosed_subsh.snap b/src/tests/snapshots/fern__tests__error__unclosed_subsh.snap index a5f3979..79d7af3 100644 --- a/src/tests/snapshots/fern__tests__error__unclosed_subsh.snap +++ b/src/tests/snapshots/fern__tests__error__unclosed_subsh.snap @@ -3,8 +3,8 @@ source: src/tests/error.rs expression: err_fmt --- Parse Error - Unclosed subshell - -> [1;1] + -> [1;2]  | 1 | (foo - | ^ + | ^  | diff --git a/test_scripts/cmdsub.sh b/test_scripts/cmdsub.sh new file mode 100755 index 0000000..5fcae70 --- /dev/null +++ b/test_scripts/cmdsub.sh @@ -0,0 +1 @@ +echo "foo $(echo "$(echo "$(echo "$(echo Hello)")")") bar" diff --git a/test_scripts/hello.sh b/test_scripts/hello.sh new file mode 100755 index 0000000..8fbf7fa --- /dev/null +++ b/test_scripts/hello.sh @@ -0,0 +1 @@ +echo Hello, World! diff --git a/test_scripts/multiline.sh b/test_scripts/multiline.sh new file mode 100644 index 0000000..0e88b53 --- /dev/null +++ b/test_scripts/multiline.sh @@ -0,0 +1,4 @@ +echo foo +echo bar +echo biz +echo buzz