use crate::ported::colorscheme::{ATTR_BOLD, ATTR_ITALIC, ATTR_UNDERLINE};
use std::collections::HashMap;
pub mod bash;
pub mod ksh;
pub mod rcsh;
pub mod readline;
pub mod tcsh;
pub mod zsh;
pub fn int_to_rgb(num: u32) -> (u8, u8, u8) {
let r = ((num >> 16) & 0xff) as u8;
let g = ((num >> 8) & 0xff) as u8;
let b = (num & 0xff) as u8;
(r, g, b)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TermEscapeStyle {
Auto,
Fbterm,
Xterm,
}
impl TermEscapeStyle {
pub fn resolve(self, term: Option<&str>) -> Self {
match self {
TermEscapeStyle::Auto => {
if term == Some("fbterm") {
TermEscapeStyle::Fbterm
} else {
TermEscapeStyle::Xterm
}
}
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColorSpec {
pub cterm: u16,
pub truecolor: Option<u32>,
}
pub struct PromptRenderer {
pub old_widths: HashMap<(String, String, Option<u64>), usize>,
}
impl Default for PromptRenderer {
fn default() -> Self {
Self::new()
}
}
impl PromptRenderer {
pub fn new() -> Self {
Self {
old_widths: HashMap::new(),
}
}
pub fn get_client_id(segment_info: &serde_json::Map<String, serde_json::Value>) -> Option<u64> {
segment_info.get("client_id").and_then(|v| v.as_u64())
}
}
pub struct ShellRenderer {
pub base: PromptRenderer,
pub escape_hl_start: String,
pub escape_hl_end: String,
pub term_truecolor: bool,
pub term_escape_style: TermEscapeStyle,
pub used_term_escape_style: TermEscapeStyle,
pub tmux_escape: bool,
pub screen_escape: bool,
pub theme: serde_json::Value,
pub local_themes: HashMap<String, serde_json::Map<String, serde_json::Value>>,
pub theme_config: serde_json::Value,
pub theme_kwargs: serde_json::Map<String, serde_json::Value>,
}
impl Default for ShellRenderer {
fn default() -> Self {
Self::new()
}
}
impl ShellRenderer {
pub fn new() -> Self {
Self {
base: PromptRenderer::new(),
escape_hl_start: String::new(),
escape_hl_end: String::new(),
term_truecolor: false,
term_escape_style: TermEscapeStyle::Auto,
used_term_escape_style: TermEscapeStyle::Xterm,
tmux_escape: false,
screen_escape: false,
theme: serde_json::Value::Null,
local_themes: HashMap::new(),
theme_config: serde_json::Value::Null,
theme_kwargs: serde_json::Map::new(),
}
}
pub fn render_matcher_info(
segment_info: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
segment_info
.get("local_theme")
.and_then(|v| v.as_str().map(String::from))
}
pub fn render<F>(
segment_info: &serde_json::Map<String, serde_json::Value>,
super_render: F,
) -> String
where
F: FnOnce(Option<&str>) -> String,
{
let local_theme = Self::render_matcher_info(segment_info);
super_render(local_theme.as_deref())
}
pub fn do_render<F>(
&mut self,
segment_info: &serde_json::Map<String, serde_json::Value>,
super_do_render: F,
) -> String
where
F: FnOnce() -> String,
{
self.do_render_resolve_style(segment_info);
super_do_render()
}
pub fn do_render_resolve_style(
&mut self,
segment_info: &serde_json::Map<String, serde_json::Value>,
) {
let resolved = if self.term_escape_style == TermEscapeStyle::Auto {
let term = segment_info
.get("environ")
.and_then(|e| e.as_object())
.and_then(|m| m.get("TERM"))
.and_then(|v| v.as_str());
if term == Some("fbterm") {
TermEscapeStyle::Fbterm
} else {
TermEscapeStyle::Xterm
}
} else {
self.term_escape_style
};
self.used_term_escape_style = resolved;
}
pub fn get_theme(&mut self, matcher_info: Option<&str>) -> serde_json::Value {
let m = match matcher_info {
Some(s) if !s.is_empty() => s,
_ => return self.theme.clone(),
};
let match_entry = match self.local_themes.get(m) {
Some(e) => e.clone(),
None => return serde_json::Value::Null,
};
if let Some(t) = match_entry.get("theme") {
return t.clone();
}
let constructed = serde_json::json!({
"theme_config": match_entry.get("config").cloned().unwrap_or(serde_json::Value::Null),
"main_theme_config": self.theme_config.clone(),
"theme_kwargs": serde_json::Value::Object(self.theme_kwargs.clone()),
});
if let Some(e) = self.local_themes.get_mut(m) {
e.insert("theme".to_string(), constructed.clone());
}
constructed
}
pub fn hlstyle(
&self,
fg: Option<ColorSpec>,
bg: Option<ColorSpec>,
attrs: Option<u32>,
escape: bool,
term: Option<&str>,
) -> String {
let mut ansi: Vec<u32> = vec![0];
let style = self.term_escape_style.resolve(term);
let is_fbterm = style == TermEscapeStyle::Fbterm;
let term_truecolor = !is_fbterm && self.term_truecolor;
if let Some(f) = fg {
if let (true, Some(tc)) = (term_truecolor, f.truecolor) {
let (r, g, b) = int_to_rgb(tc);
ansi.extend_from_slice(&[38, 2, r as u32, g as u32, b as u32]);
} else {
ansi.extend_from_slice(&[38, 5, f.cterm as u32]);
}
}
if let Some(b) = bg {
if let (true, Some(tc)) = (term_truecolor, b.truecolor) {
let (r, g, bl) = int_to_rgb(tc);
ansi.extend_from_slice(&[48, 2, r as u32, g as u32, bl as u32]);
} else {
ansi.extend_from_slice(&[48, 5, b.cterm as u32]);
}
}
if let Some(a) = attrs {
if a & ATTR_BOLD != 0 {
ansi.push(1);
} else if a & ATTR_ITALIC != 0 {
ansi.push(3);
} else if a & ATTR_UNDERLINE != 0 {
ansi.push(4);
}
}
let r = if is_fbterm {
let mut out = String::new();
let mut iter = ansi.into_iter();
while let Some(cur) = iter.next() {
if cur == 38 {
iter.next();
let idx = iter.next().unwrap_or(0);
out.push_str(&format!("\x1b[1;{}}}", idx));
} else if cur == 48 {
iter.next();
let idx = iter.next().unwrap_or(0);
out.push_str(&format!("\x1b[2;{}}}", idx));
} else {
out.push_str(&format!("\x1b[{}m", cur));
}
}
out
} else {
let joined: Vec<String> = ansi.into_iter().map(|n| n.to_string()).collect();
format!("\x1b[{}m", joined.join(";"))
};
let r = if self.tmux_escape {
format!("\x1bPtmux;{}\x1b\\", r.replace('\x1b', "\x1b\x1b"))
} else if self.screen_escape {
format!("\x1bP{}\x1b\\", r.replace('\x1b', "\x1b\x1b"))
} else {
r
};
if escape {
format!("{}{}{}", self.escape_hl_start, r, self.escape_hl_end)
} else {
r
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Map;
#[test]
fn int_to_rgb_extracts_components() {
assert_eq!(int_to_rgb(0xff0000), (0xff, 0, 0));
assert_eq!(int_to_rgb(0x00ff00), (0, 0xff, 0));
assert_eq!(int_to_rgb(0x0000ff), (0, 0, 0xff));
assert_eq!(int_to_rgb(0xff8000), (0xff, 0x80, 0));
assert_eq!(int_to_rgb(0x000000), (0, 0, 0));
}
#[test]
fn term_escape_style_auto_resolves_fbterm() {
let r = TermEscapeStyle::Auto.resolve(Some("fbterm"));
assert_eq!(r, TermEscapeStyle::Fbterm);
}
#[test]
fn term_escape_style_auto_resolves_xterm_for_other_term() {
let r = TermEscapeStyle::Auto.resolve(Some("xterm-256color"));
assert_eq!(r, TermEscapeStyle::Xterm);
}
#[test]
fn term_escape_style_auto_resolves_xterm_when_term_unset() {
let r = TermEscapeStyle::Auto.resolve(None);
assert_eq!(r, TermEscapeStyle::Xterm);
}
#[test]
fn term_escape_style_pinned_value_passthrough() {
assert_eq!(
TermEscapeStyle::Fbterm.resolve(Some("xterm")),
TermEscapeStyle::Fbterm
);
assert_eq!(
TermEscapeStyle::Xterm.resolve(Some("fbterm")),
TermEscapeStyle::Xterm
);
}
#[test]
fn prompt_renderer_get_client_id_returns_value() {
let mut info = Map::new();
info.insert("client_id".to_string(), serde_json::json!(42));
let r = PromptRenderer::get_client_id(&info);
assert_eq!(r, Some(42));
}
#[test]
fn prompt_renderer_get_client_id_returns_none_when_absent() {
let info = Map::new();
assert!(PromptRenderer::get_client_id(&info).is_none());
}
#[test]
fn shell_renderer_default_field_values_match_upstream() {
let r = ShellRenderer::new();
assert_eq!(r.escape_hl_start, "");
assert_eq!(r.escape_hl_end, "");
assert!(!r.term_truecolor);
assert_eq!(r.term_escape_style, TermEscapeStyle::Auto);
assert!(!r.tmux_escape);
assert!(!r.screen_escape);
}
#[test]
fn hlstyle_no_args_returns_just_reset_sequence() {
let r = ShellRenderer::new();
let s = r.hlstyle(None, None, None, false, Some("xterm"));
assert_eq!(s, "\x1b[0m");
}
#[test]
fn hlstyle_fg_cterm_emits_38_5_n() {
let r = ShellRenderer::new();
let s = r.hlstyle(
Some(ColorSpec {
cterm: 196,
truecolor: Some(0xff0000),
}),
None,
None,
false,
Some("xterm"),
);
assert_eq!(s, "\x1b[0;38;5;196m");
}
#[test]
fn hlstyle_fg_truecolor_emits_38_2_rgb_when_enabled() {
let mut r = ShellRenderer::new();
r.term_truecolor = true;
let s = r.hlstyle(
Some(ColorSpec {
cterm: 196,
truecolor: Some(0xff0000),
}),
None,
None,
false,
Some("xterm"),
);
assert_eq!(s, "\x1b[0;38;2;255;0;0m");
}
#[test]
fn hlstyle_truecolor_disabled_on_fbterm() {
let mut r = ShellRenderer::new();
r.term_truecolor = true;
let s = r.hlstyle(
Some(ColorSpec {
cterm: 196,
truecolor: Some(0xff0000),
}),
None,
None,
false,
Some("fbterm"),
);
assert!(s.contains("\x1b[1;196}"));
}
#[test]
fn hlstyle_bg_emits_48_5_n() {
let r = ShellRenderer::new();
let s = r.hlstyle(
None,
Some(ColorSpec {
cterm: 21,
truecolor: None,
}),
None,
false,
Some("xterm"),
);
assert_eq!(s, "\x1b[0;48;5;21m");
}
#[test]
fn hlstyle_attrs_bold_emits_1() {
let r = ShellRenderer::new();
let s = r.hlstyle(None, None, Some(ATTR_BOLD), false, Some("xterm"));
assert_eq!(s, "\x1b[0;1m");
}
#[test]
fn hlstyle_attrs_italic_emits_3() {
let r = ShellRenderer::new();
let s = r.hlstyle(None, None, Some(ATTR_ITALIC), false, Some("xterm"));
assert_eq!(s, "\x1b[0;3m");
}
#[test]
fn hlstyle_attrs_underline_emits_4() {
let r = ShellRenderer::new();
let s = r.hlstyle(None, None, Some(ATTR_UNDERLINE), false, Some("xterm"));
assert_eq!(s, "\x1b[0;4m");
}
#[test]
fn hlstyle_combined_fg_bg_attrs() {
let r = ShellRenderer::new();
let s = r.hlstyle(
Some(ColorSpec {
cterm: 231,
truecolor: None,
}),
Some(ColorSpec {
cterm: 21,
truecolor: None,
}),
Some(ATTR_BOLD),
false,
Some("xterm"),
);
assert_eq!(s, "\x1b[0;38;5;231;48;5;21;1m");
}
#[test]
fn hlstyle_escape_true_wraps_with_hl_start_and_end() {
let mut r = ShellRenderer::new();
r.escape_hl_start = "<".to_string();
r.escape_hl_end = ">".to_string();
let s = r.hlstyle(None, None, None, true, Some("xterm"));
assert_eq!(s, "<\x1b[0m>");
}
#[test]
fn hlstyle_tmux_escape_wraps_with_dcs() {
let mut r = ShellRenderer::new();
r.tmux_escape = true;
let s = r.hlstyle(None, None, None, false, Some("xterm"));
assert!(s.starts_with("\x1bPtmux;"));
assert!(s.ends_with("\x1b\\"));
assert!(s.contains("\x1b\x1b["));
}
#[test]
fn hlstyle_screen_escape_wraps_with_dcs() {
let mut r = ShellRenderer::new();
r.screen_escape = true;
let s = r.hlstyle(None, None, None, false, Some("xterm"));
assert!(s.starts_with("\x1bP"));
assert!(s.ends_with("\x1b\\"));
assert!(s.contains("\x1b\x1b["));
}
#[test]
fn render_matcher_info_pulls_local_theme() {
let mut info = Map::new();
info.insert("local_theme".to_string(), serde_json::json!("ipython"));
assert_eq!(
ShellRenderer::render_matcher_info(&info),
Some("ipython".to_string())
);
}
#[test]
fn render_matcher_info_returns_none_when_unset() {
let info = Map::new();
assert!(ShellRenderer::render_matcher_info(&info).is_none());
}
#[test]
fn do_render_resolve_style_auto_dispatches_fbterm() {
let mut r = ShellRenderer::new();
let mut info = Map::new();
info.insert("environ".to_string(), serde_json::json!({"TERM": "fbterm"}));
r.do_render_resolve_style(&info);
assert_eq!(r.used_term_escape_style, TermEscapeStyle::Fbterm);
}
#[test]
fn do_render_resolve_style_auto_defaults_to_xterm() {
let mut r = ShellRenderer::new();
let mut info = Map::new();
info.insert(
"environ".to_string(),
serde_json::json!({"TERM": "xterm-256color"}),
);
r.do_render_resolve_style(&info);
assert_eq!(r.used_term_escape_style, TermEscapeStyle::Xterm);
}
#[test]
fn do_render_resolve_style_pinned_value_passes_through() {
let mut r = ShellRenderer::new();
r.term_escape_style = TermEscapeStyle::Fbterm;
let mut info = Map::new();
info.insert(
"environ".to_string(),
serde_json::json!({"TERM": "xterm-256color"}),
);
r.do_render_resolve_style(&info);
assert_eq!(r.used_term_escape_style, TermEscapeStyle::Fbterm);
}
#[test]
fn shell_renderer_get_theme_no_matcher_returns_self_theme() {
let mut r = ShellRenderer::new();
r.theme = serde_json::json!({"name": "default"});
let t = r.get_theme(None);
assert_eq!(t["name"], "default");
}
#[test]
fn shell_renderer_get_theme_empty_string_returns_self_theme() {
let mut r = ShellRenderer::new();
r.theme = serde_json::json!({"name": "default"});
let t = r.get_theme(Some(""));
assert_eq!(t["name"], "default");
}
#[test]
fn shell_renderer_get_theme_returns_existing_local_theme() {
let mut r = ShellRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert(
"theme".to_string(),
serde_json::json!({"name": "shell-out"}),
);
r.local_themes.insert("out".to_string(), entry);
let t = r.get_theme(Some("out"));
assert_eq!(t["name"], "shell-out");
}
#[test]
fn shell_renderer_get_theme_constructs_lazy_theme() {
let mut r = ShellRenderer::new();
r.theme_config = serde_json::json!({"colorscheme": "default"});
r.theme_kwargs
.insert("extra".to_string(), serde_json::json!("kw_value"));
let mut entry = serde_json::Map::new();
entry.insert("config".to_string(), serde_json::json!({"segments": []}));
r.local_themes.insert("rewrite".to_string(), entry);
let t = r.get_theme(Some("rewrite"));
assert_eq!(t["theme_config"]["segments"], serde_json::json!([]));
assert_eq!(t["main_theme_config"]["colorscheme"], "default");
assert_eq!(t["theme_kwargs"]["extra"], "kw_value");
}
#[test]
fn shell_renderer_get_theme_caches_constructed_theme() {
let mut r = ShellRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert("config".to_string(), serde_json::json!({"a": 1}));
r.local_themes.insert("rewrite".to_string(), entry);
let _ = r.get_theme(Some("rewrite"));
let cached = r
.local_themes
.get("rewrite")
.and_then(|m| m.get("theme"))
.cloned();
assert!(cached.is_some(), "constructed theme not cached");
}
#[test]
fn shell_renderer_get_theme_missing_matcher_returns_null() {
let mut r = ShellRenderer::new();
assert_eq!(r.get_theme(Some("nonexistent")), serde_json::Value::Null);
}
}