more unit tests, better highlighting

This commit is contained in:
2025-05-13 20:22:25 -04:00
parent de6e0166c5
commit 6006244739
10 changed files with 223 additions and 28 deletions

View File

@@ -427,7 +427,7 @@ impl LexStream {
'$' if chars.peek() == Some(&'(') => { '$' if chars.peek() == Some(&'(') => {
pos += 2; pos += 2;
chars.next(); chars.next();
let mut paren_count = 0; let mut paren_count = 1;
let paren_pos = pos; let paren_pos = pos;
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
@@ -444,7 +444,7 @@ impl LexStream {
')' => { ')' => {
pos += 1; pos += 1;
paren_count -= 1; paren_count -= 1;
if paren_count >= 0 { if paren_count <= 0 {
break 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() => { '(' if self.next_is_cmd() => {
let mut paren_stack = vec!['(']; pos += 1;
let mut paren_count = 1;
let paren_pos = pos; let paren_pos = pos;
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
pos += ch.len_utf8();
match ch { match ch {
'\\' => { '\\' => {
pos += 1;
if let Some(next_ch) = chars.next() { if let Some(next_ch) = chars.next() {
pos += next_ch.len_utf8(); pos += next_ch.len_utf8();
} }
} }
'(' => { '(' => {
pos += 1; pos += 1;
paren_stack.push(ch); paren_count += 1;
} }
')' => { ')' => {
pos += 1; pos += 1;
paren_stack.pop(); paren_count -= 1;
if paren_stack.is_empty() { if paren_count <= 0 {
break break
} }
} }
_ => continue _ => pos += ch.len_utf8()
} }
} }
if !paren_stack.is_empty() && !self.flags.contains(LexFlags::LEX_UNFINISHED) { if paren_count != 0 && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
self.cursor = pos;
return Err( return Err(
ShErr::full( ShErr::full(
ShErrKind::ParseErr, ShErrKind::ParseErr,
@@ -502,6 +506,7 @@ impl LexStream {
subsh_tk.flags |= TkFlags::IS_SUBSH; subsh_tk.flags |= TkFlags::IS_SUBSH;
self.cursor = pos; self.cursor = pos;
self.set_next_is_cmd(true); self.set_next_is_cmd(true);
flog!(DEBUG, "returning subsh tk");
return Ok(subsh_tk) return Ok(subsh_tk)
} }
'{' if pos == self.cursor && self.next_is_cmd() => { '{' if pos == self.cursor && self.next_is_cmd() => {
@@ -666,6 +671,8 @@ impl LexStream {
impl Iterator for LexStream { impl Iterator for LexStream {
type Item = ShResult<Tk>; type Item = ShResult<Tk>;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
flog!(DEBUG,self.cursor);
flog!(DEBUG,self.source.len());
assert!(self.cursor <= self.source.len()); assert!(self.cursor <= self.source.len());
// We are at the end of the input // We are at the end of the input
if self.cursor == self.source.len() { if self.cursor == self.source.len() {

View File

@@ -64,7 +64,6 @@ impl ParsedSrc {
pub fn parse_src(&mut self) -> Result<(),Vec<ShErr>> { pub fn parse_src(&mut self) -> Result<(),Vec<ShErr>> {
let mut tokens = vec![]; let mut tokens = vec![];
for lex_result in LexStream::new(self.src.clone(), LexFlags::empty()) { for lex_result in LexStream::new(self.src.clone(), LexFlags::empty()) {
flog!(DEBUG, lex_result);
match lex_result { match lex_result {
Ok(token) => tokens.push(token), Ok(token) => tokens.push(token),
Err(error) => return Err(vec![error]) Err(error) => return Err(vec![error])
@@ -1417,7 +1416,6 @@ impl Iterator for ParseStream {
} }
} }
let result = self.parse_cmd_list(); let result = self.parse_cmd_list();
flog!(DEBUG, result);
match result { match result {
Ok(Some(node)) => { Ok(Some(node)) => {
Some(Ok(node)) Some(Ok(node))

View File

@@ -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 crate::builtin::BUILTINS;
use rustyline::highlight::Highlighter; use rustyline::highlight::Highlighter;
@@ -26,24 +26,48 @@ impl FernHighlighter {
pub fn highlight_subsh(&self, token: Tk) -> String { pub fn highlight_subsh(&self, token: Tk) -> String {
if token.flags.contains(TkFlags::IS_SUBSH) { if token.flags.contains(TkFlags::IS_SUBSH) {
let raw = token.as_str(); let raw = token.as_str();
let body = &raw[1..raw.len() - 1]; Self::hl_subsh_raw(raw)
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}")
} else if token.flags.contains(TkFlags::IS_CMDSUB) { } else if token.flags.contains(TkFlags::IS_CMDSUB) {
let raw = token.as_str(); let raw = token.as_str();
let body = &raw[2..raw.len() - 1]; Self::hl_cmdsub_raw(raw)
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}")
} else { } else {
unreachable!() 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 { pub fn hl_command(&self, token: Tk) -> String {
let raw = token.as_str(); let raw = token.as_str();
let paths = env::var("PATH") let paths = env::var("PATH")
@@ -78,11 +102,82 @@ impl FernHighlighter {
raw.styled(Style::Bold | Style::Red) 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 { pub fn hl_input(&self) -> String {
let mut output = self.input.clone(); let mut output = self.input.clone();
// TODO: properly implement highlighting for unfinished input // 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![]; let mut tokens = vec![];
for result in lex_results { for result in lex_results {
@@ -107,6 +202,9 @@ impl FernHighlighter {
if token.flags.contains(TkFlags::IS_CMD) { if token.flags.contains(TkFlags::IS_CMD) {
let styled = self.hl_command(token.clone()); let styled = self.hl_command(token.clone());
output.replace_range(token.span.start..token.span.end, &styled); 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 { } else {
output.replace_range(token.span.start..token.span.end, &token.to_string()); output.replace_range(token.span.start..token.span.end, &token.to_string());
} }
@@ -143,3 +241,8 @@ impl Highlighter for FernReadline {
true true
} }
} }
fn is_dquote(token: &Tk) -> bool {
let raw = token.as_str();
raw.starts_with('"')
}

27
src/tests/highlight.rs Normal file
View File

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

View File

@@ -24,6 +24,8 @@ pub mod expand;
pub mod term; pub mod term;
pub mod error; pub mod error;
pub mod getopt; pub mod getopt;
pub mod script;
pub mod highlight;
/// Unsafe to use outside of tests /// Unsafe to use outside of tests
pub fn get_nodes<F1>(input: &str, filter: F1) -> Vec<Node> pub fn get_nodes<F1>(input: &str, filter: F1) -> Vec<Node>

52
src/tests/script.rs Normal file
View File

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

View File

@@ -3,8 +3,8 @@ source: src/tests/error.rs
expression: err_fmt expression: err_fmt
--- ---
Parse Error - Unclosed subshell Parse Error - Unclosed subshell
-> [1;1] -> [1;2]
 |  |
1 | (foo 1 | (foo
 | ^  | ^
 |  |

1
test_scripts/cmdsub.sh Executable file
View File

@@ -0,0 +1 @@
echo "foo $(echo "$(echo "$(echo "$(echo Hello)")")") bar"

1
test_scripts/hello.sh Executable file
View File

@@ -0,0 +1 @@
echo Hello, World!

View File

@@ -0,0 +1,4 @@
echo foo
echo bar
echo biz
echo buzz