1use std::fs;
4use std::path::{Path, PathBuf};
5
6use wolf_diag::{Diagnostic, codes};
7
8use crate::manifest::{Kind, Manifest};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Role {
13 Entry,
15 Src,
17 Test,
19 Example,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct ModuleFile {
26 pub module: Vec<String>,
28 pub role: Role,
30 pub path: PathBuf,
32 pub display: String,
34}
35
36const RESERVED: &[&str] = &[
38 "module",
39 "extern",
40 "impl",
41 "fn",
42 "pub",
43 "let",
44 "var",
45 "type",
46 "struct",
47 "enum",
48 "interface",
49 "if",
50 "else",
51 "match",
52 "while",
53 "for",
54 "in",
55 "return",
56 "break",
57 "continue",
58 "mut",
59 "own",
60 "self",
61 "true",
62 "false",
63 "async",
64 "await",
65];
66
67pub fn find_root(start: &Path) -> Option<PathBuf> {
70 let mut dir = Some(start);
71 while let Some(current) = dir {
72 if current.join("wolfpack.toml").is_file() {
73 return Some(current.to_path_buf());
74 }
75 dir = current.parent();
76 }
77 None
78}
79
80pub const NATIVE_DIRS: &[&str] = &["ts", "go", "rust"];
82
83pub fn native_files(root: &Path, target: &str) -> Vec<(PathBuf, PathBuf)> {
86 fn walk(dir: &Path, rel: &Path, out: &mut Vec<(PathBuf, PathBuf)>) {
87 let Ok(entries) = std::fs::read_dir(dir) else {
88 return;
89 };
90 let mut entries: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
91 entries.sort();
92 for path in entries {
93 let Some(name) = path.file_name() else {
94 continue;
95 };
96 if name.to_string_lossy().starts_with('.') {
97 continue;
98 }
99 let rel = rel.join(name);
100 if path.is_dir() {
101 walk(&path, &rel, out);
102 } else {
103 out.push((rel, path));
104 }
105 }
106 }
107 let mut out = Vec::new();
108 walk(&root.join("native").join(target), Path::new(""), &mut out);
109 out
110}
111
112pub fn discover(root: &Path, manifest: &Manifest) -> (Vec<ModuleFile>, Vec<Diagnostic>) {
114 let mut out = Walk {
115 root: root.to_path_buf(),
116 files: Vec::new(),
117 diagnostics: Vec::new(),
118 };
119 let src = root.join("src");
120 if !src.is_dir() {
121 out.diagnostics.push(
122 Diagnostic::new(codes::E0612, "the project has no `src/` directory")
123 .help("create `src/` with the entry file"),
124 );
125 return (out.files, out.diagnostics);
126 }
127 out.walk(&src, &mut Vec::new(), Area::Src);
128 for (dir, area, prefix) in [
129 ("tests", Area::Tests, "tests"),
130 ("examples", Area::Examples, "examples"),
131 ] {
132 let path = root.join(dir);
133 if path.is_dir() {
134 out.walk(&path, &mut vec![prefix.to_string()], area);
135 }
136 }
137 out.check_native();
138 out.check_entry(manifest);
139 out.files.sort_by(|a, b| a.display.cmp(&b.display));
140 (out.files, out.diagnostics)
141}
142
143#[derive(Clone, Copy, PartialEq, Eq)]
144enum Area {
145 Src,
146 Tests,
147 Examples,
148}
149
150struct Walk {
151 root: PathBuf,
152 files: Vec<ModuleFile>,
153 diagnostics: Vec<Diagnostic>,
154}
155
156impl Walk {
157 fn check_native(&mut self) {
159 let Ok(entries) = std::fs::read_dir(self.root.join("native")) else {
160 return;
161 };
162 let mut names: Vec<String> = entries
163 .flatten()
164 .map(|e| e.file_name().to_string_lossy().into_owned())
165 .filter(|n| !n.starts_with('.'))
166 .filter(|n| {
167 !(NATIVE_DIRS.contains(&n.as_str()) && self.root.join("native").join(n).is_dir())
168 })
169 .collect();
170 names.sort();
171 for name in names {
172 self.diagnostics.push(
173 Diagnostic::new(codes::E0612, format!("unexpected `native/{name}`"))
174 .help("`native/` holds only the directories `ts`, `go` and `rust`"),
175 );
176 }
177 }
178
179 fn display(&self, path: &Path) -> String {
180 path.strip_prefix(&self.root)
181 .unwrap_or(path)
182 .to_string_lossy()
183 .replace('\\', "/")
184 }
185
186 fn layout_error(&mut self, message: String, help: &str) {
187 self.diagnostics
188 .push(Diagnostic::new(codes::E0612, message).help(help.to_string()));
189 }
190
191 fn walk(&mut self, dir: &Path, module: &mut Vec<String>, area: Area) {
192 let Ok(entries) = fs::read_dir(dir) else {
193 let shown = self.display(dir);
194 self.diagnostics.push(Diagnostic::new(
195 codes::E0001,
196 format!("cannot read `{shown}`"),
197 ));
198 return;
199 };
200 let mut children: Vec<(PathBuf, bool, bool)> = entries
201 .flatten()
202 .map(|e| {
203 let file_type = e.file_type().ok();
204 let is_dir = file_type.is_some_and(|t| t.is_dir());
205 let is_link = file_type.is_some_and(|t| t.is_symlink());
206 (e.path(), is_dir, is_link)
207 })
208 .collect();
209 children.sort();
210 let names: Vec<String> = children
211 .iter()
212 .filter_map(|(p, _, _)| p.file_name().and_then(|n| n.to_str()).map(str::to_string))
213 .collect();
214 for (path, is_dir, is_link) in children {
215 let shown = self.display(&path);
216 let Some(name) = path
217 .file_name()
218 .and_then(|n| n.to_str())
219 .map(str::to_string)
220 else {
221 self.layout_error(
222 format!("file name is not valid UTF-8: `{shown}`"),
223 "rename the file using ASCII letters, digits and `_`",
224 );
225 continue;
226 };
227 if name.starts_with('.') {
228 continue;
229 }
230 if is_link {
231 continue;
233 }
234 if is_dir {
235 if !self.check_segment(&name, &shown) {
236 continue;
237 }
238 if area == Area::Src
239 && module.is_empty()
240 && ["std", "tests", "examples", "native"].contains(&name.as_str())
241 {
242 self.layout_error(
243 format!("`{shown}/` uses the reserved module name `{name}`"),
244 "`std`, `tests`, `examples` and `native` are reserved root names; rename the directory",
245 );
246 continue;
247 }
248 if names.iter().any(|n| *n == format!("{name}.wolf")) {
250 self.layout_error(
251 format!("`{shown}.wolf` and the directory `{shown}/` both exist"),
252 "a directory is only a namespace; move the code of the file into a module inside the directory",
253 );
254 }
255 module.push(name);
256 self.walk(&path, module, area);
257 module.pop();
258 continue;
259 }
260 let Some(stem) = name.strip_suffix(".wolf") else {
261 if area == Area::Src {
262 self.layout_error(
263 format!("unexpected file `{shown}`"),
264 "`src/` contains only `.wolf` files and directories",
265 );
266 }
267 continue;
268 };
269 if !self.check_segment(stem, &shown) {
270 continue;
271 }
272 let mut module_path = module.clone();
273 module_path.push(stem.to_string());
274 let role = match area {
275 Area::Src if module.is_empty() && (stem == "main" || stem == "lib") => Role::Entry,
276 Area::Src => Role::Src,
277 Area::Tests => Role::Test,
278 Area::Examples => Role::Example,
279 };
280 if area == Area::Src && module.is_empty() {
281 if let Some(reserved) = ["std", "tests", "examples", "native"]
282 .iter()
283 .find(|r| **r == stem)
284 {
285 self.layout_error(
286 format!("`{shown}` uses the reserved module name `{reserved}`"),
287 "`std`, `tests`, `examples` and `native` are reserved root names; rename the file",
288 );
289 continue;
290 }
291 }
292 self.files.push(ModuleFile {
293 module: module_path,
294 role,
295 path,
296 display: shown,
297 });
298 }
299 }
300
301 fn check_segment(&mut self, name: &str, shown: &str) -> bool {
303 if RESERVED.contains(&name) {
304 self.layout_error(
305 format!("`{shown}` uses the keyword `{name}` as a module name"),
306 "rename it",
307 );
308 return false;
309 }
310 if !is_snake_case(name) {
311 self.layout_error(
312 format!("`{shown}` is not a valid module name"),
313 "file and directory names are `snake_case`: lower-case words separated by `_`",
314 );
315 return false;
316 }
317 true
318 }
319
320 fn check_entry(&mut self, manifest: &Manifest) {
321 let has = |files: &[ModuleFile], name: &str| {
322 files
323 .iter()
324 .any(|f| f.role == Role::Entry && f.module == [name.to_string()])
325 };
326 let (wanted, other) = match manifest.kind {
327 Kind::Cli => ("main", "lib"),
328 Kind::Lib => ("lib", "main"),
329 };
330 let kind = match manifest.kind {
331 Kind::Cli => "cli",
332 Kind::Lib => "lib",
333 };
334 if !has(&self.files, wanted) {
335 self.diagnostics.push(
336 Diagnostic::new(
337 codes::E0612,
338 format!("missing entry file `src/{wanted}.wolf`"),
339 )
340 .primary(
341 manifest.kind_span,
342 format!("a `{kind}` project needs `src/{wanted}.wolf`"),
343 ),
344 );
345 }
346 if has(&self.files, other) {
347 self.diagnostics.push(
348 Diagnostic::new(
349 codes::E0612,
350 format!("unexpected entry file `src/{other}.wolf`"),
351 )
352 .primary(
353 manifest.kind_span,
354 format!("a `{kind}` project has no `src/{other}.wolf`"),
355 )
356 .help(format!(
357 "rename the file, or change `kind` to `\"{}\"`",
358 if other == "main" { "cli" } else { "lib" }
359 )),
360 );
361 for file in &mut self.files {
363 if file.role == Role::Entry && file.module == [other.to_string()] {
364 file.role = Role::Src;
365 }
366 }
367 }
368 }
369}
370
371pub fn is_snake_case(name: &str) -> bool {
373 let mut parts = name.split('_');
374 let first_ok = parts.next().is_some_and(|p| {
375 p.starts_with(|c: char| c.is_ascii_lowercase())
376 && p.chars()
377 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
378 });
379 first_ok
380 && parts.all(|p| {
381 !p.is_empty()
382 && p.chars()
383 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
384 })
385}