275 lines
10 KiB
Rust
275 lines
10 KiB
Rust
// config.rs --- User configuration loader (T M2.10).
|
|
|
|
//! Loads `~/.config/pmacs/init.lua` on startup.
|
|
//!
|
|
//! The contract has three pieces:
|
|
//!
|
|
//! 1. **Missing config is not an error.** If neither
|
|
//! `$XDG_CONFIG_HOME/pmacs/init.lua` nor `$HOME/.config/pmacs/init.lua`
|
|
//! exists, the editor starts as if no config had been requested.
|
|
//! 2. **Broken config does not prevent startup.** If the file exists
|
|
//! but the chunk fails to parse or raises at runtime, the error is
|
|
//! captured (visible in the `*errors*` buffer and in the status
|
|
//! line) but the editor still launches with the builtin defaults.
|
|
//! 3. **`require` resolves against the config dir** so a user's
|
|
//! `init.lua` can split across multiple files. We do not ship a
|
|
//! package manager (per spec); resolution is the conventional Lua
|
|
//! `package.path` extension.
|
|
//!
|
|
//! # Testability
|
|
//!
|
|
//! Production callers use the no-arg [`load_user_config`], which reads
|
|
//! `XDG_CONFIG_HOME`/`HOME` from the live environment. Tests --- which
|
|
//! must not mutate the process environment, both because pmacs forbids
|
|
//! `unsafe` (and `set_var` is `unsafe` in edition 2024) and because
|
|
//! parallel test threads would race --- pass an explicit directory via
|
|
//! [`load_user_config_at`].
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use mlua::Lua;
|
|
|
|
use crate::lua::LuaHost;
|
|
|
|
/// File name of the user's main config chunk.
|
|
pub const INIT_FILE: &str = "init.lua";
|
|
|
|
/// Subdirectory under the config root that pmacs owns.
|
|
pub const CONFIG_SUBDIR: &str = "pmacs";
|
|
|
|
/// Resolve the user's config directory from the live environment.
|
|
///
|
|
/// `$XDG_CONFIG_HOME/pmacs` if set and non-empty, else
|
|
/// `$HOME/.config/pmacs`. Returns `None` if neither environment
|
|
/// variable is set --- in that case there is no place for pmacs to
|
|
/// look, and config loading is silently skipped.
|
|
#[must_use]
|
|
pub fn user_config_dir() -> Option<PathBuf> {
|
|
resolve_config_dir(
|
|
std::env::var_os("XDG_CONFIG_HOME").as_deref(),
|
|
std::env::var_os("HOME").as_deref(),
|
|
)
|
|
}
|
|
|
|
/// Pure resolution rule, factored out so tests can drive both arms
|
|
/// without touching the process environment.
|
|
#[must_use]
|
|
pub fn resolve_config_dir(
|
|
xdg: Option<&std::ffi::OsStr>,
|
|
home: Option<&std::ffi::OsStr>,
|
|
) -> Option<PathBuf> {
|
|
if let Some(xdg) = xdg
|
|
&& !xdg.is_empty()
|
|
{
|
|
return Some(PathBuf::from(xdg).join(CONFIG_SUBDIR));
|
|
}
|
|
let home = home?;
|
|
Some(PathBuf::from(home).join(".config").join(CONFIG_SUBDIR))
|
|
}
|
|
|
|
/// Path to the user's `init.lua`, if a config dir is resolvable.
|
|
#[must_use]
|
|
pub fn user_config_path() -> Option<PathBuf> {
|
|
user_config_dir().map(|d| d.join(INIT_FILE))
|
|
}
|
|
|
|
/// Append the user's config directory to Lua's `package.path` so
|
|
/// `require("name")` finds `dir/name.lua` and `dir/name/init.lua`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns the underlying [`mlua::Error`] if the package table is
|
|
/// missing or the assignment fails. Both indicate a corrupt Lua state.
|
|
pub fn install_package_path(lua: &Lua, dir: &Path) -> mlua::Result<()> {
|
|
let dir_str = dir.display().to_string();
|
|
let pkg: mlua::Table = lua.globals().get("package")?;
|
|
let existing: String = pkg.get("path").unwrap_or_default();
|
|
let prepended = format!("{dir_str}/?.lua;{dir_str}/?/init.lua;{existing}");
|
|
pkg.set("path", prepended)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load and evaluate the user's `init.lua` from the live environment.
|
|
///
|
|
/// Equivalent to [`load_user_config_at`] applied to the directory
|
|
/// returned by [`user_config_dir`]. A no-op if no config directory is
|
|
/// resolvable.
|
|
pub fn load_user_config(host: &mut LuaHost) {
|
|
if let Some(dir) = user_config_dir() {
|
|
load_user_config_at(host, &dir);
|
|
}
|
|
}
|
|
|
|
/// Load and evaluate `dir/init.lua` if it exists, after registering
|
|
/// `dir` on the Lua `package.path`.
|
|
///
|
|
/// All failures (file missing, read I/O failure, Lua parse or runtime
|
|
/// error) are non-fatal. Lua errors flow through [`LuaHost::eval`]
|
|
/// into the host's error log and the `*errors*` buffer; I/O errors
|
|
/// are silently skipped --- the editor still starts.
|
|
pub fn load_user_config_at(host: &mut LuaHost, dir: &Path) {
|
|
// Even if init.lua is absent, register the directory on
|
|
// package.path so a user who later writes `require` chunks at
|
|
// runtime can resolve them against their config dir without
|
|
// having to reset the path.
|
|
let _ = install_package_path(host.lua(), dir);
|
|
|
|
let init = dir.join(INIT_FILE);
|
|
let source = match std::fs::read_to_string(&init) {
|
|
Ok(s) => s,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
|
Err(_) => {
|
|
// Other I/O errors (permission denied, unreadable, etc.)
|
|
// are also non-fatal --- a user who's misconfigured their
|
|
// home directory shouldn't lose the editor over it.
|
|
return;
|
|
}
|
|
};
|
|
// The leading `@` follows Lua's debug-info convention for a chunk
|
|
// loaded from a file: stack traces show the path verbatim.
|
|
let label = format!("@{}", init.display());
|
|
let _ = host.eval(Some(&label), &source);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::ffi::OsString;
|
|
|
|
#[test]
|
|
fn xdg_config_home_takes_priority() {
|
|
let xdg = OsString::from("/some/xdg");
|
|
let home = OsString::from("/should/not/be/used");
|
|
let dir = resolve_config_dir(Some(&xdg), Some(&home)).unwrap();
|
|
assert_eq!(dir, PathBuf::from("/some/xdg/pmacs"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_xdg_falls_through_to_home() {
|
|
let xdg = OsString::from("");
|
|
let home = OsString::from("/home/test");
|
|
let dir = resolve_config_dir(Some(&xdg), Some(&home)).unwrap();
|
|
assert_eq!(dir, PathBuf::from("/home/test/.config/pmacs"));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_xdg_falls_back_to_home_dot_config() {
|
|
let home = OsString::from("/home/test");
|
|
let dir = resolve_config_dir(None, Some(&home)).unwrap();
|
|
assert_eq!(dir, PathBuf::from("/home/test/.config/pmacs"));
|
|
}
|
|
|
|
#[test]
|
|
fn neither_set_yields_none() {
|
|
assert!(resolve_config_dir(None, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_init_lua_is_a_silent_noop() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let mut host = LuaHost::new().unwrap();
|
|
load_user_config_at(&mut host, dir.path());
|
|
assert!(host.errors().is_empty(), "no config -> no errors");
|
|
}
|
|
|
|
#[test]
|
|
fn broken_init_lua_is_captured_and_does_not_panic() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(dir.path().join(INIT_FILE), "this is not valid lua )").unwrap();
|
|
|
|
let mut host = LuaHost::new().unwrap();
|
|
load_user_config_at(&mut host, dir.path());
|
|
|
|
let last = host.last_error().expect("error captured");
|
|
assert!(last.source.as_deref().unwrap().contains("init.lua"));
|
|
// Errors-buffer is populated.
|
|
let id = host.errors_buffer_id().expect("buffer exists");
|
|
let reg = host.registry().borrow();
|
|
let buf = reg.get(id).unwrap();
|
|
assert!(!buf.is_empty(), "errors buffer should not be empty");
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_error_in_init_lua_is_captured_with_path() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(dir.path().join(INIT_FILE), "error('user broke it')").unwrap();
|
|
|
|
let mut host = LuaHost::new().unwrap();
|
|
load_user_config_at(&mut host, dir.path());
|
|
|
|
let last = host.last_error().expect("error captured");
|
|
assert!(last.message.contains("user broke it"));
|
|
assert!(last.source.as_deref().unwrap().contains("init.lua"));
|
|
}
|
|
|
|
#[test]
|
|
fn valid_init_lua_runs_and_can_set_globals() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(dir.path().join(INIT_FILE), "USER_FLAG = 42").unwrap();
|
|
|
|
let mut host = LuaHost::new().unwrap();
|
|
load_user_config_at(&mut host, dir.path());
|
|
assert!(host.errors().is_empty());
|
|
let v: i64 = host.lua().globals().get("USER_FLAG").unwrap();
|
|
assert_eq!(v, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn package_path_includes_config_dir() {
|
|
let lua = Lua::new();
|
|
install_package_path(&lua, Path::new("/cfg")).unwrap();
|
|
let p: String = lua
|
|
.globals()
|
|
.get::<mlua::Table>("package")
|
|
.unwrap()
|
|
.get("path")
|
|
.unwrap();
|
|
assert!(
|
|
p.starts_with("/cfg/?.lua;/cfg/?/init.lua;"),
|
|
"package.path: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_init_can_rebind_a_key_in_under_five_lines() {
|
|
// Acceptance: a user-written config of fewer than five lines can
|
|
// rebind a key. Demonstrate via the editor's own attach_editor
|
|
// path so the keymap is the real default keymap.
|
|
use crate::editor_core::EditorCore;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
// Three lines: blank, comment, two statements via single-line each.
|
|
// Two statements over two lines = 2 lines total. Well under 5.
|
|
std::fs::write(
|
|
dir.path().join(INIT_FILE),
|
|
"pmacs.keymap.unbind { scope = 'global', sequence = 'C-a' }\n\
|
|
pmacs.keymap.bind { scope = 'global', sequence = 'C-a', command = 'editor.cancel' }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let registry: crate::lua_bindings::SharedRegistry =
|
|
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
|
|
let core = Rc::new(RefCell::new(EditorCore::new(registry.clone())));
|
|
let mut host = LuaHost::with_registry(registry).unwrap();
|
|
host.attach_editor(&core).expect("attach builtins");
|
|
load_user_config_at(&mut host, dir.path());
|
|
assert!(host.errors().is_empty(), "errors: {:?}", host.errors());
|
|
|
|
// After the override, C-a now resolves to editor.cancel.
|
|
let kms = host.keymaps().borrow();
|
|
let chord = crate::key::parse_sequence("C-a").unwrap();
|
|
match kms.resolve(&chord, None, &[]) {
|
|
crate::keymap_stack::StackResolution::Bound(rb) => {
|
|
assert_eq!(rb.binding.command, "editor.cancel");
|
|
}
|
|
other => panic!("expected Bound, got {other:?}"),
|
|
}
|
|
}
|
|
}
|