use crate::ported::bindings::tmux::TmuxVersionInfo;
use crate::ported::config::{POWERLINE_ROOT, TMUX_CONFIG_DIRECTORY};
use crate::ported::lib::shell::which;
use regex::Regex;
use std::path::PathBuf;
use std::sync::OnceLock;
#[allow(non_snake_case)]
pub fn CONFIG_FILE_NAME() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
Regex::new(
r"^powerline_tmux_(?P<major>\d+)\.(?P<minor>\d+)(?P<suffix>[a-z]+)?(?:_(?P<mod>plus|minus))?\.conf$",
)
.unwrap()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfigMatcher {
Exact,
Plus,
Minus,
}
impl ConfigMatcher {
pub fn applies(self, file_version: &TmuxVersionInfo, tmux_version: &TmuxVersionInfo) -> bool {
match self {
ConfigMatcher::Exact => {
file_version.major == tmux_version.major && file_version.minor == tmux_version.minor
}
ConfigMatcher::Plus => {
(file_version.major, file_version.minor) <= (tmux_version.major, tmux_version.minor)
}
ConfigMatcher::Minus => {
(file_version.major, file_version.minor) >= (tmux_version.major, tmux_version.minor)
}
}
}
pub fn priority(self) -> i32 {
match self {
ConfigMatcher::Exact => 3,
ConfigMatcher::Plus => 2,
ConfigMatcher::Minus => 1,
}
}
}
#[derive(Debug, Clone)]
pub struct TmuxConfigFile {
pub path: PathBuf,
pub matcher: ConfigMatcher,
pub priority: i32,
pub file_version: TmuxVersionInfo,
}
pub fn list_all_tmux_configs() -> Vec<TmuxConfigFile> {
let dir = TMUX_CONFIG_DIRECTORY();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut out: Vec<TmuxConfigFile> = Vec::new();
for entry in entries.flatten() {
let fname = entry.file_name();
let fname_str = match fname.to_str() {
Some(s) => s,
None => continue,
};
let captures = match CONFIG_FILE_NAME().captures(fname_str) {
Some(c) => c,
None => continue,
};
if captures.name("suffix").is_some() {
continue;
}
let major: f64 = match captures.name("major").and_then(|m| m.as_str().parse().ok()) {
Some(n) => n,
None => continue,
};
let minor: i32 = match captures.name("minor").and_then(|m| m.as_str().parse().ok()) {
Some(n) => n,
None => continue,
};
let mod_str = captures.name("mod").map(|m| m.as_str());
let matcher = match mod_str {
None => ConfigMatcher::Exact,
Some("plus") => ConfigMatcher::Plus,
Some("minus") => ConfigMatcher::Minus,
Some(_) => continue,
};
out.push(TmuxConfigFile {
path: entry.path(),
matcher,
priority: matcher.priority(),
file_version: TmuxVersionInfo {
major,
minor,
suffix: None,
},
});
}
out
}
pub fn get_tmux_configs(version: &TmuxVersionInfo) -> Vec<(PathBuf, i64)> {
let mut out = Vec::new();
for cfg in list_all_tmux_configs() {
if cfg.matcher.applies(&cfg.file_version, version) {
let sort_key = (cfg.priority as i64)
+ (cfg.file_version.minor as i64) * 10
+ (cfg.file_version.major as i64) * 10_000;
out.push((cfg.path, sort_key));
}
}
out
}
#[derive(Debug, Clone)]
pub struct EmptyArgs {
pub ext: Vec<String>,
pub side: String,
pub config_path: Option<String>,
}
impl EmptyArgs {
pub fn new(ext: &str, _config_path: Option<&str>) -> Self {
EmptyArgs {
ext: vec![ext.to_string()],
side: "left".to_string(),
config_path: None,
}
}
}
#[allow(non_snake_case)]
pub fn TMUX_VAR_RE() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"\$(_POWERLINE_\w+)").unwrap())
}
pub fn check_command(cmd: &str) -> Option<String> {
if which(cmd).is_some() {
Some(cmd.to_string())
} else {
None
}
}
pub fn deduce_command() -> Option<String> {
if let Some(c) = check_command("powerline") {
return Some(c);
}
let p = POWERLINE_ROOT().join("scripts").join("powerline");
if let Some(c) = check_command(&p.to_string_lossy()) {
return Some(c);
}
if which("sh").is_some() && which("sed").is_some() && which("socat").is_some() {
let p = POWERLINE_ROOT().join("client").join("powerline.sh");
if let Some(c) = check_command(&p.to_string_lossy()) {
return Some(c);
}
}
let p = POWERLINE_ROOT().join("client").join("powerline.py");
if let Some(c) = check_command(&p.to_string_lossy()) {
return Some(c);
}
if let Some(c) = check_command("powerline-render") {
return Some(c);
}
let p = POWERLINE_ROOT().join("scripts").join("powerline-render");
check_command(&p.to_string_lossy())
}
pub fn replace_env(s: &str, tmux_environ: &std::collections::HashMap<String, String>) -> String {
TMUX_VAR_RE()
.replace_all(s, |caps: ®ex::Captures| {
let varname = caps.get(1).map(|m| m.as_str()).unwrap_or("");
tmux_environ
.get(varname)
.cloned()
.unwrap_or_else(|| caps.get(0).unwrap().as_str().to_string())
})
.into_owned()
}
pub fn parse_tmux_file_line(line: &str) -> Option<Vec<String>> {
let trimmed = line.trim_end_matches('\n');
if trimmed.starts_with('#') || trimmed.is_empty() {
return None;
}
Some(shlex_split(trimmed))
}
fn shlex_split(s: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(c) = chars.next() {
match c {
'\\' if !in_single => {
if let Some(next) = chars.next() {
current.push(next);
}
}
'\'' if !in_double => {
in_single = !in_single;
}
'"' if !in_single => {
in_double = !in_double;
}
' ' | '\t' if !in_single && !in_double => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
out.push(current);
}
out
}
pub fn sorted_tmux_configs(version: &TmuxVersionInfo) -> Vec<(PathBuf, i64)> {
let mut entries = get_tmux_configs(version);
entries.sort_by_key(|(_, priority)| *priority);
entries
}
pub fn uses_check_env_vars(
component: &str,
shell: Option<&str>,
environ: &std::collections::HashMap<String, String>,
) -> bool {
let component_upper = component.to_uppercase();
let shells: Vec<&str> = match shell {
Some(s) => vec![s, "shell"],
None => vec!["shell"],
};
for sh in shells {
let varname = format!("POWERLINE_NO_{}_{}", sh.to_uppercase(), component_upper);
if environ
.get(&varname)
.map(|s| !s.is_empty())
.unwrap_or(false)
{
return true;
}
}
false
}
pub fn uses_component_exit_code(
config: &serde_json::Map<String, serde_json::Value>,
component: &str,
) -> i32 {
let components = config
.get("ext")
.and_then(|v| v.as_object())
.and_then(|m| m.get("shell"))
.and_then(|v| v.as_object())
.and_then(|m| m.get("components"))
.and_then(|v| v.as_array());
let component_in = match components {
Some(arr) => arr.iter().any(|v| v.as_str() == Some(component)),
None => component == "tmux" || component == "prompt",
};
if component_in {
0
} else {
1
}
}
pub fn shell_command() -> Option<String> {
deduce_command()
}
#[cfg(test)]
mod tests {
use super::*;
fn ver(major: f64, minor: i32) -> TmuxVersionInfo {
TmuxVersionInfo {
major,
minor,
suffix: None,
}
}
#[test]
fn config_file_name_matches_standard_format() {
let re = CONFIG_FILE_NAME();
assert!(re.is_match("powerline_tmux_2.1.conf"));
assert!(re.is_match("powerline_tmux_1.8_plus.conf"));
assert!(re.is_match("powerline_tmux_1.8_minus.conf"));
assert!(!re.is_match("powerline_tmux_2.1.txt"));
assert!(!re.is_match("powerline_2.1.conf"));
}
#[test]
fn exact_matcher_requires_same_major_minor() {
let m = ConfigMatcher::Exact;
assert!(m.applies(&ver(2.0, 1), &ver(2.0, 1)));
assert!(!m.applies(&ver(2.0, 1), &ver(2.0, 2)));
assert!(!m.applies(&ver(2.0, 1), &ver(3.0, 1)));
}
#[test]
fn plus_matcher_applies_when_file_lte_tmux() {
let m = ConfigMatcher::Plus;
assert!(m.applies(&ver(1.0, 8), &ver(1.0, 8)));
assert!(m.applies(&ver(1.0, 8), &ver(2.0, 1)));
assert!(!m.applies(&ver(2.0, 0), &ver(1.0, 9)));
}
#[test]
fn minus_matcher_applies_when_file_gte_tmux() {
let m = ConfigMatcher::Minus;
assert!(m.applies(&ver(1.0, 8), &ver(1.0, 8)));
assert!(m.applies(&ver(2.0, 1), &ver(1.0, 9)));
assert!(!m.applies(&ver(1.0, 8), &ver(2.0, 1)));
}
#[test]
fn priority_order_matches_upstream() {
assert_eq!(ConfigMatcher::Exact.priority(), 3);
assert_eq!(ConfigMatcher::Plus.priority(), 2);
assert_eq!(ConfigMatcher::Minus.priority(), 1);
}
#[test]
fn empty_args_init_stores_ext_as_single_element_list() {
let a = EmptyArgs::new("tmux", None);
assert_eq!(a.ext, vec!["tmux".to_string()]);
}
#[test]
fn empty_args_init_defaults_side_to_left() {
let a = EmptyArgs::new("tmux", None);
assert_eq!(a.side, "left");
}
#[test]
fn empty_args_init_pins_config_path_to_none() {
let a = EmptyArgs::new("tmux", Some("/etc/powerline"));
assert!(a.config_path.is_none());
}
#[test]
fn tmux_var_re_matches_dollar_powerline_var() {
let re = TMUX_VAR_RE();
assert!(re.is_match("$_POWERLINE_FOO"));
assert!(re.is_match("foo $_POWERLINE_BAR_X bar"));
assert!(!re.is_match("_POWERLINE_NOPE"));
assert!(!re.is_match("$POWERLINE_NO_UNDER"));
}
#[test]
fn tmux_var_re_captures_group_after_dollar() {
let re = TMUX_VAR_RE();
let cap = re.captures("$_POWERLINE_FG").unwrap();
assert_eq!(cap.get(1).unwrap().as_str(), "_POWERLINE_FG");
}
#[test]
fn check_command_returns_some_for_real_binary() {
let r = check_command("sh");
assert_eq!(r, Some("sh".to_string()));
}
#[test]
fn check_command_returns_none_for_missing_binary() {
let r = check_command("definitely-not-on-this-system-xyz-abc");
assert!(r.is_none());
}
#[test]
fn deduce_command_returns_some_or_none() {
let r = deduce_command();
if let Some(s) = r {
assert!(!s.is_empty());
}
}
#[test]
fn replace_env_substitutes_known_var() {
let mut env = std::collections::HashMap::new();
env.insert("_POWERLINE_FG".to_string(), "#abcdef".to_string());
let r = replace_env("$_POWERLINE_FG", &env);
assert_eq!(r, "#abcdef");
}
#[test]
fn replace_env_passes_through_unknown_var() {
let env = std::collections::HashMap::new();
let r = replace_env("$_POWERLINE_MISSING", &env);
assert_eq!(r, "$_POWERLINE_MISSING");
}
#[test]
fn replace_env_substitutes_multiple_in_one_string() {
let mut env = std::collections::HashMap::new();
env.insert("_POWERLINE_FG".to_string(), "fg".to_string());
env.insert("_POWERLINE_BG".to_string(), "bg".to_string());
let r = replace_env("$_POWERLINE_FG/$_POWERLINE_BG", &env);
assert_eq!(r, "fg/bg");
}
#[test]
fn replace_env_leaves_non_dollar_powerline_text_alone() {
let env = std::collections::HashMap::new();
let r = replace_env("plain text", &env);
assert_eq!(r, "plain text");
}
#[test]
fn parse_tmux_file_line_skips_comments() {
assert!(parse_tmux_file_line("# comment").is_none());
assert!(parse_tmux_file_line("#another").is_none());
}
#[test]
fn parse_tmux_file_line_skips_blank_lines() {
assert!(parse_tmux_file_line("\n").is_none());
assert!(parse_tmux_file_line("").is_none());
}
#[test]
fn parse_tmux_file_line_splits_simple_args() {
let r = parse_tmux_file_line("set -g status on").unwrap();
assert_eq!(r, vec!["set", "-g", "status", "on"]);
}
#[test]
fn parse_tmux_file_line_handles_quoted_args() {
let r = parse_tmux_file_line("set status-left \"a b c\"").unwrap();
assert_eq!(r, vec!["set", "status-left", "a b c"]);
}
#[test]
fn sorted_tmux_configs_returns_entries_in_ascending_priority_order() {
let version = TmuxVersionInfo {
major: 2.0,
minor: 1,
suffix: None,
};
let entries = sorted_tmux_configs(&version);
let mut prev = i64::MIN;
for (_, priority) in &entries {
assert!(*priority >= prev);
prev = *priority;
}
}
#[test]
fn uses_check_env_vars_returns_true_for_powerline_no_var() {
let mut env = std::collections::HashMap::new();
env.insert("POWERLINE_NO_BASH_PROMPT".to_string(), "1".to_string());
assert!(uses_check_env_vars("prompt", Some("bash"), &env));
}
#[test]
fn uses_check_env_vars_checks_shell_fallback() {
let mut env = std::collections::HashMap::new();
env.insert("POWERLINE_NO_SHELL_TMUX".to_string(), "1".to_string());
assert!(uses_check_env_vars("tmux", Some("zsh"), &env));
assert!(uses_check_env_vars("tmux", None, &env));
}
#[test]
fn uses_check_env_vars_returns_false_when_unset() {
let env = std::collections::HashMap::new();
assert!(!uses_check_env_vars("prompt", Some("bash"), &env));
}
#[test]
fn uses_check_env_vars_returns_false_when_var_is_empty() {
let mut env = std::collections::HashMap::new();
env.insert("POWERLINE_NO_BASH_PROMPT".to_string(), "".to_string());
assert!(!uses_check_env_vars("prompt", Some("bash"), &env));
}
#[test]
fn uses_component_exit_code_returns_0_when_component_in_config() {
let cfg: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(r#"{"ext": {"shell": {"components": ["tmux", "prompt"]}}}"#)
.unwrap();
assert_eq!(uses_component_exit_code(&cfg, "tmux"), 0);
assert_eq!(uses_component_exit_code(&cfg, "prompt"), 0);
}
#[test]
fn uses_component_exit_code_returns_1_when_component_not_in_config() {
let cfg: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(r#"{"ext": {"shell": {"components": ["prompt"]}}}"#).unwrap();
assert_eq!(uses_component_exit_code(&cfg, "tmux"), 1);
}
#[test]
fn uses_component_exit_code_default_components_when_missing() {
let cfg = serde_json::Map::new();
assert_eq!(uses_component_exit_code(&cfg, "tmux"), 0);
assert_eq!(uses_component_exit_code(&cfg, "prompt"), 0);
assert_eq!(uses_component_exit_code(&cfg, "other"), 1);
}
#[test]
fn shell_command_returns_deduce_command_result() {
assert_eq!(shell_command(), deduce_command());
}
}