#[cfg(feature = "syntax-highlighting")]
use super::highlighting::bash_token_style;
#[cfg(feature = "syntax-highlighting")]
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
use super::{terminal_width, visual_width};
pub const GUTTER_OVERHEAD: usize = 2;
pub(super) fn wrap_text_at_width(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let text_width = visual_width(text);
if text_width <= max_width {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = visual_width(word);
if current_line.is_empty() {
current_line = word.to_string();
current_width = word_width;
} else {
let new_width = current_width + 1 + word_width;
if new_width <= max_width {
current_line.push(' ');
current_line.push_str(word);
current_width = new_width;
} else {
lines.push(current_line);
current_line = word.to_string();
current_width = word_width;
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub fn format_with_gutter(content: &str, max_width: Option<usize>) -> String {
let gutter = super::GUTTER;
let term_width = max_width.unwrap_or_else(terminal_width);
let available_width = term_width.saturating_sub(2);
let lines: Vec<String> = content
.lines()
.flat_map(|line| {
wrap_text_at_width(line, available_width)
.into_iter()
.map(|wrapped_line| format!("{gutter} {gutter:#} {wrapped_line}"))
})
.collect();
lines.join("\n")
}
pub fn wrap_styled_text(styled: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![styled.to_string()];
}
let leading_spaces = styled.chars().take_while(|c| *c == ' ').count();
let indent = " ".repeat(leading_spaces);
let content = &styled[leading_spaces..];
if content.is_empty() {
return vec![styled.to_string()];
}
let content_width = max_width.saturating_sub(leading_spaces);
if content_width < 10 {
return vec![styled.to_string()];
}
let options = wrap_ansi::WrapOptions::builder()
.trim_whitespace(false)
.build();
let wrapped = wrap_ansi::wrap_ansi(content, content_width, Some(options));
if wrapped.is_empty() {
return vec![String::new()];
}
let cleaned = wrapped
.replace("\x1b[39m", "") .replace("\x1b[49m", "");
let lines: Vec<_> = cleaned.lines().collect();
let mut result = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
let trimmed = line.strip_prefix("\x1b[0m").unwrap_or(line);
let with_dim = if i > 0 && trimmed.starts_with("\x1b[3") {
format!("\x1b[2m{trimmed}")
} else {
trimmed.to_owned()
};
result.push(format!("{indent}{with_dim}"));
}
result
}
#[cfg(feature = "syntax-highlighting")]
fn format_bash_with_gutter_impl(content: &str, width_override: Option<usize>) -> String {
let content = content.replace("\r\n", "\n");
let content = content.trim_end_matches('\n');
const TPL_OPEN: &str = "WTO";
const TPL_CLOSE: &str = "WTC";
let normalized = content
.replace("{{", TPL_OPEN)
.replace("}}", &format!("{TPL_CLOSE} "));
let content = normalized.as_str();
let gutter = super::GUTTER;
let reset = anstyle::Reset;
let dim = anstyle::Style::new().dimmed();
let string_style = bash_token_style("string").unwrap_or(dim);
let term_width = width_override.unwrap_or_else(terminal_width);
let available_width = term_width.saturating_sub(2);
let highlight_names = vec![
"function", "keyword", "string", "operator", "comment", "number", "variable", "constant", ];
let bash_language = tree_sitter_bash::LANGUAGE.into();
let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;
let mut config = HighlightConfiguration::new(
bash_language,
"bash",
bash_highlights,
"", "", )
.expect("tree-sitter-bash HIGHLIGHT_QUERY should be valid");
config.configure(&highlight_names);
let mut highlighter = Highlighter::new();
let highlights = highlighter
.highlight(&config, content.as_bytes(), None, |_| None)
.expect("highlighting valid UTF-8 should not fail");
let content_bytes = content.as_bytes();
let mut styled = format!("{dim}");
let mut pending_highlight: Option<usize> = None;
let mut active_style: Option<anstyle::Style> = None;
let mut ate_tpl_boundary = false;
let close_with_space = format!("{TPL_CLOSE} ");
for event in highlights {
match event.unwrap() {
HighlightEvent::Source { start, end } => {
if let Ok(text) = std::str::from_utf8(&content_bytes[start..end]) {
let text = if ate_tpl_boundary {
ate_tpl_boundary = false;
text.strip_prefix(' ').unwrap_or(text)
} else {
text
};
let is_placeholder = text == TPL_CLOSE || text == TPL_OPEN;
if let Some(idx) = pending_highlight.take()
&& let Some(name) = highlight_names.get(idx)
&& let Some(style) = bash_token_style(name)
&& !is_placeholder
{
styled.push_str(&format!("{reset}{style}"));
active_style = Some(style);
}
let has_placeholder = text.contains(TPL_OPEN) || text.contains(TPL_CLOSE);
let ends_with_close = text.ends_with(TPL_CLOSE);
let text = if !has_placeholder {
text.to_string()
} else if active_style == Some(string_style) {
text.replace(TPL_OPEN, "{{")
.replace(&close_with_space, "}}")
.replace(TPL_CLOSE, "}}")
} else {
let restore = format!("{reset}{}", active_style.unwrap_or(dim));
let close_repl = format!("{reset}{string_style}}}}}{restore}");
text.replace(TPL_OPEN, &format!("{reset}{string_style}{{{{{restore}"))
.replace(&close_with_space, &close_repl)
.replace(TPL_CLOSE, &close_repl)
};
if ends_with_close {
ate_tpl_boundary = true;
}
let style_restore = match active_style {
Some(style) => format!("{dim}{reset}{style}"),
None => format!("{dim}"),
};
styled.push_str(&text.replace('\n', &format!("\n{style_restore}")));
}
}
HighlightEvent::HighlightStart(idx) => {
pending_highlight = Some(idx.0);
}
HighlightEvent::HighlightEnd => {
pending_highlight = None;
active_style = None;
styled.push_str(&format!("{reset}{dim}"));
}
}
}
styled
.lines()
.flat_map(|line| wrap_styled_text(line, available_width))
.map(|wrapped| format!("{gutter} {gutter:#} {wrapped}{reset}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(feature = "syntax-highlighting")]
pub fn format_bash_with_gutter(content: &str) -> String {
format_bash_with_gutter_impl(content, None)
}
#[cfg(all(test, feature = "syntax-highlighting"))]
pub(crate) fn format_bash_with_gutter_at_width(content: &str, width: usize) -> String {
format_bash_with_gutter_impl(content, Some(width))
}
#[cfg(not(feature = "syntax-highlighting"))]
pub fn format_bash_with_gutter(content: &str) -> String {
format_with_gutter(content, None)
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
#[test]
fn test_wrap_text_at_width() {
assert_eq!(wrap_text_at_width("short text", 20), vec!["short text"]);
assert_eq!(
wrap_text_at_width("hello world foo bar", 10),
vec!["hello", "world foo", "bar"]
);
assert_eq!(
wrap_text_at_width("superlongword", 5),
vec!["superlongword"]
);
assert_eq!(
wrap_text_at_width("hello world", 20),
vec!["hello world"]
);
assert_eq!(wrap_text_at_width("hello world", 0), vec!["hello world"]);
assert_eq!(wrap_text_at_width("", 20), vec![""]);
}
#[test]
fn test_wrap_styled_text_edge_cases() {
assert_eq!(wrap_styled_text("short text", 20), vec!["short text"]);
assert_eq!(wrap_styled_text("hello world", 0), vec!["hello world"]);
assert_eq!(wrap_styled_text("", 20), vec![""]);
assert_eq!(
wrap_styled_text(" Print help", 80),
vec![" Print help"]
);
assert_eq!(wrap_styled_text(" ", 80), vec![" "]);
}
#[test]
fn test_wrap_styled_text_preserves_indent_on_wrap() {
let result = wrap_styled_text(
" This is a longer text that should wrap across multiple lines",
40,
);
assert!(result.len() > 1);
for line in &result {
assert!(
line.starts_with(" "),
"Line should start with 10 spaces: {:?}",
line
);
}
}
#[test]
fn test_format_with_gutter() {
assert_snapshot!(format_with_gutter("hello", Some(80)), @"[107m [0m hello");
assert_snapshot!(format_with_gutter("line1\nline2", Some(80)), @"
[107m [0m line1
[107m [0m line2
");
assert_snapshot!(format_with_gutter("", Some(80)), @"");
assert_snapshot!(format_with_gutter("word1 word2 word3 word4", Some(15)), @"
[107m [0m word1 word2
[107m [0m word3 word4
");
}
#[test]
fn test_wrap_styled_text_with_ansi() {
let styled = "\u{1b}[1mbold text\u{1b}[0m here";
let result = wrap_styled_text(styled, 100);
assert_snapshot!(result[0], @"[1mbold text[0m here");
}
#[test]
fn test_wrap_styled_text_strips_injected_resets() {
let styled = "some colored text";
let result = wrap_styled_text(styled, 50);
assert!(!result[0].contains("\u{1b}[39m"));
assert!(!result[0].contains("\u{1b}[49m"));
}
#[test]
fn test_wrap_styled_text_restores_dim_on_continuation() {
let dim = "\x1b[2m";
let green = "\x1b[32m";
let reset = "\x1b[0m";
let styled = format!(
"{dim}{green}This is a very long string that definitely needs to wrap across multiple lines{reset}"
);
let result = wrap_styled_text(&styled, 30);
assert!(result.len() > 1);
assert!(result[0].starts_with("\x1b[2m\x1b[32m"));
for line in result.iter().skip(1) {
assert!(line.starts_with("\x1b[2m\x1b[32m") || line.starts_with("\x1b[2m"));
}
}
#[test]
#[cfg(feature = "syntax-highlighting")]
fn test_format_bash_with_gutter() {
assert_snapshot!(format_bash_with_gutter_at_width("echo hello", 80), @"[107m [0m [2m[0m[2m[34mecho[0m[2m hello[0m");
assert_snapshot!(
format_bash_with_gutter_at_width("echo line1\necho line2", 80),
@"
[107m [0m [2m[0m[2m[34mecho[0m[2m line1[0m
[107m [0m [2m[0m[2m[34mecho[0m[2m line2[0m
"
);
assert_snapshot!(
format_bash_with_gutter_at_width("npm install && cargo build --release", 100),
@"[107m [0m [2m[0m[2m[34mnpm[0m[2m install [0m[2m[36m&&[0m[2m [0m[2m[34mcargo[0m[2m build [0m[2m[36m--release[0m[2m[0m"
);
}
#[test]
#[cfg(feature = "syntax-highlighting")]
fn test_unified_multiline_highlighting() {
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
let cmd = "echo 'line1' &&\necho 'line2' &&\necho 'line3'";
let highlight_names = vec![
"function", "keyword", "string", "operator", "comment", "number", "variable",
"constant",
];
let bash_language = tree_sitter_bash::LANGUAGE.into();
let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;
let mut config =
HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
config.configure(&highlight_names);
let mut highlighter = Highlighter::new();
let mut output = String::new();
let highlights = highlighter
.highlight(&config, cmd.as_bytes(), None, |_| None)
.unwrap();
for event in highlights {
match event.unwrap() {
HighlightEvent::Source { start, end } => {
output.push_str(&cmd[start..end]);
}
HighlightEvent::HighlightStart(idx) => {
output.push_str(&format!("[{}:", highlight_names[idx.0]));
}
HighlightEvent::HighlightEnd => {
output.push(']');
}
}
}
assert_snapshot!(output, @"
[function:echo] [string:'line1'] [operator:&&]
[function:echo] [string:'line2'] [operator:&&]
[function:echo] [string:'line3']
");
}
#[test]
#[cfg(feature = "syntax-highlighting")]
fn test_template_vars_inside_quotes_restored() {
use ansi_str::AnsiStr;
let cmd = r#"if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#;
let result = format_bash_with_gutter_at_width(cmd, 120);
assert_snapshot!(result.ansi_strip(), @r#" if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#);
}
#[test]
#[cfg(feature = "syntax-highlighting")]
fn test_highlighting_with_template_syntax() {
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
let cmd = "echo {{ branch }} && mkdir {{ path }}";
let highlight_names = vec!["function", "keyword", "string", "operator", "constant"];
let bash_language = tree_sitter_bash::LANGUAGE.into();
let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;
let mut config =
HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
config.configure(&highlight_names);
let mut highlighter = Highlighter::new();
let mut output = String::new();
let highlights = highlighter
.highlight(&config, cmd.as_bytes(), None, |_| None)
.unwrap();
for event in highlights {
match event.unwrap() {
HighlightEvent::Source { start, end } => {
output.push_str(&cmd[start..end]);
}
HighlightEvent::HighlightStart(idx) => {
output.push_str(&format!("[{}:", highlight_names[idx.0]));
}
HighlightEvent::HighlightEnd => {
output.push(']');
}
}
}
assert_snapshot!(output, @"[function:echo] {{ branch }} [operator:&&] [function:mkdir] {{ path }}");
}
}