use std::collections::HashSet;
use std::ops::Range;
use ansi_to_tui::IntoText;
use nu_plugin::EvaluatedCall;
use nu_protocol::shell_error::generic::GenericError;
use nu_protocol::{IntoSpanned, PipelineData, ShellError, Span as NuSpan, Value};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use skim::prelude::*;
use crate::command_context::CommandContext;
pub struct NuItem {
pub context: Arc<CommandContext>,
pub value: Value,
text: String,
display: Line<'static>,
}
impl NuItem {
pub fn new(context: Arc<CommandContext>, value: Value) -> Self {
let display = parse_ansi(
context
.format
.map(&context, &value)
.to_expanded_string(", ", &context.nu_config),
);
let text = display
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
Self {
context,
value,
text,
display,
}
}
}
fn parse_ansi(formatted: String) -> Line<'static> {
if !formatted.contains('\x1b') {
return Line::from(formatted);
}
let Ok(parsed) = formatted.as_bytes().into_text() else {
return Line::from(formatted);
};
let mut spans = Vec::new();
for (index, line) in parsed.lines.into_iter().enumerate() {
if index > 0 {
spans.push(Span::raw("\n"));
}
spans.extend(line.spans);
}
Line::from(spans)
}
fn merge_styles(left: Style, right: Style) -> Style {
fn merge_color(left: Option<Color>, right: Option<Color>) -> Option<Color> {
match (left, right) {
(Some(Color::Reset), _) => right,
(_, Some(Color::Reset)) => left,
_ => right.or(left),
}
}
Style {
fg: merge_color(left.fg, right.fg),
bg: merge_color(left.bg, right.bg),
underline_color: merge_color(left.underline_color, right.underline_color),
add_modifier: left.add_modifier | right.add_modifier,
..Style::default()
}
}
enum MatchedChars {
Indices(HashSet<usize>),
Range(Range<usize>),
}
impl MatchedChars {
fn new(matches: &Matches, text: &str) -> Option<Self> {
Some(match matches {
Matches::None => return None,
Matches::CharIndices(indices) => Self::Indices(indices.iter().copied().collect()),
Matches::CharRange(start, end) => Self::Range(*start..*end),
Matches::ByteRange(start, end) => {
let char_start = text
.get(..*start)
.map_or(0, |prefix| prefix.chars().count());
let char_end = text
.get(..*end)
.map_or_else(|| text.chars().count(), |prefix| prefix.chars().count());
Self::Range(char_start..char_end)
}
})
}
fn contains(&self, char_index: usize) -> bool {
match self {
Self::Indices(indices) => indices.contains(&char_index),
Self::Range(range) => range.contains(&char_index),
}
}
}
fn highlight(display: &Line<'_>, text: &str, context: &DisplayContext) -> Line<'static> {
let styled = |content: String, ansi_style: Style, is_match: bool| {
let style = merge_styles(context.base_style, ansi_style);
let style = if is_match {
merge_styles(style, context.matched_style)
} else {
style
};
Span::styled(content, style)
};
let Some(matched) = MatchedChars::new(&context.matches, text) else {
return display
.spans
.iter()
.map(|span| styled(span.content.to_string(), span.style, false))
.collect();
};
let mut spans = Vec::new();
let mut char_index = 0;
for span in &display.spans {
let mut run = String::new();
let mut run_is_match = false;
for ch in span.content.chars() {
let is_match = matched.contains(char_index);
if is_match != run_is_match && !run.is_empty() {
spans.push(styled(std::mem::take(&mut run), span.style, run_is_match));
}
run_is_match = is_match;
run.push(ch);
char_index += 1;
}
if !run.is_empty() {
spans.push(styled(run, span.style, run_is_match));
}
}
Line::from(spans)
}
impl SkimItem for NuItem {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.text)
}
fn display(&self, context: DisplayContext) -> Line<'_> {
highlight(&self.display, &self.text, &context)
}
fn preview(&self, context: PreviewContext) -> ItemPreview {
let preview_result = self.context.preview.map(&self.context, &self.value);
if let Ok(preview_result) = preview_result.coerce_string() {
return ItemPreview::AnsiText(preview_result);
}
let result = self.context.engine.find_decl("table").and_then(
#[allow(clippy::result_large_err)]
|table_decl| {
let table_decl = table_decl.ok_or_else(|| {
ShellError::Generic(GenericError::new(
"`table` decl is empty",
"`table` decl is empty",
NuSpan::unknown(),
))
})?;
let as_table = self.context.engine.call_decl(
table_decl,
EvaluatedCall::new(NuSpan::unknown()).with_named(
"width".into_spanned(NuSpan::unknown()),
Value::int(context.width as i64, NuSpan::unknown()),
),
PipelineData::Value((*preview_result).clone(), None),
true,
false,
)?;
let as_table_text = as_table.collect_string("\n", &self.context.nu_config)?;
Ok(as_table_text)
},
);
match result {
Ok(text) => ItemPreview::AnsiText(text),
Err(err) => ItemPreview::AnsiText(err.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: Style = Style::new().fg(Color::White).bg(Color::Black);
const MATCHED: Style = Style::new().fg(Color::Red);
fn context(matches: Matches) -> DisplayContext {
DisplayContext {
matches,
base_style: BASE,
matched_style: MATCHED,
..Default::default()
}
}
fn rendered(line: &Line<'_>) -> Vec<(String, Option<Color>, Option<Color>)> {
line.spans
.iter()
.map(|span| (span.content.to_string(), span.style.fg, span.style.bg))
.collect()
}
fn highlight_str(formatted: &str, matches: Matches) -> Line<'static> {
let display = parse_ansi(formatted.to_owned());
let text: String = display
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let highlighted = highlight(&display, &text, &context(matches));
assert_eq!(
highlighted
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
text,
);
highlighted
}
#[test]
fn plain_text_with_char_indices() {
let line = highlight_str("hello", Matches::CharIndices(vec![1, 2]));
assert_eq!(
rendered(&line),
[
("h".to_owned(), Some(Color::White), Some(Color::Black)),
("el".to_owned(), Some(Color::Red), Some(Color::Black)),
("lo".to_owned(), Some(Color::White), Some(Color::Black)),
],
);
}
#[test]
fn plain_text_with_char_range() {
let line = highlight_str("hello", Matches::CharRange(3, 5));
assert_eq!(
rendered(&line),
[
("hel".to_owned(), Some(Color::White), Some(Color::Black)),
("lo".to_owned(), Some(Color::Red), Some(Color::Black)),
],
);
}
#[test]
fn byte_range_is_converted_to_char_positions() {
let line = highlight_str("aébc", Matches::ByteRange(1, 4));
assert_eq!(
rendered(&line),
[
("a".to_owned(), Some(Color::White), Some(Color::Black)),
("éb".to_owned(), Some(Color::Red), Some(Color::Black)),
("c".to_owned(), Some(Color::White), Some(Color::Black)),
],
);
}
#[test]
fn no_matches_still_applies_the_base_style() {
let line = highlight_str("hello", Matches::None);
assert_eq!(
rendered(&line),
[("hello".to_owned(), Some(Color::White), Some(Color::Black))],
);
}
#[test]
fn ansi_colors_survive_and_matches_override_them() {
let line = highlight_str("ab\x1b[32mcd\x1b[0mef", Matches::CharIndices(vec![0, 2, 4]));
assert_eq!(
rendered(&line),
[
("a".to_owned(), Some(Color::Red), Some(Color::Black)),
("b".to_owned(), Some(Color::White), Some(Color::Black)),
("c".to_owned(), Some(Color::Red), Some(Color::Black)),
("d".to_owned(), Some(Color::Green), Some(Color::Black)),
("e".to_owned(), Some(Color::Red), Some(Color::Black)),
("f".to_owned(), Some(Color::White), Some(Color::Black)),
],
);
}
#[test]
fn newlines_are_preserved_across_ansi_parsing() {
let display = parse_ansi("a\x1b[32mb\nc\x1b[0md".to_owned());
let text: String = display
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(text, "ab\ncd");
}
#[test]
fn reset_does_not_override_the_other_style() {
let reset = Style::new().fg(Color::Reset).bg(Color::Reset);
assert_eq!(merge_styles(BASE, reset).fg, Some(Color::White));
assert_eq!(merge_styles(BASE, reset).bg, Some(Color::Black));
assert_eq!(merge_styles(reset, MATCHED).fg, Some(Color::Red));
}
}