use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
const EXTENSION: &str = "m2";
pub fn resolve(typed: &str) -> PathBuf {
let path = expand_home(typed);
match path.extension() {
Some(_) => path,
None => path.with_extension(EXTENSION),
}
}
fn expand_home(typed: &str) -> PathBuf {
match (typed.strip_prefix("~/"), env::var_os("HOME")) {
(Some(rest), Some(home)) => PathBuf::from(home).join(rest),
_ => PathBuf::from(typed),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Entries {
Code,
All,
}
pub fn completions(typed: &str, entries: Entries) -> Vec<String> {
let (directory, name) = typed
.rfind('/')
.map_or(("", typed), |slash| typed.split_at(slash + 1));
let listed = match directory {
"" => PathBuf::from("."),
directory => expand_home(directory),
};
let Ok(listing) = fs::read_dir(listed) else {
return Vec::new();
};
let mut found: Vec<(bool, String)> = listing
.flatten()
.filter_map(|entry| {
let entry_name = entry.file_name().into_string().ok()?;
let is_directory = fs::metadata(entry.path()).is_ok_and(|meta| meta.is_dir());
let shown = entry_name.starts_with(name)
&& (name.starts_with('.') || !entry_name.starts_with('.'))
&& (is_directory
|| entries == Entries::All
|| Path::new(&entry_name)
.extension()
.is_some_and(|ext| ext == EXTENSION));
shown.then(|| {
let slash = if is_directory { "/" } else { "" };
(!is_directory, format!("{directory}{entry_name}{slash}"))
})
})
.collect();
found.sort();
found.into_iter().map(|(_, path)| path).collect()
}
pub fn write(path: &Path, code: &str) -> io::Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
fs::write(path, code)
}
#[cfg(not(test))]
pub fn previous_session() -> Option<PathBuf> {
let state = env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))?;
Some(
state
.join(env!("CARGO_PKG_NAME"))
.join("previous-session")
.with_extension(EXTENSION),
)
}
#[cfg(test)]
pub fn previous_session() -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_name_becomes_an_m2_file_and_an_explicit_extension_stays() {
assert_eq!(resolve("notes"), PathBuf::from("notes.m2"));
assert_eq!(resolve("ideal.m2"), PathBuf::from("ideal.m2"));
assert_eq!(resolve("ring.txt"), PathBuf::from("ring.txt"));
}
#[test]
fn a_typed_path_completes_to_the_entries_it_can_name() {
let root = env::temp_dir().join(format!("spectral-m2-completion-{}", std::process::id()));
for directory in ["ring", "rational"] {
fs::create_dir_all(root.join(directory)).unwrap();
}
for file in ["resolution.m2", "readme.txt", ".recent.m2"] {
fs::write(root.join(file), "").unwrap();
}
let typed = format!("{}/r", root.display());
let named = |entries| -> Vec<String> {
completions(&typed, entries)
.into_iter()
.map(|path| path[typed.len() - 1..].to_string())
.collect()
};
assert_eq!(
named(Entries::Code),
["rational/", "ring/", "resolution.m2"]
);
assert_eq!(
named(Entries::All),
["rational/", "ring/", "readme.txt", "resolution.m2"]
);
let hidden = completions(&format!("{}/.r", root.display()), Entries::Code);
assert_eq!(hidden.len(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_leading_tilde_is_the_home_directory() {
let Some(home) = env::var_os("HOME") else {
return;
};
assert_eq!(
resolve("~/work/ring"),
PathBuf::from(home).join("work/ring.m2")
);
}
}