use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::formatter::WrapMode;
use crate::parser::LatexFlavor;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileDiscoveryError {
NonTexFilePath {
path: PathBuf,
},
UnsupportedLintFilePath {
path: PathBuf,
},
WalkError {
path: PathBuf,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FileKind {
Tex,
Sty,
Cls,
Bib,
}
impl FileKind {
pub fn is_latex(self) -> bool {
matches!(self, FileKind::Tex | FileKind::Sty | FileKind::Cls)
}
pub fn latex_flavor(self) -> LatexFlavor {
match self {
FileKind::Sty | FileKind::Cls => LatexFlavor::Package,
_ => LatexFlavor::Document,
}
}
pub fn default_wrap(self) -> WrapMode {
match self {
FileKind::Sty | FileKind::Cls => WrapMode::Preserve,
_ => WrapMode::Reflow,
}
}
}
pub fn collect_tex_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>, FileDiscoveryError> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if !is_tex_file(path) {
return Err(FileDiscoveryError::NonTexFilePath { path: path.clone() });
}
files.push(path.clone());
continue;
}
if path.is_dir() {
let mut builder = WalkBuilder::new(path);
builder.standard_filters(true);
builder.hidden(false);
for entry in builder.build() {
match entry {
Ok(entry) => {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let entry_path = entry.path().to_path_buf();
if is_tex_file(&entry_path) {
files.push(entry_path);
}
}
Err(err) => {
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: err.to_string(),
});
}
}
}
continue;
}
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: "path does not exist".to_string(),
});
}
files.sort();
files.dedup();
Ok(files)
}
fn is_tex_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("tex"))
}
fn lint_file_kind(path: &Path) -> Option<FileKind> {
let ext = path.extension().and_then(|ext| ext.to_str())?;
if ext.eq_ignore_ascii_case("tex") {
Some(FileKind::Tex)
} else if ext.eq_ignore_ascii_case("sty") {
Some(FileKind::Sty)
} else if ext.eq_ignore_ascii_case("cls") {
Some(FileKind::Cls)
} else if ext.eq_ignore_ascii_case("bib") {
Some(FileKind::Bib)
} else {
None
}
}
pub fn file_kind_or_tex(path: &Path) -> FileKind {
lint_file_kind(path).unwrap_or(FileKind::Tex)
}
pub fn collect_lint_files(
paths: &[PathBuf],
) -> Result<Vec<(PathBuf, FileKind)>, FileDiscoveryError> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
match lint_file_kind(path) {
Some(kind) => files.push((path.clone(), kind)),
None => {
return Err(FileDiscoveryError::UnsupportedLintFilePath { path: path.clone() });
}
}
continue;
}
if path.is_dir() {
let mut builder = WalkBuilder::new(path);
builder.standard_filters(true);
builder.hidden(false);
for entry in builder.build() {
match entry {
Ok(entry) => {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let entry_path = entry.path().to_path_buf();
if let Some(kind) = lint_file_kind(&entry_path) {
files.push((entry_path, kind));
}
}
Err(err) => {
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: err.to_string(),
});
}
}
}
continue;
}
return Err(FileDiscoveryError::WalkError {
path: path.clone(),
message: "path does not exist".to_string(),
});
}
files.sort();
files.dedup();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn collects_tex_files_recursively_sorted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("b.tex"), "b").unwrap();
fs::write(root.join("a.tex"), "a").unwrap();
fs::write(root.join("note.sty"), "x").unwrap();
fs::create_dir(root.join("sub")).unwrap();
fs::write(root.join("sub").join("c.tex"), "c").unwrap();
let files = collect_tex_files(&[root.to_path_buf()]).unwrap();
assert_eq!(
files,
vec![
root.join("a.tex"),
root.join("b.tex"),
root.join("sub").join("c.tex"),
]
);
}
#[test]
fn explicit_tex_file_is_accepted_uppercase_too() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Doc.TEX");
fs::write(&path, "x").unwrap();
let files = collect_tex_files(std::slice::from_ref(&path)).unwrap();
assert_eq!(files, vec![path]);
}
#[test]
fn explicit_non_tex_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pkg.sty");
fs::write(&path, "x").unwrap();
assert_eq!(
collect_tex_files(std::slice::from_ref(&path)),
Err(FileDiscoveryError::NonTexFilePath { path })
);
}
#[test]
fn missing_path_is_a_walk_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope");
assert!(matches!(
collect_tex_files(&[path]),
Err(FileDiscoveryError::WalkError { .. })
));
}
#[test]
fn duplicate_paths_are_deduplicated() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.tex");
fs::write(&path, "a").unwrap();
let files = collect_tex_files(&[path.clone(), path.clone()]).unwrap();
assert_eq!(files, vec![path]);
}
#[test]
fn collect_lint_files_keeps_all_kinds_sorted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("b.tex"), "b").unwrap();
fs::write(root.join("a.bib"), "a").unwrap();
fs::write(root.join("note.sty"), "x").unwrap();
fs::write(root.join("base.cls"), "x").unwrap();
fs::write(root.join("readme.md"), "x").unwrap();
fs::create_dir(root.join("sub")).unwrap();
fs::write(root.join("sub").join("c.bib"), "c").unwrap();
let files = collect_lint_files(&[root.to_path_buf()]).unwrap();
assert_eq!(
files,
vec![
(root.join("a.bib"), FileKind::Bib),
(root.join("b.tex"), FileKind::Tex),
(root.join("base.cls"), FileKind::Cls),
(root.join("note.sty"), FileKind::Sty),
(root.join("sub").join("c.bib"), FileKind::Bib),
],
"the `.md` file is ignored; `.sty`/`.cls` are collected as LaTeX kinds"
);
}
#[test]
fn collect_lint_files_accepts_explicit_bib() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("refs.BIB");
fs::write(&path, "x").unwrap();
let files = collect_lint_files(std::slice::from_ref(&path)).unwrap();
assert_eq!(files, vec![(path, FileKind::Bib)]);
}
#[test]
fn collect_lint_files_rejects_unsupported_explicit_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("readme.md");
fs::write(&path, "x").unwrap();
assert_eq!(
collect_lint_files(std::slice::from_ref(&path)),
Err(FileDiscoveryError::UnsupportedLintFilePath { path })
);
}
#[test]
fn file_kind_or_tex_dispatches_by_extension() {
assert_eq!(file_kind_or_tex(Path::new("refs.bib")), FileKind::Bib);
assert_eq!(file_kind_or_tex(Path::new("refs.BIB")), FileKind::Bib);
assert_eq!(file_kind_or_tex(Path::new("doc.tex")), FileKind::Tex);
assert_eq!(file_kind_or_tex(Path::new("pkg.sty")), FileKind::Sty);
assert_eq!(file_kind_or_tex(Path::new("Pkg.STY")), FileKind::Sty);
assert_eq!(file_kind_or_tex(Path::new("base.cls")), FileKind::Cls);
assert_eq!(file_kind_or_tex(Path::new("Base.CLS")), FileKind::Cls);
assert_eq!(file_kind_or_tex(Path::new("buffer")), FileKind::Tex);
}
#[test]
fn collect_lint_files_accepts_explicit_package_and_class() {
let dir = tempfile::tempdir().unwrap();
let sty = dir.path().join("pkg.sty");
let cls = dir.path().join("base.cls");
fs::write(&sty, "x").unwrap();
fs::write(&cls, "x").unwrap();
assert_eq!(
collect_lint_files(std::slice::from_ref(&sty)).unwrap(),
vec![(sty, FileKind::Sty)]
);
assert_eq!(
collect_lint_files(std::slice::from_ref(&cls)).unwrap(),
vec![(cls, FileKind::Cls)]
);
}
#[test]
fn package_and_class_kinds_are_latex_with_preserve_default() {
for kind in [FileKind::Sty, FileKind::Cls] {
assert!(kind.is_latex());
assert_eq!(kind.latex_flavor(), LatexFlavor::Package);
assert_eq!(kind.default_wrap(), WrapMode::Preserve);
}
assert!(FileKind::Tex.is_latex());
assert_eq!(FileKind::Tex.latex_flavor(), LatexFlavor::Document);
assert_eq!(FileKind::Tex.default_wrap(), WrapMode::Reflow);
assert!(!FileKind::Bib.is_latex());
}
}