use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use log::*;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("error parsing {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("config file {path} uses version {version} format.\n\
Run `lx --upgrade-config` to migrate it to version {CONFIG_VERSION}.")]
NeedsUpgrade { path: PathBuf, version: String },
#[error("personality inheritance cycle: {chain}")]
InheritanceCycle { chain: String },
#[error("personality '{child}' inherits from '{parent}', which does not exist")]
MissingParent { child: String, parent: String },
#[error("{path} is already at version {CONFIG_VERSION}; no upgrade needed")]
AlreadyCurrent { path: PathBuf },
}
trait IoResultExt<T> {
fn with_path(self, path: impl Into<PathBuf>) -> Result<T, ConfigError>;
}
impl<T> IoResultExt<T> for std::io::Result<T> {
fn with_path(self, path: impl Into<PathBuf>) -> Result<T, ConfigError> {
self.map_err(|source| ConfigError::Io { path: path.into(), source })
}
}
pub static CONFIG: LazyLock<Option<Config>> = LazyLock::new(load_config);
enum SettingKind {
Str,
Bool,
Int,
}
struct SettingDef {
key: &'static str,
flag: &'static str,
kind: SettingKind,
}
static SETTING_FLAGS: &[SettingDef] = &[
SettingDef { key: "oneline", flag: "--oneline", kind: SettingKind::Bool },
SettingDef { key: "long", flag: "--long", kind: SettingKind::Bool },
SettingDef { key: "grid", flag: "--grid", kind: SettingKind::Bool },
SettingDef { key: "across", flag: "--across", kind: SettingKind::Bool },
SettingDef { key: "recurse", flag: "--recurse", kind: SettingKind::Bool },
SettingDef { key: "tree", flag: "--tree", kind: SettingKind::Bool },
SettingDef { key: "classify", flag: "--classify", kind: SettingKind::Str },
SettingDef { key: "colour", flag: "--colour", kind: SettingKind::Str },
SettingDef { key: "color", flag: "--colour", kind: SettingKind::Str },
SettingDef { key: "colour-scale", flag: "--colour-scale", kind: SettingKind::Str },
SettingDef { key: "color-scale", flag: "--colour-scale", kind: SettingKind::Str },
SettingDef { key: "icons", flag: "--icons", kind: SettingKind::Str },
SettingDef { key: "all", flag: "--all", kind: SettingKind::Bool },
SettingDef { key: "list-dirs", flag: "--list-dirs", kind: SettingKind::Bool },
SettingDef { key: "level", flag: "--level", kind: SettingKind::Int },
SettingDef { key: "reverse", flag: "--reverse", kind: SettingKind::Bool },
SettingDef { key: "sort", flag: "--sort", kind: SettingKind::Str },
SettingDef { key: "group-dirs", flag: "--group-dirs", kind: SettingKind::Str },
SettingDef { key: "only-dirs", flag: "--only-dirs", kind: SettingKind::Bool },
SettingDef { key: "only-files", flag: "--only-files", kind: SettingKind::Bool },
SettingDef { key: "size-style", flag: "--size-style", kind: SettingKind::Str },
SettingDef { key: "binary", flag: "--binary", kind: SettingKind::Bool },
SettingDef { key: "bytes", flag: "--bytes", kind: SettingKind::Bool },
SettingDef { key: "header", flag: "--header", kind: SettingKind::Bool },
SettingDef { key: "inode", flag: "--inode", kind: SettingKind::Bool },
SettingDef { key: "links", flag: "--links", kind: SettingKind::Bool },
SettingDef { key: "blocks", flag: "--blocks", kind: SettingKind::Bool },
SettingDef { key: "group", flag: "--group", kind: SettingKind::Bool },
SettingDef { key: "uid", flag: "--uid", kind: SettingKind::Bool },
SettingDef { key: "gid", flag: "--gid", kind: SettingKind::Bool },
SettingDef { key: "time-style", flag: "--time-style", kind: SettingKind::Str },
SettingDef { key: "modified", flag: "--modified", kind: SettingKind::Bool },
SettingDef { key: "changed", flag: "--changed", kind: SettingKind::Bool },
SettingDef { key: "accessed", flag: "--accessed", kind: SettingKind::Bool },
SettingDef { key: "created", flag: "--created", kind: SettingKind::Bool },
SettingDef { key: "total-size", flag: "--total-size", kind: SettingKind::Bool },
SettingDef { key: "count", flag: "--count", kind: SettingKind::Bool },
SettingDef { key: "extended", flag: "--extended", kind: SettingKind::Bool },
SettingDef { key: "octal-permissions", flag: "--octal-permissions", kind: SettingKind::Bool },
SettingDef { key: "flags", flag: "--flags", kind: SettingKind::Bool },
SettingDef { key: "ignore", flag: "--ignore", kind: SettingKind::Str },
SettingDef { key: "prune", flag: "--prune", kind: SettingKind::Str },
SettingDef { key: "symlinks", flag: "--symlinks", kind: SettingKind::Str },
SettingDef { key: "classify", flag: "--classify", kind: SettingKind::Str },
SettingDef { key: "vcs", flag: "--vcs", kind: SettingKind::Str },
SettingDef { key: "vcs-status", flag: "--vcs-status", kind: SettingKind::Bool },
SettingDef { key: "vcs-ignore", flag: "--vcs-ignore", kind: SettingKind::Bool },
SettingDef { key: "vcs-repos", flag: "--vcs-repos", kind: SettingKind::Bool },
SettingDef { key: "theme", flag: "--theme", kind: SettingKind::Str },
SettingDef { key: "width", flag: "--width", kind: SettingKind::Int },
SettingDef { key: "absolute", flag: "--absolute", kind: SettingKind::Bool },
SettingDef { key: "hyperlink", flag: "--hyperlink", kind: SettingKind::Str },
SettingDef { key: "quotes", flag: "--quotes", kind: SettingKind::Str },
SettingDef { key: "permissions", flag: "--permissions", kind: SettingKind::Bool },
SettingDef { key: "filesize", flag: "--filesize", kind: SettingKind::Bool },
SettingDef { key: "user", flag: "--user", kind: SettingKind::Bool },
SettingDef { key: "no-permissions", flag: "--no-permissions", kind: SettingKind::Bool },
SettingDef { key: "no-filesize", flag: "--no-filesize", kind: SettingKind::Bool },
SettingDef { key: "no-user", flag: "--no-user", kind: SettingKind::Bool },
SettingDef { key: "no-time", flag: "--no-time", kind: SettingKind::Bool },
SettingDef { key: "no-modified", flag: "--no-modified", kind: SettingKind::Bool },
SettingDef { key: "no-changed", flag: "--no-changed", kind: SettingKind::Bool },
SettingDef { key: "no-accessed", flag: "--no-accessed", kind: SettingKind::Bool },
SettingDef { key: "no-created", flag: "--no-created", kind: SettingKind::Bool },
SettingDef { key: "no-icons", flag: "--no-icons", kind: SettingKind::Bool },
SettingDef { key: "no-inode", flag: "--no-inode", kind: SettingKind::Bool },
SettingDef { key: "no-group", flag: "--no-group", kind: SettingKind::Bool },
SettingDef { key: "no-uid", flag: "--no-uid", kind: SettingKind::Bool },
SettingDef { key: "no-gid", flag: "--no-gid", kind: SettingKind::Bool },
SettingDef { key: "no-links", flag: "--no-links", kind: SettingKind::Bool },
SettingDef { key: "no-blocks", flag: "--no-blocks", kind: SettingKind::Bool },
SettingDef { key: "no-octal", flag: "--no-octal", kind: SettingKind::Bool },
SettingDef { key: "no-header", flag: "--no-header", kind: SettingKind::Bool },
SettingDef { key: "no-count", flag: "--no-count", kind: SettingKind::Bool },
SettingDef { key: "no-total-size", flag: "--no-total-size", kind: SettingKind::Bool },
];
fn find_setting(key: &str) -> Option<&'static SettingDef> {
SETTING_FLAGS.iter().find(|s| s.key == key)
}
fn removed_setting_hint(key: &str) -> Option<&'static str> {
match key {
"time" => Some(
"the `time` setting was removed in config version 0.5; \
use `modified`, `changed`, `accessed`, or `created` \
(each a boolean) to add timestamp columns"
),
"numeric" => Some(
"the `numeric` setting was removed in config version 0.5; \
UID and GID are now first-class columns. Use \
`uid = true, gid = true, no-user = true, no-group = true` \
for the old `-n` behaviour, or pick whichever columns you want"
),
_ => None,
}
}
fn settings_to_args(settings: &HashMap<String, toml::Value>, context: &str) -> Vec<OsString> {
let mut args = Vec::new();
for (key, value) in settings {
let Some(def) = find_setting(key) else {
if let Some(hint) = removed_setting_hint(key) {
eprintln!("lx: setting '{key}' in {context}: {hint}");
} else {
eprintln!("lx: unknown setting '{key}' in {context}");
}
continue;
};
match def.kind {
SettingKind::Bool => {
let truthy = match value {
toml::Value::Boolean(b) => *b,
toml::Value::String(s) => s == "true",
_ => {
warn!("Expected boolean for '{key}' in {context}; ignoring");
continue;
}
};
if truthy {
args.push(def.flag.into());
}
}
SettingKind::Str => {
let s = if let toml::Value::String(s) = value { s.as_str() } else {
warn!("Expected string for '{key}' in {context}; ignoring");
continue;
};
args.push(format!("{}={s}", def.flag).into());
}
SettingKind::Int => {
let n = match value {
toml::Value::Integer(n) => *n,
toml::Value::String(s) => {
if let Ok(n) = s.parse::<i64>() { n } else {
warn!("Expected integer for '{key}' in {context}; ignoring");
continue;
}
}
_ => {
warn!("Expected integer for '{key}' in {context}; ignoring");
continue;
}
};
args.push(format!("{}={n}", def.flag).into());
}
}
}
args
}
#[derive(Debug, Clone)]
pub enum StringOrList {
Str(String),
List(Vec<String>),
}
impl StringOrList {
pub fn to_csv(&self) -> String {
match self {
Self::Str(s) => s.clone(),
Self::List(v) => v.join(","),
}
}
#[allow(dead_code)] pub fn to_vec(&self) -> Vec<String> {
match self {
Self::Str(s) => s.split(',').map(|s| s.trim().to_string()).collect(),
Self::List(v) => v.clone(),
}
}
}
impl<'de> Deserialize<'de> for StringOrList {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de>,
{
use serde::de;
struct Visitor;
impl<'de> de::Visitor<'de> for Visitor {
type Value = StringOrList;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a string or array of strings")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<StringOrList, E> {
Ok(StringOrList::Str(v.to_string()))
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<StringOrList, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
v.push(s);
}
Ok(StringOrList::List(v))
}
}
deserializer.deserialize_any(Visitor)
}
}
pub const CONFIG_VERSION: &str = "0.5";
const ACCEPTED_VERSIONS: &[&str] = &["0.3", "0.4", "0.5"];
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub version: Option<String>,
#[serde(default)]
pub format: HashMap<String, Vec<String>>,
#[serde(default)]
pub personality: HashMap<String, PersonalityDef>,
#[serde(default)]
pub theme: HashMap<String, ThemeDef>,
#[serde(default)]
pub style: HashMap<String, StyleDef>,
#[serde(default)]
pub class: HashMap<String, Vec<String>>,
#[serde(skip)]
pub drop_in_paths: Vec<PathBuf>,
}
impl Config {
fn merge(&mut self, other: Config) {
for (k, v) in other.format { self.format.insert(k, v); }
for (k, v) in other.personality { self.personality.insert(k, v); }
for (k, v) in other.theme { self.theme.insert(k, v); }
for (k, v) in other.style { self.style.insert(k, v); }
for (k, v) in other.class { self.class.insert(k, v); }
}
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(default, rename_all = "kebab-case")]
pub struct ThemeDef {
pub inherits: Option<String>,
pub use_style: Option<String>,
#[serde(flatten)]
pub ui: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(default)]
pub struct StyleDef {
#[serde(default, rename = "class")]
pub classes: HashMap<String, String>,
#[serde(flatten)]
pub patterns: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(default)]
pub struct ConditionalOverride {
#[serde(default)]
pub env: HashMap<String, toml::Value>,
#[serde(flatten)]
pub settings: HashMap<String, toml::Value>,
}
impl ConditionalOverride {
fn matches(&self) -> bool {
self.env.iter().all(|(key, condition)| {
let actual = env::var(key).unwrap_or_default();
match condition {
toml::Value::String(expected) => actual == *expected,
toml::Value::Boolean(true) => env::var(key).is_ok(),
toml::Value::Boolean(false) => env::var(key).is_err(),
_ => true,
}
})
}
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(default)]
pub struct PersonalityDef {
pub inherits: Option<String>,
pub format: Option<String>,
pub columns: Option<StringOrList>,
#[serde(default)]
pub when: Vec<ConditionalOverride>,
#[serde(flatten)]
pub settings: HashMap<String, toml::Value>,
}
impl PersonalityDef {
pub fn to_args(&self) -> Vec<OsString> {
let mut args = Vec::new();
if let Some(ref cols) = self.columns {
args.push(format!("--columns={}", cols.to_csv()).into());
} else if let Some(ref fmt) = self.format {
args.push(format!("--format={fmt}").into());
}
args.extend(settings_to_args(&self.settings, "[personality]"));
args
}
}
pub fn find_config_path() -> Option<PathBuf> {
if let Ok(path) = env::var("LX_CONFIG") {
let p = PathBuf::from(&path);
if p.is_file() {
debug!("Config from LX_CONFIG: {}", p.display());
return Some(p);
}
debug!("LX_CONFIG={path}: not a file, no config");
return None;
}
if let Some(home) = home_dir() {
let p = home.join(".lxconfig.toml");
if p.is_file() {
debug!("Config from home dir: {}", p.display());
return Some(p);
}
}
let xdg = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
home_dir()
.map(|h| h.join(".config"))
.unwrap_or_default()
});
let p = xdg.join("lx").join("config.toml");
if p.is_file() {
debug!("Config from XDG: {}", p.display());
return Some(p);
}
#[cfg(target_os = "macos")]
if let Some(home) = home_dir() {
let p = home.join("Library/Application Support/lx/config.toml");
if p.is_file() {
debug!("Config from macOS Library: {}", p.display());
return Some(p);
}
}
None
}
fn find_drop_in_dir(main_config: Option<&Path>) -> Option<PathBuf> {
if let Some(config_path) = main_config {
if let Some(parent) = config_path.parent() {
let d = parent.join("conf.d");
if d.is_dir() {
return Some(d);
}
}
}
let xdg = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
home_dir()
.map(|h| h.join(".config"))
.unwrap_or_default()
});
let d = xdg.join("lx").join("conf.d");
if d.is_dir() {
return Some(d);
}
#[cfg(target_os = "macos")]
if let Some(home) = home_dir() {
let d = home.join("Library/Application Support/lx/conf.d");
if d.is_dir() {
return Some(d);
}
}
None
}
fn load_drop_ins(dir: &Path) -> Vec<(PathBuf, Config)> {
let mut entries: Vec<PathBuf> = match fs::read_dir(dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
.collect(),
Err(e) => {
warn!("conf.d: failed to read {}: {e}", dir.display());
return Vec::new();
}
};
entries.sort();
let mut fragments = Vec::new();
for path in entries {
match fs::read_to_string(&path) {
Ok(contents) => {
match toml::from_str::<Config>(&contents) {
Ok(cfg) => {
debug!("conf.d: loaded {}", path.display());
fragments.push((path, cfg));
}
Err(e) => {
warn!("conf.d: parse error in {}: {e}", path.display());
}
}
}
Err(e) => {
warn!("conf.d: failed to read {}: {e}", path.display());
}
}
}
fragments
}
fn try_load_config() -> Result<Option<Config>, ConfigError> {
let config_path = find_config_path();
let mut config = if let Some(ref path) = config_path {
let contents = fs::read_to_string(path).with_path(path)?;
let version = detect_config_version(&contents);
if !ACCEPTED_VERSIONS.contains(&version) {
return Err(ConfigError::NeedsUpgrade {
path: path.clone(),
version: version.to_string(),
});
}
let cfg: Config = toml::from_str(&contents)
.map_err(|source| ConfigError::Parse { path: path.clone(), source })?;
if version == "0.3" {
let has_when = cfg.personality.values().any(|p| !p.when.is_empty());
if has_when {
eprintln!("lx: config has [[personality.*.when]] blocks but version is \"0.3\".");
eprintln!(" Change version to \"0.4\" to enable conditional config.");
}
}
info!("Loaded config from {}", path.display());
Some(cfg)
} else {
None
};
if let Some(drop_in_dir) = find_drop_in_dir(config_path.as_deref()) {
let fragments = load_drop_ins(&drop_in_dir);
if !fragments.is_empty() {
let config = config.get_or_insert_with(Config::default);
for (path, fragment) in fragments {
config.drop_in_paths.push(path);
config.merge(fragment);
}
}
}
Ok(config)
}
fn load_config() -> Option<Config> {
match try_load_config() {
Ok(config) => config,
Err(e) => {
eprintln!("lx: {e}");
None
}
}
}
fn detect_config_version(contents: &str) -> &str {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with("version") && trimmed.contains('=') {
if let Some(val) = trimmed.split('=').nth(1) {
let val = val.trim().trim_matches('"');
return match val {
"0.2" => "0.2",
"0.3" => "0.3",
"0.4" => "0.4",
_ => val, };
}
}
}
"0.1" }
pub fn default_config_toml() -> &'static str {
include_str!("../lxconfig.default.toml")
}
pub fn resolve_personality(name: &str) -> Result<Option<PersonalityDef>, ConfigError> {
let mut chain: Vec<PersonalityDef> = Vec::new();
let mut visited: Vec<String> = Vec::new();
let mut current = Some(name.to_string());
while let Some(ref pname) = current {
if visited.contains(pname) {
visited.push(pname.clone());
let chain_str = visited.join(" \u{2192} ");
return Err(ConfigError::InheritanceCycle { chain: chain_str });
}
visited.push(pname.clone());
let Some(def) = lookup_personality(pname) else {
if chain.is_empty() {
return Ok(None); }
return Err(ConfigError::MissingParent {
child: visited[visited.len() - 2].clone(),
parent: pname.clone(),
});
};
let next = def.inherits.clone();
chain.push(def);
current = next;
}
let mut effective = PersonalityDef::default();
for def in chain.into_iter().rev() {
if def.format.is_some() {
effective.format = def.format;
}
if def.columns.is_some() {
effective.columns = def.columns;
}
for (key, value) in def.settings {
effective.settings.insert(key, value);
}
effective.when.extend(def.when);
}
for cond in &effective.when {
if cond.matches() {
debug!("conditional override matched: env = {:?}", cond.env);
for (key, value) in &cond.settings {
effective.settings.insert(key.clone(), value.clone());
}
}
}
Ok(Some(effective))
}
fn lookup_personality(name: &str) -> Option<PersonalityDef> {
if let Some(ref cfg) = *CONFIG
&& let Some(p) = cfg.personality.get(name) {
return Some(p.clone());
}
compiled_personality(name)
}
fn compiled_personality(name: &str) -> Option<PersonalityDef> {
use toml::Value::{Boolean, String as Str};
match name {
"default" => Some(PersonalityDef {
settings: HashMap::from([
("theme".into(), toml::Value::String("exa".into())),
]),
..Default::default()
}),
"lx" => Some(PersonalityDef {
inherits: Some("default".into()),
..Default::default()
}),
"ll" => Some(PersonalityDef {
inherits: Some("lx".into()),
format: Some("long2".into()),
settings: HashMap::from([
("group-dirs".into(), Str("first".into())),
]),
..Default::default()
}),
"lll" => Some(PersonalityDef {
inherits: Some("lx".into()),
format: Some("long3".into()),
settings: HashMap::from([
("group-dirs".into(), Str("first".into())),
("header".into(), Boolean(true)),
("time-style".into(), Str("long-iso".into())),
]),
..Default::default()
}),
"la" => Some(PersonalityDef {
inherits: Some("ll".into()),
settings: HashMap::from([
("all".into(), Boolean(true)),
]),
..Default::default()
}),
"tree" => Some(PersonalityDef {
inherits: Some("default".into()),
format: Some("long2".into()),
settings: HashMap::from([
("tree".into(), Boolean(true)),
("group-dirs".into(), Str("first".into())),
]),
..Default::default()
}),
"ls" => Some(PersonalityDef {
settings: HashMap::from([
("grid".into(), Boolean(true)),
("across".into(), Boolean(true)),
]),
..Default::default()
}),
_ => None,
}
}
pub fn compiled_classes() -> HashMap<String, Vec<String>> {
fn gl(exts: &[&str]) -> Vec<String> {
exts.iter().map(|e| format!("*.{e}")).collect()
}
HashMap::from([
("image".into(), gl(&[
"png", "jfi", "jfif", "jif", "jpe", "jpeg", "jpg", "gif", "bmp",
"tiff", "tif", "ppm", "pgm", "pbm", "pnm", "webp", "raw", "arw",
"svg", "stl", "eps", "dvi", "ps", "cbr", "jpf", "cbz", "xpm",
"ico", "cr2", "orf", "nef", "heif", "avif", "jxl", "j2k", "jp2",
"j2c", "jpx",
])),
("video".into(), gl(&[
"avi", "flv", "m2v", "m4v", "mkv", "mov", "mp4", "mpeg",
"mpg", "ogm", "ogv", "vob", "wmv", "webm", "m2ts", "heic",
])),
("music".into(), gl(&[
"aac", "m4a", "mp3", "ogg", "wma", "mka", "opus",
])),
("lossless".into(), gl(&[
"alac", "ape", "flac", "wav",
])),
("crypto".into(), gl(&[
"asc", "enc", "gpg", "pgp", "sig", "signature", "pfx", "p12",
])),
("document".into(), gl(&[
"djvu", "doc", "docx", "dvi", "eml", "eps", "fotd", "key",
"keynote", "numbers", "odp", "odt", "pages", "pdf", "ppt",
"pptx", "rtf", "xls", "xlsx",
])),
("compressed".into(), gl(&[
"zip", "tar", "Z", "z", "gz", "bz2", "a", "ar", "7z",
"iso", "dmg", "tc", "rar", "par", "tgz", "xz", "txz",
"lz", "tlz", "lzma", "deb", "rpm", "zst", "lz4", "cpio",
])),
("compiled".into(), gl(&[
"class", "elc", "hi", "o", "pyc", "zwc", "ko",
])),
("temp".into(), gl(&[
"tmp", "swp", "swo", "swn", "bak", "bkp", "bk",
])),
("immediate".into(), vec![
"Makefile".into(), "Cargo.toml".into(), "SConstruct".into(),
"CMakeLists.txt".into(), "build.gradle".into(), "pom.xml".into(),
"Rakefile".into(), "package.json".into(), "Gruntfile.js".into(),
"Gruntfile.coffee".into(), "BUILD".into(), "BUILD.bazel".into(),
"WORKSPACE".into(), "build.xml".into(), "Podfile".into(),
"webpack.config.js".into(), "meson.build".into(),
"composer.json".into(), "RoboFile.php".into(), "PKGBUILD".into(),
"Justfile".into(), "Procfile".into(), "Dockerfile".into(),
"Containerfile".into(), "Vagrantfile".into(), "Brewfile".into(),
"Gemfile".into(), "Pipfile".into(), "build.sbt".into(),
"mix.exs".into(), "bsconfig.json".into(), "tsconfig.json".into(),
]),
])
}
pub fn resolve_classes() -> HashMap<String, Vec<String>> {
let mut classes = compiled_classes();
if let Some(ref cfg) = *CONFIG {
for (name, patterns) in &cfg.class {
classes.insert(name.clone(), patterns.clone());
}
}
classes
}
pub fn compiled_exa_style() -> StyleDef {
StyleDef {
classes: HashMap::from([
("temp".into(), "38;5;244".into()),
("immediate".into(), "bold underline yellow".into()),
("image".into(), "38;5;133".into()),
("video".into(), "38;5;135".into()),
("music".into(), "38;5;92".into()),
("lossless".into(), "38;5;93".into()),
("crypto".into(), "38;5;109".into()),
("document".into(), "38;5;105".into()),
("compressed".into(), "red".into()),
("compiled".into(), "38;5;137".into()),
]),
patterns: HashMap::new(),
}
}
pub fn resolve_style(name: &str) -> Option<StyleDef> {
if let Some(ref cfg) = *CONFIG
&& let Some(s) = cfg.style.get(name) {
return Some(s.clone());
}
match name {
"exa" => Some(compiled_exa_style()),
_ => None,
}
}
pub fn init_config_path() -> PathBuf {
home_dir()
.map(|h| h.join(".lxconfig.toml"))
.unwrap_or_else(|| PathBuf::from(".lxconfig.toml"))
}
pub fn write_init_config(path: &PathBuf) -> std::io::Result<()> {
if path.exists() {
return Err(std::io::Error::other(
format!("{} already exists; remove it first or edit it directly", path.display())
));
}
fs::write(path, default_config_toml())
}
pub fn show_config(personality_name: &str) {
use nu_ansi_term::{Color, Style};
let heading = Style::new().bold().fg(Color::Yellow);
let label = Style::new().bold();
let name = Style::new().bold().fg(Color::Cyan);
let value = Style::new().fg(Color::Green);
let dimmed = Style::new().dimmed();
let config_path = find_config_path();
let has_config = CONFIG.is_some();
println!("{}", heading.paint("lx configuration"));
println!();
match &config_path {
Some(p) => println!("{} {}", label.paint("Config file:"), value.paint(p.display().to_string())),
None => println!("{} {}", label.paint("Config file:"), dimmed.paint("(none)")),
}
println!("{} {}", label.paint("Config version:"), value.paint(CONFIG_VERSION));
if let Some(ref cfg) = *CONFIG {
if !cfg.drop_in_paths.is_empty() {
println!("{} {} file(s) from conf.d/", label.paint("Drop-ins:"), value.paint(cfg.drop_in_paths.len().to_string()));
for p in &cfg.drop_in_paths {
println!(" {}", dimmed.paint(p.display().to_string()));
}
}
}
println!();
println!("{} {}", label.paint("Personality:"), name.paint(personality_name));
let source = if has_config
&& CONFIG.as_ref().unwrap().personality.contains_key(personality_name)
{
"config"
} else {
"builtin"
};
println!(" {} {}", label.paint("source:"), dimmed.paint(source));
if let Ok(Some(p)) = resolve_personality(personality_name) {
if let Some(ref inherits) = p.inherits {
println!(" {} {}", label.paint("inherits:"), name.paint(inherits));
}
if let Some(ref fmt) = p.format {
println!(" {} {}", label.paint("format:"), name.paint(fmt));
}
if let Some(ref cols) = p.columns {
println!(" {} {}", label.paint("columns:"), value.paint(cols.to_csv()));
}
if !p.settings.is_empty() {
println!(" {}",label.paint("settings:"));
let mut keys: Vec<_> = p.settings.keys().collect();
keys.sort();
for key in keys {
println!(" {} = {}", name.paint(key), value.paint(p.settings[key].to_string()));
}
}
}
println!();
let theme_name = resolve_personality(personality_name)
.ok()
.flatten()
.and_then(|p| p.settings.get("theme").and_then(|v| {
if let toml::Value::String(s) = v { Some(s.clone()) } else { None }
}));
if let Some(ref tname) = theme_name {
println!("{} {}", label.paint("Theme:"), name.paint(tname));
let source = if tname == "exa" {
"builtin"
} else if has_config && CONFIG.as_ref().unwrap().theme.contains_key(tname) {
"config"
} else {
"unknown"
};
println!(" {} {}", label.paint("source:"), dimmed.paint(source));
if tname == "exa" {
println!(" {} {} {}", label.paint("use-style:"), name.paint("exa"), dimmed.paint("(implicit)"));
} else {
if let Some(ref cfg) = *CONFIG
&& let Some(theme) = cfg.theme.get(tname) {
if let Some(ref inherits) = theme.inherits {
println!(" {} {}", label.paint("inherits:"), name.paint(inherits));
}
if let Some(ref style) = theme.use_style {
println!(" {} {}", label.paint("use-style:"), name.paint(style));
}
}
}
} else {
println!("{} {}", label.paint("Theme:"), dimmed.paint("(none)"));
}
println!();
let style_name = theme_name.as_deref().and_then(|tn| {
if tn == "exa" {
Some("exa".to_string())
} else if let Some(ref cfg) = *CONFIG {
cfg.theme.get(tn).and_then(|t| t.use_style.clone())
} else {
None
}
});
if let Some(ref sname) = style_name {
println!("{} {}", label.paint("Style:"), name.paint(sname));
let source = if sname == "exa" {
"builtin"
} else if has_config && CONFIG.as_ref().unwrap().style.contains_key(sname) {
"config"
} else {
"unknown"
};
println!(" {} {}", label.paint("source:"), dimmed.paint(source));
if let Some(style) = resolve_style(sname) {
if !style.classes.is_empty() {
println!(" {}", label.paint("class references:"));
let mut keys: Vec<_> = style.classes.keys().collect();
keys.sort();
for key in keys {
println!(" {} = {}", name.paint(key), value.paint(format!("\"{}\"", style.classes[key])));
}
}
if !style.patterns.is_empty() {
println!(" {}", label.paint("file patterns:"));
let mut keys: Vec<_> = style.patterns.keys().collect();
keys.sort();
for key in keys {
println!(" {} = {}", name.paint(format!("\"{key}\"")), value.paint(format!("\"{}\"", style.patterns[key])));
}
}
}
} else {
println!("{} {}", label.paint("Style:"), dimmed.paint("(none)"));
}
println!();
let classes = resolve_classes();
println!("{} {} defined", label.paint("Classes:"), value.paint(classes.len().to_string()));
let mut names: Vec<_> = classes.keys().collect();
names.sort();
for cname in names {
let source = if has_config
&& CONFIG.as_ref().unwrap().class.contains_key(cname)
{
"config"
} else {
"builtin"
};
let patterns = &classes[cname];
println!(" {} {}: {} patterns",
name.paint(cname), dimmed.paint(format!("({source})")),
value.paint(patterns.len().to_string()));
}
println!();
println!("{}", label.paint("Formats:"));
let compiled = vec!["long", "long2", "long3"];
for fname in &compiled {
let source = if has_config
&& CONFIG.as_ref().unwrap().format.contains_key(*fname)
{
"config (overrides builtin)"
} else {
"builtin"
};
println!(" {}: {}", name.paint(*fname), dimmed.paint(source));
}
if let Some(ref cfg) = *CONFIG {
for fname in cfg.format.keys() {
if !compiled.contains(&fname.as_str()) {
println!(" {}: {}", name.paint(fname), dimmed.paint("config"));
}
}
}
}
fn all_theme_names() -> Vec<String> {
let mut names = vec!["exa".to_string()];
if let Some(ref cfg) = *CONFIG {
for name in cfg.theme.keys() {
if !names.contains(name) {
names.push(name.clone());
}
}
}
names.sort();
names
}
fn format_theme_toml(name: &str) -> Option<String> {
if name == "exa" {
return Some("# [theme.exa] is compiled-in and cannot be dumped as TOML.\n\
# To customise, create a new theme that inherits from it:\n\
#\n\
# [theme.custom]\n\
# inherits = \"exa\"\n\
# directory = \"bold dodgerblue\"\n\
# date = \"steelblue\"".to_string());
}
let cfg = CONFIG.as_ref()?;
let theme = cfg.theme.get(name)?;
let mut lines = vec![format!("[theme.{name}]")];
if let Some(ref inherits) = theme.inherits {
lines.push(format!("inherits = \"{inherits}\""));
}
if let Some(ref use_style) = theme.use_style {
lines.push(format!("use-style = \"{use_style}\""));
}
let mut keys: Vec<_> = theme.ui.keys().collect();
keys.sort();
for key in keys {
lines.push(format!("{key} = \"{}\"", theme.ui[key]));
}
Some(lines.join("\n"))
}
pub fn dump_theme(name: &str) {
if let Some(toml) = format_theme_toml(name) { println!("{toml}") } else {
eprintln!("lx: unknown theme '{name}'");
eprintln!("Known themes: {}", all_theme_names().join(", "));
std::process::exit(3);
}
}
pub fn dump_theme_all() {
let names = all_theme_names();
let mut first = true;
for name in &names {
if let Some(toml) = format_theme_toml(name) {
if !first { println!(); }
println!("{toml}");
first = false;
}
}
}
fn all_style_names() -> Vec<String> {
let mut names = vec!["exa".to_string()];
if let Some(ref cfg) = *CONFIG {
for name in cfg.style.keys() {
if !names.contains(name) {
names.push(name.clone());
}
}
}
names.sort();
names
}
fn format_style_toml(name: &str) -> Option<String> {
let style = resolve_style(name)?;
let mut lines = vec![format!("[style.{name}]")];
let mut keys: Vec<_> = style.classes.keys().collect();
keys.sort();
for key in keys {
lines.push(format!("class.{key} = \"{}\"", style.classes[key]));
}
let mut keys: Vec<_> = style.patterns.keys().collect();
keys.sort();
for key in keys {
lines.push(format!("\"{key}\" = \"{}\"", style.patterns[key]));
}
Some(lines.join("\n"))
}
pub fn dump_style(name: &str) {
if let Some(toml) = format_style_toml(name) { println!("{toml}") } else {
eprintln!("lx: unknown style '{name}'");
eprintln!("Known styles: {}", all_style_names().join(", "));
std::process::exit(3);
}
}
pub fn dump_style_all() {
let names = all_style_names();
let mut first = true;
for name in &names {
if let Some(toml) = format_style_toml(name) {
if !first { println!(); }
println!("{toml}");
first = false;
}
}
}
const COMPILED_PERSONALITIES: &[&str] = &[
"default", "lx", "ll", "lll", "la", "tree", "ls",
];
fn all_personality_names() -> Vec<String> {
let mut names: Vec<String> = COMPILED_PERSONALITIES.iter()
.map(|s| (*s).into())
.collect();
if let Some(ref cfg) = *CONFIG {
for name in cfg.personality.keys() {
if !names.iter().any(|n| n == name) {
names.push(name.clone());
}
}
}
names.sort();
names
}
fn format_personality_toml(name: &str) -> Option<String> {
let def = lookup_personality(name)?;
let mut lines = vec![format!("[personality.{name}]")];
if let Some(ref inherits) = def.inherits {
lines.push(format!("inherits = \"{inherits}\""));
}
if let Some(ref format) = def.format {
lines.push(format!("format = \"{format}\""));
}
if let Some(ref columns) = def.columns {
let entries: Vec<String> = columns.to_csv()
.split(',')
.map(|s| format!("\"{}\"", s.trim()))
.collect();
lines.push(format!("columns = [{}]", entries.join(", ")));
}
let mut keys: Vec<_> = def.settings.keys().collect();
keys.sort();
for key in keys {
let value = &def.settings[key];
match value {
toml::Value::String(s) => lines.push(format!("{key} = \"{s}\"")),
toml::Value::Boolean(b) => lines.push(format!("{key} = {b}")),
toml::Value::Integer(i) => lines.push(format!("{key} = {i}")),
toml::Value::Float(f) => lines.push(format!("{key} = {f}")),
_ => lines.push(format!("{key} = {value}")),
}
}
Some(lines.join("\n"))
}
pub fn dump_personality(name: &str) {
if let Some(toml) = format_personality_toml(name) { println!("{toml}") } else {
eprintln!("lx: unknown personality '{name}'");
eprintln!("Known personalities: {}", all_personality_names().join(", "));
std::process::exit(3);
}
}
pub fn dump_personality_all() {
let names = all_personality_names();
let mut first = true;
for name in &names {
if let Some(toml) = format_personality_toml(name) {
if !first { println!(); }
println!("{toml}");
first = false;
}
}
}
fn format_class_toml(name: &str, patterns: &[String]) -> String {
let indent = " ".repeat(name.len() + 4); let mut lines = vec![format!("{name} = [")];
for (i, pat) in patterns.iter().enumerate() {
let entry = format!("\"{pat}\"");
let last = lines.last_mut().unwrap();
if i == 0 {
last.push_str(&entry);
} else {
let trial_len = last.len() + 2 + entry.len();
if trial_len > 72 {
last.push(',');
lines.push(format!("{indent}{entry}"));
} else {
last.push_str(", ");
last.push_str(&entry);
}
}
}
lines.last_mut().unwrap().push(']');
lines.join("\n")
}
pub fn show_class(name: &str) {
let classes = resolve_classes();
if let Some(patterns) = classes.get(name) {
println!("[class]");
println!("{}", format_class_toml(name, patterns));
} else {
eprintln!("lx: unknown class '{name}'");
eprintln!("Known classes: {}", {
let mut names: Vec<_> = classes.keys().collect();
names.sort();
names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
});
std::process::exit(3);
}
}
pub fn show_class_all() {
let classes = resolve_classes();
let mut names: Vec<_> = classes.keys().collect();
names.sort();
println!("[class]");
for name in names {
println!("{}", format_class_toml(name, &classes[name]));
}
}
fn compiled_formats() -> HashMap<String, Vec<String>> {
HashMap::from([
("long".into(), vec![
"permissions".into(), "size".into(), "user".into(), "modified".into(),
]),
("long2".into(), vec![
"permissions".into(), "size".into(), "user".into(), "group".into(),
"modified".into(), "vcs".into(),
]),
("long3".into(), vec![
"permissions".into(), "links".into(), "size".into(), "blocks".into(),
"user".into(), "group".into(), "modified".into(), "changed".into(),
"created".into(), "accessed".into(), "vcs".into(),
]),
])
}
pub fn resolve_formats() -> HashMap<String, Vec<String>> {
let mut formats = compiled_formats();
if let Some(ref cfg) = *CONFIG {
for (name, columns) in &cfg.format {
formats.insert(name.clone(), columns.clone());
}
}
formats
}
pub fn show_format(name: &str) {
let formats = resolve_formats();
if let Some(columns) = formats.get(name) {
println!("[format]");
println!("{}", format_format_toml(name, columns));
} else {
eprintln!("lx: unknown format '{name}'");
eprintln!("Known formats: {}", {
let mut names: Vec<_> = formats.keys().collect();
names.sort();
names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
});
std::process::exit(3);
}
}
pub fn show_format_all() {
let formats = resolve_formats();
let mut names: Vec<_> = formats.keys().collect();
names.sort();
println!("[format]");
for name in names {
println!("{}", format_format_toml(name, &formats[name]));
}
}
fn format_format_toml(name: &str, columns: &[String]) -> String {
let entries: Vec<String> = columns.iter().map(|c| format!("\"{c}\"")).collect();
let body = entries.join(", ");
let line = format!("{name} = [{body}]");
if line.len() <= 72 {
line
} else {
let indent = " ".repeat(name.len() + 4);
let mut lines = vec![format!("{name} = [")];
for (i, entry) in entries.iter().enumerate() {
let last = lines.last_mut().unwrap();
if i == 0 {
last.push_str(entry);
} else {
let trial_len = last.len() + 2 + entry.len();
if trial_len > 72 {
last.push(',');
lines.push(format!("{indent}{entry}"));
} else {
last.push_str(", ");
last.push_str(entry);
}
}
}
lines.last_mut().unwrap().push(']');
lines.join("\n")
}
}
pub fn upgrade_config(path: &PathBuf) -> Result<(), ConfigError> {
let contents = fs::read_to_string(path).with_path(path)?;
let version = detect_config_version(&contents);
if version == CONFIG_VERSION {
return Err(ConfigError::AlreadyCurrent { path: path.clone() });
}
if version == "0.3" || version == "0.4" {
let mut warned_time = false;
let mut warned_numeric = false;
for line in contents.lines() {
let trimmed = line.trim();
if !trimmed.contains('=') {
continue;
}
if !warned_time
&& trimmed.starts_with("time")
&& !trimmed.starts_with("time-style")
{
eprintln!(
"lx: warning: `time = \"...\"` is removed in config \
version 0.5; use `modified`, `changed`, `accessed`, \
or `created` booleans instead. Upgrading anyway."
);
warned_time = true;
}
if !warned_numeric && trimmed.starts_with("numeric") {
eprintln!(
"lx: warning: `numeric = ...` is removed in config \
version 0.5; UID and GID are first-class columns now. \
Use `uid = true, gid = true, no-user = true, \
no-group = true` for the old behaviour. Upgrading anyway."
);
warned_numeric = true;
}
if warned_time && warned_numeric {
break;
}
}
}
if version == "0.3" || version == "0.4" {
let backup = path.with_extension("toml.bak");
fs::copy(path, &backup).with_path(&backup)?;
let old_version_line = format!("version = \"{version}\"");
let new_version_line = format!("version = \"{CONFIG_VERSION}\"");
let updated = contents.replacen(&old_version_line, &new_version_line, 1);
fs::write(path, &updated).with_path(path)?;
eprintln!("Original config saved to {}", backup.display());
eprintln!("Upgraded {} from version {version} to {CONFIG_VERSION}", path.display());
return Ok(());
}
let legacy: LegacyConfig = toml::from_str(&contents)
.map_err(|source| ConfigError::Parse { path: path.clone(), source })?;
let mut out = String::new();
out.push_str(&format!("version = \"{CONFIG_VERSION}\"\n"));
if !legacy.format.is_empty() {
out.push_str("\n[format]\n");
for (name, columns) in &legacy.format {
out.push_str(&format!(
"{name} = [{}]\n",
columns.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ")
));
}
}
if version == "0.1" && !legacy.defaults.is_empty() {
out.push_str("\n[personality.default]\n");
for (key, value) in &legacy.defaults {
out.push_str(&format!("{key} = {value}\n"));
}
let has_lx = legacy.personality.contains_key("lx");
if has_lx {
out.push_str("\n[personality.lx]\n");
out.push_str("inherits = \"default\"\n");
if let Some(lx_p) = legacy.personality.get("lx") {
for (key, value) in lx_p {
if key != "inherits" {
out.push_str(&format!("{key} = {value}\n"));
}
}
}
} else {
out.push_str("\n[personality.lx]\ninherits = \"default\"\n");
}
}
for (name, settings) in &legacy.personality {
if version == "0.1" && name == "lx" {
continue; }
out.push_str(&format!("\n[personality.{name}]\n"));
for (key, value) in settings {
out.push_str(&format!("{key} = {value}\n"));
}
}
let backup = path.with_extension("toml.bak");
fs::copy(path, &backup).with_path(&backup)?;
fs::write(path, &out).with_path(path)?;
eprintln!("Original config saved to {}", backup.display());
eprintln!("Upgraded {} from version {version} to {CONFIG_VERSION}", path.display());
eprintln!("Note: comments were not preserved. You may want to review the result.");
Ok(())
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct LegacyConfig {
#[serde(default)]
defaults: HashMap<String, toml::Value>,
#[serde(default)]
format: HashMap<String, Vec<String>>,
#[serde(default)]
personality: HashMap<String, HashMap<String, toml::Value>>,
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from)
}