use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use m2_syn::{lex_str_recovering, CommentTrivia, LexErrorKind, Spanned};
const EXTENSION: &str = "m2";
pub fn without_comments(source: &str) -> String {
let Ok(tokens) = lex_str_recovering(source) else {
return source.to_string();
};
if tokens
.diagnostics
.iter()
.any(|error| error.kind() == LexErrorKind::UnterminatedBlockComment)
{
return source.to_string();
}
let comments = CommentTrivia::of(&tokens.value);
let mut ranges = comments
.outer()
.iter()
.chain(comments.inner())
.filter_map(|comment| comment.span().range())
.collect::<Vec<_>>();
ranges.sort_by_key(|range| range.start);
let mut code = source.to_string();
for range in ranges.into_iter().rev() {
code.replace_range(range, " ");
}
code
}
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),
}
}
pub fn abbreviate_home(path: &Path) -> String {
match env::var_os("HOME").and_then(|home| path.strip_prefix(home).ok()) {
Some(rest) if rest.as_os_str().is_empty() => "~".into(),
Some(rest) => format!("~/{}", rest.display()),
None => path.display().to_string(),
}
}
#[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 {
#[test]
fn loaded_comments_are_removed_without_joining_tokens() {
let source = "-- heading\nx-* block\ncomment *-y -- trailing\n";
let code = super::without_comments(source);
assert_eq!(code.trim(), "x y");
assert!(super::without_comments("-- note\n-* σημείωση *-\n")
.trim()
.is_empty());
}
#[test]
fn comment_markers_inside_literals_are_preserved() {
for source in [
"\"-- literal -* also literal *-\"",
"///-- literal\n\n-* also literal *-///",
"f(\"escaped \\\" -- still literal\", -* hidden *- x)",
] {
let code = super::without_comments(source);
assert!(code.contains("--"));
assert!(!code.contains("hidden"));
if !source.contains("hidden") {
assert_eq!(code, source);
}
}
}
#[test]
fn unclosed_file_comments_keep_their_parse_state() {
let source = "x = 1\n-* unfinished";
assert_eq!(super::without_comments(source), source);
}
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")
);
}
#[test]
fn the_home_directory_is_spelled_as_a_tilde() {
let Some(home) = env::var_os("HOME").map(PathBuf::from) else {
return;
};
assert_eq!(abbreviate_home(&home), "~");
assert_eq!(abbreviate_home(&home.join("work/ring")), "~/work/ring");
assert_eq!(abbreviate_home(Path::new("/srv/m2")), "/srv/m2");
}
}