use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use std::collections::HashSet;
use super::theme::Theme;
#[derive(Default)]
pub(crate) struct HlCtx {
pub error_line: Option<usize>,
pub collection_resolves: bool,
pub loaded_envs: HashSet<String>,
pub request_names: HashSet<String>,
}
const KEYWORDS: &[&str] = &[
"REQUEST",
"REPORT",
"FOR",
"IN",
"FILES",
"FOLDERS",
"ENVS",
"TUPLES",
"FROM",
"ZIP",
"CONCAT",
"MATCH",
"WITH",
"LIST",
"BASELINE",
"COMPARISON",
"END",
"PARALLEL",
"AS",
"RESPONSE",
"RAW",
"PRETTY",
"SHOW",
"HIDE",
"JOIN",
"ON",
];
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_keyword(word: &str) -> bool {
let upper = word.to_ascii_uppercase();
KEYWORDS.contains(&upper.as_str())
}
pub(crate) fn highlight_line(line: &str, ctx: &HlCtx, th: &Theme) -> Vec<Span<'static>> {
if line.trim_start().starts_with('#') {
return highlight_comment(line, ctx, th);
}
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut env_names = false;
let mut expect_request_name = false;
let mut i = 0;
while i < n {
let c = chars[i];
if c == '{' && i + 1 < n && chars[i + 1] == '{' {
let start = i;
i += 2;
while i < n && !(chars[i] == '}' && i + 1 < n && chars[i + 1] == '}') {
i += 1;
}
i = (i + 2).min(n); let text: String = chars[start..i].iter().collect();
spans.push(Span::styled(text, Style::default().fg(th.subst)));
continue;
}
if c == '"' {
let start = i;
i += 1;
while i < n {
if chars[i] == '\\' && i + 1 < n {
i += 2;
continue;
}
if chars[i] == '"' {
i += 1;
break;
}
i += 1;
}
if env_names {
let name = unquote_literal(&chars[start..i]);
let colour = if ctx.loaded_envs.contains(&name) {
th.ok
} else {
th.pending
};
spans.push(Span::styled(
chars[start..i].iter().collect::<String>(),
Style::default().fg(colour),
));
} else if expect_request_name {
expect_request_name = false;
let name = unquote_literal(&chars[start..i]);
let colour = if ctx.request_names.contains(&name) {
th.ok
} else {
th.pending
};
spans.push(Span::styled(
chars[start..i].iter().collect::<String>(),
Style::default().fg(colour),
));
} else {
push_string_spans(&mut spans, &chars[start..i], th);
}
continue;
}
if is_word_char(c) {
let start = i;
while i < n && is_word_char(chars[i]) {
i += 1;
}
let word: String = chars[start..i].iter().collect();
let style = if is_keyword(&word) {
let upper = word.to_ascii_uppercase();
if matches!(upper.as_str(), "ENVS" | "BASELINE" | "COMPARISON") {
env_names = true;
}
expect_request_name = upper == "REQUEST";
Style::default().fg(th.accent).add_modifier(Modifier::BOLD)
} else if expect_request_name {
expect_request_name = false;
let colour = if ctx.request_names.contains(&word) {
th.ok
} else {
th.pending
};
Style::default().fg(colour)
} else {
Style::default().fg(th.text)
};
spans.push(Span::styled(word, style));
continue;
}
let start = i;
while i < n {
let ch = chars[i];
if is_word_char(ch) || ch == '"' || (ch == '{' && i + 1 < n && chars[i + 1] == '{') {
break;
}
i += 1;
}
let text: String = chars[start..i].iter().collect();
spans.push(Span::styled(text, Style::default().fg(th.text)));
}
if spans.is_empty() {
spans.push(Span::raw(String::new()));
}
spans
}
fn push_string_spans(spans: &mut Vec<Span<'static>>, s: &[char], th: &Theme) {
let n = s.len();
let mut i = 0;
let mut plain_start = 0;
while i < n {
if s[i] == '{' && i + 1 < n && s[i + 1] == '{' {
if i > plain_start {
spans.push(Span::styled(
s[plain_start..i].iter().collect::<String>(),
Style::default().fg(th.text),
));
}
let start = i;
i += 2;
while i < n && !(s[i] == '}' && i + 1 < n && s[i + 1] == '}') {
i += 1;
}
i = (i + 2).min(n);
spans.push(Span::styled(
s[start..i].iter().collect::<String>(),
Style::default().fg(th.subst),
));
plain_start = i;
continue;
}
i += 1;
}
if plain_start < n {
spans.push(Span::styled(
s[plain_start..n].iter().collect::<String>(),
Style::default().fg(th.text),
));
}
}
fn highlight_comment(line: &str, ctx: &HlCtx, th: &Theme) -> Vec<Span<'static>> {
let dim = Style::default().fg(th.dim);
let whole = || vec![Span::styled(line.to_string(), dim)];
let Some(hash) = line.find('#') else {
return whole();
};
let Some(rel_colon) = line[hash + 1..].find(':') else {
return whole();
};
let colon = hash + 1 + rel_colon;
let key = line[hash + 1..colon].trim().to_ascii_lowercase();
let value_full = &line[colon + 1..];
let value = value_full.trim();
let colour = match key.as_str() {
"collection" if ctx.collection_resolves => th.ok,
"collection" => th.pending,
"environment" if ctx.loaded_envs.contains(value) => th.ok,
"environment" => th.pending,
_ => return whole(),
};
if value.is_empty() {
return whole();
}
let ws_len = value_full.len() - value_full.trim_start().len();
let value_start = colon + 1 + ws_len;
let value_end = value_start + value.len();
let mut spans = vec![Span::styled(line[..value_start].to_string(), dim)];
spans.push(Span::styled(
value.to_string(),
Style::default().fg(colour).add_modifier(Modifier::BOLD),
));
if value_end < line.len() {
spans.push(Span::styled(line[value_end..].to_string(), dim));
}
spans
}
fn unquote_literal(s: &[char]) -> String {
let inner = s
.strip_prefix(&['"'])
.unwrap_or(s)
.strip_suffix(&['"'])
.unwrap_or(s);
let mut out = String::with_capacity(inner.len());
let mut it = inner.iter();
while let Some(&c) = it.next() {
if c == '\\' {
if let Some(&next) = it.next() {
out.push(next);
}
} else {
out.push(c);
}
}
out
}
fn mark_error(spans: &mut [Span<'static>], th: &Theme) {
for sp in spans {
sp.style = sp.style.fg(th.err).add_modifier(Modifier::UNDERLINED);
}
}
pub(crate) fn highlight_row(row: usize, line: &str, ctx: &HlCtx, th: &Theme) -> Vec<Span<'static>> {
let mut spans = highlight_line(line, ctx, th);
if ctx.error_line == Some(row + 1) {
mark_error(&mut spans, th);
}
spans
}
pub(crate) fn highlight_source(text: &str, ctx: &HlCtx, th: &Theme) -> Vec<Line<'static>> {
text.split('\n')
.enumerate()
.map(|(i, line)| Line::from(highlight_row(i, line, ctx, th)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Language;
use crate::tui::theme::theme;
fn th() -> Theme {
theme(&Language::English)
}
fn ctx() -> HlCtx {
HlCtx::default()
}
fn assert_tiles(line: &str, spans: &[Span<'static>]) {
let total: usize = spans.iter().map(|s| s.content.chars().count()).sum();
assert_eq!(total, line.chars().count(), "spans must tile {line:?}");
}
#[test]
fn keywords_are_accented_and_bold() {
let th = th();
let line = "REPORT REQUEST upload";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let report = spans.iter().find(|s| s.content == "REPORT").unwrap();
assert_eq!(report.style.fg, Some(th.accent));
assert!(report.style.add_modifier.contains(Modifier::BOLD));
let ident = spans.iter().find(|s| s.content == "upload").unwrap();
assert_ne!(ident.style.fg, Some(th.accent));
assert!(!ident.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn keywords_match_case_insensitively() {
let th = th();
let spans = highlight_line("report request x", &ctx(), &th);
let report = spans.iter().find(|s| s.content == "report").unwrap();
assert_eq!(report.style.fg, Some(th.accent));
}
#[test]
fn substitution_placeholders_use_the_subst_colour() {
let th = th();
let line = "URL={{ base }}/api";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let var = spans.iter().find(|s| s.content == "{{ base }}").unwrap();
assert_eq!(var.style.fg, Some(th.subst));
}
#[test]
fn plain_comment_lines_are_dimmed_wholesale() {
let th = th();
let line = " # just a note";
let spans = highlight_line(line, &ctx(), &th);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, line);
assert_eq!(spans[0].style.fg, Some(th.dim));
}
#[test]
fn collection_directive_value_is_green_when_bound_and_amber_when_not() {
let th = th();
let line = "# collection: ./x.hurl";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let val = spans.iter().find(|s| s.content == "./x.hurl").unwrap();
assert_eq!(val.style.fg, Some(th.pending));
let bound = HlCtx {
collection_resolves: true,
..Default::default()
};
let spans = highlight_line(line, &bound, &th);
let val = spans.iter().find(|s| s.content == "./x.hurl").unwrap();
assert_eq!(val.style.fg, Some(th.ok));
}
#[test]
fn environment_directive_value_tracks_whether_the_env_is_loaded() {
let th = th();
let line = "# environment: staging";
let spans = highlight_line(line, &ctx(), &th);
let val = spans.iter().find(|s| s.content == "staging").unwrap();
assert_eq!(val.style.fg, Some(th.pending));
let loaded = HlCtx {
loaded_envs: HashSet::from(["staging".to_string()]),
..Default::default()
};
let spans = highlight_line(line, &loaded, &th);
let val = spans.iter().find(|s| s.content == "staging").unwrap();
assert_eq!(val.style.fg, Some(th.ok));
}
#[test]
fn envs_clause_names_are_coloured_by_loaded_state() {
let th = th();
let line = "FOR TARGET IN ENVS \"prod\", \"staging\"";
let loaded = HlCtx {
loaded_envs: HashSet::from(["prod".to_string()]),
..Default::default()
};
let spans = highlight_line(line, &loaded, &th);
assert_tiles(line, &spans);
let prod = spans.iter().find(|s| s.content == "\"prod\"").unwrap();
assert_eq!(prod.style.fg, Some(th.ok), "loaded env is green");
let staging = spans.iter().find(|s| s.content == "\"staging\"").unwrap();
assert_eq!(staging.style.fg, Some(th.pending), "unloaded env is amber");
}
#[test]
fn keywords_inside_a_string_literal_are_not_highlighted() {
let th = th();
let line = "REQUEST \"Upload for document\"";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let kw = spans.iter().find(|s| s.content == "REQUEST").unwrap();
assert_eq!(kw.style.fg, Some(th.accent));
for sp in &spans {
if sp.content.contains("for") || sp.content.contains("Upload") {
assert_ne!(
sp.style.fg,
Some(th.accent),
"string-literal content is not keyword-accented"
);
assert!(!sp.style.add_modifier.contains(Modifier::BOLD));
}
}
}
#[test]
fn request_names_are_coloured_by_whether_they_resolve() {
let th = th();
let line = "REQUEST Oauth";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let name = spans.iter().find(|s| s.content == "Oauth").unwrap();
assert_eq!(name.style.fg, Some(th.pending), "unknown request is amber");
let bound = HlCtx {
request_names: HashSet::from(["Oauth".to_string()]),
..Default::default()
};
let spans = highlight_line(line, &bound, &th);
let name = spans.iter().find(|s| s.content == "Oauth").unwrap();
assert_eq!(name.style.fg, Some(th.ok), "resolved request is green");
}
#[test]
fn quoted_request_name_after_report_request_is_coloured() {
let th = th();
let bound = HlCtx {
request_names: HashSet::from(["Upload document".to_string()]),
..Default::default()
};
let line = "REPORT REQUEST \"Upload document\"";
let spans = highlight_line(line, &bound, &th);
assert_tiles(line, &spans);
let name = spans
.iter()
.find(|s| s.content == "\"Upload document\"")
.unwrap();
assert_eq!(name.style.fg, Some(th.ok));
}
#[test]
fn only_the_first_token_after_request_is_a_name() {
let th = th();
let bound = HlCtx {
request_names: HashSet::from(["proc".to_string()]),
..Default::default()
};
let line = "REQUEST proc AS result";
let spans = highlight_line(line, &bound, &th);
let name = spans.iter().find(|s| s.content == "proc").unwrap();
assert_eq!(name.style.fg, Some(th.ok));
let alias = spans.iter().find(|s| s.content == "result").unwrap();
assert_eq!(alias.style.fg, Some(th.text), "the alias stays plain text");
}
#[test]
fn substitutions_inside_a_string_literal_still_highlight() {
let th = th();
let line = "FILES \"dir/{{NAME}}\"";
let spans = highlight_line(line, &ctx(), &th);
assert_tiles(line, &spans);
let var = spans.iter().find(|s| s.content == "{{NAME}}").unwrap();
assert_eq!(var.style.fg, Some(th.subst));
}
#[test]
fn the_error_row_is_recoloured_and_underlined() {
let th = th();
let err = HlCtx {
error_line: Some(1),
..Default::default()
};
let spans = highlight_row(0, "REQUEST bad", &err, &th);
for sp in &spans {
assert_eq!(sp.style.fg, Some(th.err));
assert!(sp.style.add_modifier.contains(Modifier::UNDERLINED));
}
let ok = highlight_row(1, "REQUEST good", &err, &th);
let kw = ok.iter().find(|s| s.content == "REQUEST").unwrap();
assert_eq!(kw.style.fg, Some(th.accent));
}
#[test]
fn highlight_source_yields_one_line_per_row() {
let th = th();
let lines = highlight_source("REQUEST a\nEND", &ctx(), &th);
assert_eq!(lines.len(), 2);
}
}