use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone, Debug, Default)]
pub struct ZstyleColors {
pub menu_selection: String,
pub prefix_color: String,
pub tag_colors: HashMap<String, String>,
pub list_separator: String,
pub header: HeaderColors,
pub follow_symlinks: bool,
}
#[derive(Clone, Debug)]
pub struct HeaderColors {
pub pre: String,
pub post: String,
pub pre_color: String,
pub text_color: String,
pub post_color: String,
}
impl Default for HeaderColors {
fn default() -> Self {
Self {
pre: "-<<".into(),
post: ">>-".into(),
pre_color: "1;31".into(),
text_color: "34".into(),
post_color: "1;31".into(),
}
}
}
impl HeaderColors {
pub fn from_env() -> Self {
Self {
pre: std::env::var("ZPWR_DESC_PRE").unwrap_or_else(|_| "-<<".into()),
post: std::env::var("ZPWR_DESC_POST").unwrap_or_else(|_| ">>-".into()),
pre_color: std::env::var("ZPWR_DESC_PRE_COLOR").unwrap_or_else(|_| "1;31".into()),
text_color: std::env::var("ZPWR_DESC_TEXT_COLOR").unwrap_or_else(|_| "34".into()),
post_color: std::env::var("ZPWR_DESC_POST_COLOR").unwrap_or_else(|_| "1;31".into()),
}
}
pub fn format(&self, text: &str) -> String {
format!(
"\x1b[{}m{}\x1b[0m\x1b[{}m{}\x1b[0m\x1b[{}m{}\x1b[0m",
self.pre_color, self.pre, self.text_color, text, self.post_color, self.post
)
}
}
impl ZstyleColors {
pub fn from_zpwr() -> Self {
let mut colors = Self::default();
colors.add_default_file_colors();
if let Ok(home) = std::env::var("HOME") {
let env_file = PathBuf::from(&home).join(".zpwr/env/.zpwr_env.sh");
if let Ok(content) = std::fs::read_to_string(&env_file) {
colors.parse_env_file(&content);
}
let zstyle_file = PathBuf::from(&home).join(".zpwr/autoload/common/zpwrBindZstyle");
if let Ok(content) = std::fs::read_to_string(&zstyle_file) {
colors.parse_zstyle_file(&content);
}
}
colors.header = HeaderColors::from_env();
if let Ok(sep) = std::env::var("ZPWR_CHAR_LOGO") {
colors.list_separator = sep;
}
colors.follow_symlinks = std::env::var("LC_FOLLOW_SYMLINKS")
.map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
.unwrap_or(false);
colors
}
fn add_default_file_colors(&mut self) {
let file_color = "32".to_string();
for tag in &[
"file",
"files",
"all-files",
"globbed-files",
"local-directories",
"directories",
"directory",
"path",
"paths",
] {
self.tag_colors
.insert((*tag).to_string(), file_color.clone());
}
}
fn parse_env_file(&mut self, content: &str) {
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("export ").or(Some(line)) {
if let Some((key, val)) = rest.split_once('=') {
let val = val.trim_matches(|c| c == '\'' || c == '"');
match key {
"ZPWR_CHAR_LOGO" => self.list_separator = val.into(),
"ZPWR_DESC_PRE" => self.header.pre = val.into(),
"ZPWR_DESC_POST" => self.header.post = val.into(),
"ZPWR_DESC_PRE_COLOR" => self.header.pre_color = val.into(),
"ZPWR_DESC_TEXT_COLOR" => self.header.text_color = val.into(),
"ZPWR_DESC_POST_COLOR" => self.header.post_color = val.into(),
_ => {}
}
}
}
}
}
fn parse_zstyle_file(&mut self, content: &str) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if !line.starts_with("zstyle ") {
continue;
}
let rest = &line[7..];
let (pattern, rest) = Self::extract_quoted(rest);
if pattern.is_none() {
continue;
}
let pattern = pattern.unwrap();
let rest = rest.trim_start();
if !rest.starts_with("list-colors") {
continue;
}
let rest = rest[11..].trim_start();
let (value, _) = Self::extract_quoted(rest);
if value.is_none() {
continue;
}
let value = value.unwrap();
if let Some(tag) = Self::extract_tag(&pattern) {
if tag.is_empty() {
if let Some(ma) = value.strip_prefix("ma=") {
self.menu_selection = ma.into();
}
} else {
if let Some(color) = Self::parse_list_color_value(&value) {
self.tag_colors.insert(tag.to_string(), color.1.clone());
match tag {
"executables" => {
self.tag_colors
.insert("external command".into(), color.1.clone());
}
"functions" => {
self.tag_colors
.insert("shell function".into(), color.1.clone());
}
"builtins" => {
self.tag_colors
.insert("builtin command".into(), color.1.clone());
}
"parameters" => {
self.tag_colors.insert("parameter".into(), color.1.clone());
}
"aliases" | "alias" => {
self.tag_colors.insert("alias".into(), color.1.clone());
self.tag_colors.insert("aliases".into(), color.1.clone());
}
_ => {}
}
if self.prefix_color.is_empty() {
self.prefix_color = color.0;
}
}
}
}
}
}
fn extract_quoted(s: &str) -> (Option<String>, &str) {
let s = s.trim_start();
if let Some(after_q) = s.strip_prefix('\'') {
if let Some(end) = after_q.find('\'') {
return (Some(after_q[..end].to_string()), &after_q[end + 1..]);
}
} else if let Some(after_q) = s.strip_prefix('"') {
if let Some(end) = after_q.find('"') {
return (Some(after_q[..end].to_string()), &after_q[end + 1..]);
}
}
(None, s)
}
fn extract_tag(pattern: &str) -> Option<&str> {
if !pattern.starts_with(":completion:") {
return None;
}
let parts: Vec<&str> = pattern.split(':').collect();
for part in parts.iter().rev() {
if part.is_empty() || *part == "completion" || part.contains('*') {
continue;
}
return Some(*part);
}
Some("") }
fn parse_list_color_value(value: &str) -> Option<(String, String)> {
if let Some(rest) = value.strip_prefix("=(#b)(*)=") {
let parts: Vec<&str> = rest.splitn(2, '=').collect();
if parts.len() == 2 {
return Some((parts[0].to_string(), parts[1].to_string()));
} else if parts.len() == 1 {
return Some(("1;30".to_string(), parts[0].to_string()));
}
}
None
}
}
pub fn zpwr_list_colors() -> HashMap<String, String> {
let colors = ZstyleColors::from_zpwr();
colors.tag_colors
}
pub fn load_zpwr_config() -> ZstyleColors {
ZstyleColors::from_zpwr()
}
pub const DEFAULT_PREFIX_COLOR: &str = "1;30";
pub const MENU_SELECTION_COLOR: &str = "37;1;4;44";
#[derive(Clone, Debug, Default)]
pub struct LsColors {
pub directory: String, pub symlink: String, pub executable: String, pub file: String, pub extensions: HashMap<String, String>, }
impl LsColors {
pub fn from_env() -> Self {
let mut colors = Self::default();
if let Ok(ls_colors) = std::env::var("LS_COLORS") {
for entry in ls_colors.split(':') {
if let Some((key, color)) = entry.split_once('=') {
match key {
"di" => colors.directory = color.to_string(),
"ln" => colors.symlink = color.to_string(),
"ex" => colors.executable = color.to_string(),
"fi" => colors.file = color.to_string(),
_ if key.starts_with("*.") => {
let ext = key[2..].to_lowercase();
colors.extensions.insert(ext, color.to_string());
}
_ => {}
}
}
}
}
if colors.directory.is_empty() {
colors.directory = "1;34".to_string(); }
if colors.symlink.is_empty() {
colors.symlink = "1;36".to_string(); }
if colors.executable.is_empty() {
colors.executable = "1;32".to_string(); }
colors
}
pub fn color_for(&self, filename: &str, is_dir: bool, is_exec: bool, is_link: bool) -> &str {
if is_link {
return &self.symlink;
}
if is_dir {
return &self.directory;
}
if is_exec {
return &self.executable;
}
if let Some(ext) = filename.rsplit('.').next() {
if let Some(color) = self.extensions.get(&ext.to_lowercase()) {
return color;
}
}
&self.file
}
}
static LS_COLORS: std::sync::OnceLock<LsColors> = std::sync::OnceLock::new();
pub fn ls_color_for_file(filename: &str, is_dir: bool, is_exec: bool, is_link: bool) -> String {
let colors = LS_COLORS.get_or_init(LsColors::from_env);
colors
.color_for(filename, is_dir, is_exec, is_link)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_tag() {
assert_eq!(
ZstyleColors::extract_tag(":completion:*:*:*:*:aliases"),
Some("aliases")
);
assert_eq!(
ZstyleColors::extract_tag(":completion:*:functions"),
Some("functions")
);
assert_eq!(ZstyleColors::extract_tag(":completion:*"), Some(""));
assert_eq!(
ZstyleColors::extract_tag(":completion:*:zpwr-vim"),
Some("zpwr-vim")
);
assert_eq!(ZstyleColors::extract_tag("not-completion"), None);
}
#[test]
fn test_parse_list_color_value() {
assert_eq!(
ZstyleColors::parse_list_color_value("=(#b)(*)=1;30=34;42;4"),
Some(("1;30".to_string(), "34;42;4".to_string()))
);
assert_eq!(
ZstyleColors::parse_list_color_value("=(#b)(*)=1;30=1;37;44"),
Some(("1;30".to_string(), "1;37;44".to_string()))
);
}
#[test]
fn test_header_colors_format() {
let hc = HeaderColors::default();
let formatted = hc.format("test");
assert!(formatted.contains("-<<"));
assert!(formatted.contains("test"));
assert!(formatted.contains(">>-"));
}
#[test]
fn test_from_zpwr_loads_something() {
let colors = ZstyleColors::from_zpwr();
assert!(colors.tag_colors.len() >= 0);
}
#[test]
fn test_parse_zstyles_basic() {
let content = r#"
zstyle ':completion:*' menu select
zstyle ':completion:*:descriptions' format '%d'
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 2);
assert_eq!(styles[0].pattern, ":completion:*");
assert_eq!(styles[0].style, "menu");
assert_eq!(styles[0].values, vec!["select"]);
assert!(!styles[0].eval);
assert_eq!(styles[1].pattern, ":completion:*:descriptions");
assert_eq!(styles[1].style, "format");
assert_eq!(styles[1].values, vec!["%d"]);
}
#[test]
fn test_parse_zstyles_with_builtin() {
let content = r#"
builtin zstyle ':completion:*' verbose yes
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 1);
assert_eq!(styles[0].style, "verbose");
assert_eq!(styles[0].values, vec!["yes"]);
}
#[test]
fn test_parse_zstyles_with_eval() {
let content = r#"
zstyle -e ':completion:*' hosts 'reply=($myhosts)'
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 1);
assert!(styles[0].eval);
assert_eq!(styles[0].style, "hosts");
}
#[test]
fn test_parse_zstyles_multiple_values() {
let content = r#"
zstyle ':completion:*' completer _complete _approximate _correct
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 1);
assert_eq!(
styles[0].values,
vec!["_complete", "_approximate", "_correct"]
);
}
#[test]
fn test_parse_zstyles_quoted_values() {
let content = r#"
zstyle ':completion:*' format "-- %d --"
zstyle ':completion:*' list-prompt 'At %p'
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 2);
assert_eq!(styles[0].values, vec!["-- %d --"]);
assert_eq!(styles[1].values, vec!["At %p"]);
}
#[test]
fn test_parse_zstyles_ignores_comments() {
let content = r#"
# This is a comment
zstyle ':completion:*' menu select
# Another comment
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 1);
}
#[test]
fn test_parse_zstyles_ignores_empty_lines() {
let content = r#"
zstyle ':completion:*' menu select
zstyle ':completion:*' verbose yes
"#;
let styles = parse_zstyles_from_content(content);
assert_eq!(styles.len(), 2);
}
#[test]
fn test_parse_ansi_c_string_basic() {
assert_eq!(parse_ansi_c_string("hello"), "hello");
assert_eq!(parse_ansi_c_string(r"hello\nworld"), "hello\nworld");
assert_eq!(parse_ansi_c_string(r"tab\there"), "tab\there");
}
#[test]
fn test_parse_ansi_c_string_escapes() {
assert_eq!(parse_ansi_c_string(r"\\"), "\\");
assert_eq!(parse_ansi_c_string(r"\'"), "'");
assert_eq!(parse_ansi_c_string(r"\r\n"), "\r\n");
}
#[test]
fn test_header_colors_default() {
let hc = HeaderColors::default();
assert_eq!(hc.pre, "-<<");
assert_eq!(hc.post, ">>-");
assert_eq!(hc.pre_color, "1;31");
assert_eq!(hc.text_color, "34");
assert_eq!(hc.post_color, "1;31");
}
#[test]
fn test_zstyle_colors_default() {
let colors = ZstyleColors::default();
assert!(colors.menu_selection.is_empty());
assert!(colors.prefix_color.is_empty());
assert!(colors.tag_colors.is_empty());
}
#[test]
fn test_parsed_zstyle_struct() {
let zstyle = ParsedZstyle {
pattern: ":completion:*".to_string(),
style: "menu".to_string(),
values: vec!["select".to_string()],
eval: false,
};
assert_eq!(zstyle.pattern, ":completion:*");
assert_eq!(zstyle.style, "menu");
assert!(!zstyle.eval);
}
}
#[derive(Debug, Clone)]
pub struct ParsedZstyle {
pub pattern: String,
pub style: String,
pub values: Vec<String>,
pub eval: bool,
}
pub fn parse_zstyles_from_config() -> Vec<ParsedZstyle> {
let mut styles = Vec::new();
if let Ok(home) = std::env::var("HOME") {
let zshrc = format!("{}/.zshrc", home);
if let Ok(content) = std::fs::read_to_string(&zshrc) {
styles.extend(parse_zstyles_from_content(&content));
}
let zpwr_zshrc = format!("{}/.zpwr/install/.zshrc", home);
if let Ok(content) = std::fs::read_to_string(&zpwr_zshrc) {
styles.extend(parse_zstyles_from_content(&content));
}
let zstyle_file = format!("{}/.zpwr/autoload/common/zpwrBindZstyle", home);
if let Ok(content) = std::fs::read_to_string(&zstyle_file) {
styles.extend(parse_zstyles_from_content(&content));
}
let menu_file = format!("{}/.zpwr/autoload/common/zpwrBindMenu", home);
if let Ok(content) = std::fs::read_to_string(&menu_file) {
styles.extend(parse_zstyles_from_content(&content));
}
}
styles
}
pub fn parse_zstyles_from_content(content: &str) -> Vec<ParsedZstyle> {
let mut styles = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("builtin ").unwrap_or(line);
if !line.starts_with("zstyle ") {
continue;
}
let rest = &line[7..].trim_start();
let (eval, rest) = if let Some(after_e) = rest.strip_prefix("-e ") {
(true, after_e.trim_start())
} else {
(false, *rest)
};
let (pattern, rest) = extract_zstyle_arg(rest);
if pattern.is_none() {
continue;
}
let pattern = pattern.unwrap();
let rest = rest.trim_start();
let (style, rest) = extract_zstyle_arg(rest);
if style.is_none() {
continue;
}
let style = style.unwrap();
let mut values = Vec::new();
let mut remaining = rest.trim_start();
while !remaining.is_empty() {
let (val, r) = extract_zstyle_arg(remaining);
if let Some(v) = val {
values.push(v);
remaining = r.trim_start();
} else {
break;
}
}
styles.push(ParsedZstyle {
pattern,
style,
values,
eval,
});
}
styles
}
fn extract_zstyle_arg(s: &str) -> (Option<String>, &str) {
let s = s.trim_start();
if s.is_empty() {
return (None, s);
}
if s.starts_with('\'') {
let mut i = 1;
let chars: Vec<char> = s.chars().collect();
while i < chars.len() {
if chars[i] == '\'' {
if i + 1 < chars.len() && chars[i + 1] == '\'' {
i += 2;
continue;
}
let content: String = chars[1..i].iter().collect();
let content = content.replace("''", "'");
return (Some(content), &s[i + 1..]);
}
i += 1;
}
return (None, s);
}
if s.starts_with('"') {
let mut i = 1;
let chars: Vec<char> = s.chars().collect();
while i < chars.len() {
if chars[i] == '"' && (i == 0 || chars[i - 1] != '\\') {
let content: String = chars[1..i].iter().collect();
return (Some(content), &s[i + 1..]);
}
i += 1;
}
return (None, s);
}
if let Some(after_dq) = s.strip_prefix("$'") {
if let Some(end) = after_dq.find('\'') {
let content = &after_dq[..end];
let content = parse_ansi_c_string(content);
return (Some(content), &after_dq[end + 1..]);
}
return (None, s);
}
let end = s.find(|c: char| c.is_whitespace()).unwrap_or(s.len());
if end == 0 {
return (None, s);
}
(Some(s[..end].to_string()), &s[end..])
}
fn parse_ansi_c_string(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('e') => result.push('\x1b'),
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('\\') => result.push('\\'),
Some('\'') => result.push('\''),
Some('[') => result.push('['),
Some('0') => {
let mut octal = String::new();
while octal.len() < 3 && chars.peek().is_some_and(|c| c.is_ascii_digit()) {
octal.push(chars.next().unwrap());
}
if let Ok(n) = u8::from_str_radix(&octal, 8) {
result.push(n as char);
}
}
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}