use regex::Regex;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::sync::OnceLock;
pub const NBSP: &str = "\u{a0}";
pub fn np_control_character_translations() -> &'static HashMap<char, String> {
static M: OnceLock<HashMap<char, String>> = OnceLock::new();
M.get_or_init(|| {
let mut m = HashMap::new();
for i in 0u32..0x20 {
let ch = char::from_u32(i).unwrap();
let repl_byte = (i + 0x40) as u8;
let repl = format!("^{}", repl_byte as char);
m.insert(ch, repl);
}
m
})
}
pub fn np_invalid_character_translations() -> &'static HashMap<u32, String> {
static M: OnceLock<HashMap<u32, String>> = OnceLock::new();
M.get_or_init(|| {
let mut m = HashMap::new();
for i in 0xDC80u32..0xDD00 {
m.insert(i, format!("<{:02x}>", i - 0xDC00));
}
m
})
}
pub fn np_invalid_character_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^$").unwrap())
}
pub fn np_character_translations() -> HashMap<char, String> {
let mut m = np_control_character_translations().clone();
for (cp, repl) in np_invalid_character_translations() {
if let Some(c) = char::from_u32(*cp) {
m.insert(c, repl.clone());
}
}
m
}
pub fn translate_np(s: &str) -> String {
let table = np_character_translations();
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if let Some(repl) = table.get(&c) {
out.push_str(repl);
} else {
out.push(c);
}
}
out
}
#[derive(Debug, Clone)]
pub enum RenderReturn {
Plain(String),
Tuple {
highlighted: String,
raw: Option<String>,
width: Option<usize>,
},
}
pub fn construct_returned_value(
rendered_highlighted: String,
rendered_raw: Option<String>,
width: usize,
output_raw: bool,
output_width: bool,
) -> RenderReturn {
if !output_raw && !output_width {
return RenderReturn::Plain(rendered_highlighted);
}
RenderReturn::Tuple {
highlighted: rendered_highlighted,
raw: if output_raw { rendered_raw } else { None },
width: if output_width { Some(width) } else { None },
}
}
pub fn width_data(ambiwidth: u8) -> HashMap<char, u8> {
let mut m = HashMap::new();
m.insert('N', 1);
m.insert('a', 1);
m.insert('A', ambiwidth);
m.insert('H', 1);
m.insert('W', 2);
m.insert('F', 2);
m
}
pub fn strwidth(s: &str) -> usize {
s.chars().count()
}
pub fn compute_divider_widths<F>(mut get_divider: F) -> Map<String, Value>
where
F: FnMut(&str, &str) -> String,
{
let mut out = Map::new();
for side in ["left", "right"] {
let mut side_map = Map::new();
side_map.insert(
"hard".to_string(),
Value::from(strwidth(&get_divider(side, "hard"))),
);
side_map.insert(
"soft".to_string(),
Value::from(strwidth(&get_divider(side, "soft"))),
);
out.insert(side.to_string(), Value::Object(side_map));
}
out
}
pub struct Renderer {
pub theme_config: Map<String, Value>,
pub local_themes: Map<String, Value>,
pub character_translations: HashMap<char, String>,
pub width_data: HashMap<char, u8>,
pub theme: Value,
pub shutdown_called: std::sync::Mutex<Vec<String>>,
}
impl Renderer {
pub fn new(
theme_config: Map<String, Value>,
local_themes: Map<String, Value>,
ambiwidth: u8,
) -> Self {
let use_nbsp = theme_config
.get("use_non_breaking_spaces")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut character_translations: HashMap<char, String> = HashMap::new();
if use_nbsp {
character_translations.insert(' ', NBSP.to_string());
}
Self {
theme_config,
local_themes,
character_translations,
width_data: width_data(ambiwidth),
theme: Value::Null,
shutdown_called: std::sync::Mutex::new(Vec::new()),
}
}
pub fn get_theme(&self, _matcher_info: Option<&Value>) -> Value {
self.theme.clone()
}
pub fn shutdown(&self) {
let mut log = self
.shutdown_called
.lock()
.unwrap_or_else(|e| e.into_inner());
log.push("theme".to_string());
}
pub fn escape(&self, string: &str) -> String {
let mut out = String::with_capacity(string.len());
for c in string.chars() {
match self.character_translations.get(&c) {
Some(replacement) => out.push_str(replacement),
None => out.push(c),
}
}
out
}
pub fn hl(contents: Option<&str>, hlstyle_output: &str) -> String {
format!("{}{}", hlstyle_output, contents.unwrap_or(""))
}
pub fn _prepare_segments(segments: &mut [Value], calculate_contents_len: bool) {
for segment in segments.iter_mut() {
if let Some(obj) = segment.as_object_mut() {
if let Some(contents) = obj.get("contents").and_then(|v| v.as_str()) {
let translated = translate_np(contents);
obj.insert("contents".to_string(), Value::String(translated));
}
}
}
if calculate_contents_len {
for segment in segments.iter_mut() {
if let Some(obj) = segment.as_object_mut() {
let literal = obj
.get("literal_contents")
.and_then(|v| v.as_array())
.cloned();
let contents_len = if let Some(lit) = literal {
let has_literal = lit
.get(1)
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
if has_literal {
lit.first().and_then(|v| v.as_u64()).unwrap_or(0) as usize
} else {
obj.get("contents")
.and_then(|v| v.as_str())
.map(strwidth)
.unwrap_or(0)
}
} else {
obj.get("contents")
.and_then(|v| v.as_str())
.map(strwidth)
.unwrap_or(0)
};
obj.insert(
"_contents_len".to_string(),
Value::from(contents_len as u64),
);
}
}
}
}
pub fn segment_info() -> Map<String, Value> {
let mut info = Map::new();
let env_map: Map<String, Value> = std::env::vars()
.map(|(k, v)| (k, Value::String(v)))
.collect();
info.insert("environ".to_string(), Value::Object(env_map.clone()));
info.insert(
"home".to_string(),
env_map.get("HOME").cloned().unwrap_or(Value::Null),
);
info
}
pub fn get_segment_info(
&self,
segment_info: Option<Map<String, Value>>,
mode: Option<&str>,
) -> Map<String, Value> {
let mut r = Self::segment_info();
r.insert(
"mode".to_string(),
mode.map(|s| Value::String(s.into())).unwrap_or(Value::Null),
);
if let Some(extra) = segment_info {
for (k, v) in extra {
r.insert(k, v);
}
}
let pwd = r
.get("environ")
.and_then(|v| v.as_object())
.and_then(|env| env.get("PWD"))
.and_then(|v| v.as_str())
.map(String::from);
if let Some(p) = pwd {
r.insert("getcwd".to_string(), Value::String(p));
}
r
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nbsp_constant_matches_upstream() {
assert_eq!(NBSP, "\u{a0}");
}
#[test]
fn np_control_character_translations_has_32_entries() {
let m = np_control_character_translations();
assert_eq!(m.len(), 32);
}
#[test]
fn np_control_character_translations_maps_null_to_caret_at() {
let m = np_control_character_translations();
assert_eq!(m.get(&'\u{00}'), Some(&"^@".to_string()));
}
#[test]
fn np_control_character_translations_maps_a_to_caret_a() {
let m = np_control_character_translations();
assert_eq!(m.get(&'\u{01}'), Some(&"^A".to_string()));
}
#[test]
fn np_control_character_translations_maps_tab_to_caret_i() {
let m = np_control_character_translations();
assert_eq!(m.get(&'\t'), Some(&"^I".to_string()));
}
#[test]
fn np_control_character_translations_maps_newline_to_caret_j() {
let m = np_control_character_translations();
assert_eq!(m.get(&'\n'), Some(&"^J".to_string()));
}
#[test]
fn np_invalid_character_translations_has_128_entries() {
let m = np_invalid_character_translations();
assert_eq!(m.len(), 128);
}
#[test]
fn np_invalid_character_translations_first_entry_is_80() {
let m = np_invalid_character_translations();
assert_eq!(m.get(&0xDC80), Some(&"<80>".to_string()));
}
#[test]
fn np_invalid_character_translations_last_entry_is_ff() {
let m = np_invalid_character_translations();
assert_eq!(m.get(&0xDCFF), Some(&"<ff>".to_string()));
}
#[test]
fn np_character_translations_contains_both_control_and_invalid() {
let m = np_character_translations();
assert!(m.len() >= 32);
assert!(m.contains_key(&'\u{00}'));
}
#[test]
fn translate_np_replaces_control_chars() {
let r = translate_np("abc\x01def");
assert_eq!(r, "abc^Adef");
}
#[test]
fn translate_np_passes_printable_chars_through() {
let r = translate_np("hello world");
assert_eq!(r, "hello world");
}
#[test]
fn translate_np_handles_multiple_control_chars() {
let r = translate_np("\x00\x01\x02");
assert_eq!(r, "^@^A^B");
}
#[test]
fn construct_returned_value_plain_when_no_flags() {
let r = construct_returned_value("hi".to_string(), None, 5, false, false);
match r {
RenderReturn::Plain(s) => assert_eq!(s, "hi"),
_ => panic!("expected Plain"),
}
}
#[test]
fn construct_returned_value_tuple_with_width_only() {
let r = construct_returned_value("hi".to_string(), None, 5, false, true);
match r {
RenderReturn::Tuple {
highlighted,
raw,
width,
} => {
assert_eq!(highlighted, "hi");
assert!(raw.is_none());
assert_eq!(width, Some(5));
}
_ => panic!("expected Tuple"),
}
}
#[test]
fn construct_returned_value_tuple_with_raw_only() {
let r =
construct_returned_value("hi".to_string(), Some("hi-raw".to_string()), 5, true, false);
match r {
RenderReturn::Tuple {
highlighted,
raw,
width,
} => {
assert_eq!(highlighted, "hi");
assert_eq!(raw, Some("hi-raw".to_string()));
assert!(width.is_none());
}
_ => panic!("expected Tuple"),
}
}
#[test]
fn construct_returned_value_tuple_with_both() {
let r =
construct_returned_value("hi".to_string(), Some("hi-raw".to_string()), 5, true, true);
match r {
RenderReturn::Tuple {
highlighted,
raw,
width,
} => {
assert_eq!(highlighted, "hi");
assert_eq!(raw, Some("hi-raw".to_string()));
assert_eq!(width, Some(5));
}
_ => panic!("expected Tuple"),
}
}
#[test]
fn width_data_default_ambiwidth_is_1() {
let w = width_data(1);
assert_eq!(w.get(&'N'), Some(&1));
assert_eq!(w.get(&'A'), Some(&1));
assert_eq!(w.get(&'W'), Some(&2));
assert_eq!(w.get(&'F'), Some(&2));
}
#[test]
fn width_data_ambiwidth_overrides_ambiguous() {
let w = width_data(2);
assert_eq!(w.get(&'A'), Some(&2));
}
#[test]
fn strwidth_counts_chars() {
assert_eq!(strwidth("hello"), 5);
assert_eq!(strwidth(""), 0);
assert_eq!(strwidth("café"), 4);
}
#[test]
fn compute_divider_widths_emits_both_sides_and_kinds() {
let r = compute_divider_widths(|side, kind| match (side, kind) {
("left", "hard") => " ".to_string(),
("left", "soft") => " ".to_string(),
("right", "hard") => " ".to_string(),
("right", "soft") => " ".to_string(),
_ => "".to_string(),
});
assert!(r.contains_key("left"));
assert!(r.contains_key("right"));
let left = r["left"].as_object().unwrap();
assert_eq!(left["hard"], 1);
assert_eq!(left["soft"], 1);
}
#[test]
fn renderer_init_use_nbsp_default_adds_space_translation() {
let cfg = Map::new();
let r = Renderer::new(cfg, Map::new(), 1);
assert_eq!(r.character_translations.get(&' '), Some(&NBSP.to_string()));
}
#[test]
fn renderer_init_use_nbsp_false_omits_space_translation() {
let mut cfg = Map::new();
cfg.insert("use_non_breaking_spaces".to_string(), Value::Bool(false));
let r = Renderer::new(cfg, Map::new(), 1);
assert!(!r.character_translations.contains_key(&' '));
}
#[test]
fn renderer_init_uses_ambiwidth_for_width_data() {
let cfg = Map::new();
let r = Renderer::new(cfg, Map::new(), 2);
assert_eq!(r.width_data.get(&'A'), Some(&2));
}
#[test]
fn segment_info_includes_environ_and_home() {
let info = Renderer::segment_info();
assert!(info.contains_key("environ"));
assert!(info.contains_key("home"));
}
#[test]
fn get_segment_info_merges_segment_info_over_base() {
let cfg = Map::new();
let r = Renderer::new(cfg, Map::new(), 1);
let mut extra = Map::new();
extra.insert("client_id".to_string(), Value::from(42));
let info = r.get_segment_info(Some(extra), Some("normal"));
assert_eq!(info["mode"], "normal");
assert_eq!(info["client_id"], 42);
}
#[test]
fn get_segment_info_sets_mode_null_when_none() {
let cfg = Map::new();
let r = Renderer::new(cfg, Map::new(), 1);
let info = r.get_segment_info(None, None);
assert_eq!(info["mode"], Value::Null);
}
#[test]
fn get_segment_info_overrides_getcwd_when_pwd_set() {
let cfg = Map::new();
let r = Renderer::new(cfg, Map::new(), 1);
let mut extra = Map::new();
let mut env = Map::new();
env.insert("PWD".to_string(), Value::String("/my/cwd".into()));
extra.insert("environ".to_string(), Value::Object(env));
let info = r.get_segment_info(Some(extra), None);
assert_eq!(info.get("getcwd"), Some(&Value::String("/my/cwd".into())));
}
#[test]
fn get_theme_returns_self_theme() {
let mut r = Renderer::new(Map::new(), Map::new(), 1);
r.theme = serde_json::json!({"name": "default"});
let t = r.get_theme(None);
assert_eq!(t["name"], "default");
}
#[test]
fn get_theme_ignores_matcher_info() {
let mut r = Renderer::new(Map::new(), Map::new(), 1);
r.theme = serde_json::json!({"name": "default"});
let info = serde_json::json!({"foo": "bar"});
let t = r.get_theme(Some(&info));
assert_eq!(t["name"], "default");
}
#[test]
fn shutdown_records_theme() {
let r = Renderer::new(Map::new(), Map::new(), 1);
r.shutdown();
let log = r.shutdown_called.lock().unwrap();
assert_eq!(*log, vec!["theme".to_string()]);
}
#[test]
fn escape_translates_chars_via_character_translations() {
let mut r = Renderer::new(Map::new(), Map::new(), 1);
r.character_translations.clear();
r.character_translations.insert('%', "%%".to_string());
assert_eq!(r.escape("100% done"), "100%% done");
}
#[test]
fn escape_passes_untranslated_chars_through() {
let r = Renderer::new(Map::new(), Map::new(), 1);
let s = "abc";
assert_eq!(r.escape(s), "abc");
}
#[test]
fn escape_default_translates_space_to_nbsp() {
let r = Renderer::new(Map::new(), Map::new(), 1);
let result = r.escape("hi there");
assert!(result.contains('\u{a0}'));
assert!(!result.contains(' '));
}
#[test]
fn escape_use_non_breaking_spaces_false_keeps_spaces() {
let mut theme_config = Map::new();
theme_config.insert("use_non_breaking_spaces".to_string(), Value::Bool(false));
let r = Renderer::new(theme_config, Map::new(), 1);
assert_eq!(r.escape("hi there"), "hi there");
}
#[test]
fn hl_concatenates_hlstyle_output_and_contents() {
let result = Renderer::hl(Some("text"), "\x1b[1m");
assert_eq!(result, "\x1b[1mtext");
}
#[test]
fn hl_none_contents_becomes_empty_string() {
let result = Renderer::hl(None, "\x1b[1m");
assert_eq!(result, "\x1b[1m");
}
#[test]
fn _prepare_segments_translates_non_printable_contents() {
let mut segments: Vec<Value> = vec![serde_json::json!({
"contents": "hello\x01world",
})];
Renderer::_prepare_segments(&mut segments, false);
let c = segments[0]["contents"].as_str().unwrap();
assert!(!c.contains('\x01'));
}
#[test]
fn _prepare_segments_calculates_contents_len_from_strwidth_when_no_literal() {
let mut segments: Vec<Value> = vec![serde_json::json!({
"contents": "hello",
"literal_contents": [0, ""],
})];
Renderer::_prepare_segments(&mut segments, true);
assert_eq!(segments[0]["_contents_len"], 5);
}
#[test]
fn _prepare_segments_uses_literal_contents_len_when_literal_non_empty() {
let mut segments: Vec<Value> = vec![serde_json::json!({
"contents": "ignored",
"literal_contents": [42, "raw text"],
})];
Renderer::_prepare_segments(&mut segments, true);
assert_eq!(segments[0]["_contents_len"], 42);
}
#[test]
fn _prepare_segments_skips_contents_len_when_not_requested() {
let mut segments: Vec<Value> = vec![serde_json::json!({
"contents": "hello",
"literal_contents": [0, ""],
})];
Renderer::_prepare_segments(&mut segments, false);
assert!(segments[0].get("_contents_len").is_none());
}
}