use std::fs;
use std::path::{Path, PathBuf};
use wolf_diag::{Diagnostic, codes};
use crate::manifest::{Kind, Manifest};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
Entry,
Src,
Test,
Example,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModuleFile {
pub module: Vec<String>,
pub role: Role,
pub path: PathBuf,
pub display: String,
}
const RESERVED: &[&str] = &[
"module",
"extern",
"impl",
"fn",
"pub",
"let",
"var",
"type",
"struct",
"enum",
"interface",
"if",
"else",
"match",
"while",
"for",
"in",
"return",
"break",
"continue",
"mut",
"own",
"self",
"true",
"false",
"async",
"await",
];
pub fn find_root(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
if current.join("wolfpack.toml").is_file() {
return Some(current.to_path_buf());
}
dir = current.parent();
}
None
}
pub fn discover(root: &Path, manifest: &Manifest) -> (Vec<ModuleFile>, Vec<Diagnostic>) {
let mut out = Walk {
root: root.to_path_buf(),
files: Vec::new(),
diagnostics: Vec::new(),
};
let src = root.join("src");
if !src.is_dir() {
out.diagnostics.push(
Diagnostic::new(codes::E0612, "the project has no `src/` directory")
.help("create `src/` with the entry file"),
);
return (out.files, out.diagnostics);
}
out.walk(&src, &mut Vec::new(), Area::Src);
for (dir, area, prefix) in [
("tests", Area::Tests, "tests"),
("examples", Area::Examples, "examples"),
] {
let path = root.join(dir);
if path.is_dir() {
out.walk(&path, &mut vec![prefix.to_string()], area);
}
}
out.check_entry(manifest);
out.files.sort_by(|a, b| a.display.cmp(&b.display));
(out.files, out.diagnostics)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Area {
Src,
Tests,
Examples,
}
struct Walk {
root: PathBuf,
files: Vec<ModuleFile>,
diagnostics: Vec<Diagnostic>,
}
impl Walk {
fn display(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn layout_error(&mut self, message: String, help: &str) {
self.diagnostics
.push(Diagnostic::new(codes::E0612, message).help(help.to_string()));
}
fn walk(&mut self, dir: &Path, module: &mut Vec<String>, area: Area) {
let Ok(entries) = fs::read_dir(dir) else {
let shown = self.display(dir);
self.diagnostics.push(Diagnostic::new(
codes::E0001,
format!("cannot read `{shown}`"),
));
return;
};
let mut children: Vec<(PathBuf, bool, bool)> = entries
.flatten()
.map(|e| {
let file_type = e.file_type().ok();
let is_dir = file_type.is_some_and(|t| t.is_dir());
let is_link = file_type.is_some_and(|t| t.is_symlink());
(e.path(), is_dir, is_link)
})
.collect();
children.sort();
let names: Vec<String> = children
.iter()
.filter_map(|(p, _, _)| p.file_name().and_then(|n| n.to_str()).map(str::to_string))
.collect();
for (path, is_dir, is_link) in children {
let shown = self.display(&path);
let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
else {
self.layout_error(
format!("file name is not valid UTF-8: `{shown}`"),
"rename the file using ASCII letters, digits and `_`",
);
continue;
};
if name.starts_with('.') {
continue;
}
if is_link {
continue;
}
if is_dir {
if !self.check_segment(&name, &shown) {
continue;
}
if area == Area::Src
&& module.is_empty()
&& ["std", "tests", "examples"].contains(&name.as_str())
{
self.layout_error(
format!("`{shown}/` uses the reserved module name `{name}`"),
"`std`, `tests` and `examples` are reserved root names; rename the directory",
);
continue;
}
if names.iter().any(|n| *n == format!("{name}.wolf")) {
self.layout_error(
format!("`{shown}.wolf` and the directory `{shown}/` both exist"),
"a directory is only a namespace; move the code of the file into a module inside the directory",
);
}
module.push(name);
self.walk(&path, module, area);
module.pop();
continue;
}
let Some(stem) = name.strip_suffix(".wolf") else {
if area == Area::Src {
self.layout_error(
format!("unexpected file `{shown}`"),
"`src/` contains only `.wolf` files and directories",
);
}
continue;
};
if !self.check_segment(stem, &shown) {
continue;
}
let mut module_path = module.clone();
module_path.push(stem.to_string());
let role = match area {
Area::Src if module.is_empty() && (stem == "main" || stem == "lib") => Role::Entry,
Area::Src => Role::Src,
Area::Tests => Role::Test,
Area::Examples => Role::Example,
};
if area == Area::Src && module.is_empty() {
if let Some(reserved) = ["std", "tests", "examples"].iter().find(|r| **r == stem) {
self.layout_error(
format!("`{shown}` uses the reserved module name `{reserved}`"),
"`std`, `tests` and `examples` are reserved root names; rename the file",
);
continue;
}
}
self.files.push(ModuleFile {
module: module_path,
role,
path,
display: shown,
});
}
}
fn check_segment(&mut self, name: &str, shown: &str) -> bool {
if RESERVED.contains(&name) {
self.layout_error(
format!("`{shown}` uses the keyword `{name}` as a module name"),
"rename it",
);
return false;
}
if !is_snake_case(name) {
self.layout_error(
format!("`{shown}` is not a valid module name"),
"file and directory names are `snake_case`: lower-case words separated by `_`",
);
return false;
}
true
}
fn check_entry(&mut self, manifest: &Manifest) {
let has = |files: &[ModuleFile], name: &str| {
files
.iter()
.any(|f| f.role == Role::Entry && f.module == [name.to_string()])
};
let (wanted, other) = match manifest.kind {
Kind::Cli => ("main", "lib"),
Kind::Lib => ("lib", "main"),
};
let kind = match manifest.kind {
Kind::Cli => "cli",
Kind::Lib => "lib",
};
if !has(&self.files, wanted) {
self.diagnostics.push(
Diagnostic::new(
codes::E0612,
format!("missing entry file `src/{wanted}.wolf`"),
)
.primary(
manifest.kind_span,
format!("a `{kind}` project needs `src/{wanted}.wolf`"),
),
);
}
if has(&self.files, other) {
self.diagnostics.push(
Diagnostic::new(
codes::E0612,
format!("unexpected entry file `src/{other}.wolf`"),
)
.primary(
manifest.kind_span,
format!("a `{kind}` project has no `src/{other}.wolf`"),
)
.help(format!(
"rename the file, or change `kind` to `\"{}\"`",
if other == "main" { "cli" } else { "lib" }
)),
);
for file in &mut self.files {
if file.role == Role::Entry && file.module == [other.to_string()] {
file.role = Role::Src;
}
}
}
}
}
pub fn is_snake_case(name: &str) -> bool {
let mut parts = name.split('_');
let first_ok = parts.next().is_some_and(|p| {
p.starts_with(|c: char| c.is_ascii_lowercase())
&& p.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
});
first_ok
&& parts.all(|p| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}