1use std::collections::BTreeSet;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use crate::model::names::{ApplicationName, ModuleName};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AppSrcSpec {
23 pub path: PathBuf,
24 pub name: ApplicationName,
25 pub vsn: Option<String>,
26 pub modules: BTreeSet<ModuleName>,
27 pub applications: BTreeSet<ApplicationName>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum DiscoveryWarning {
32 ParseFailed { path: PathBuf, reason: String },
33 ReadFailed { path: PathBuf, reason: String },
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct DiscoveryOutput {
38 pub specs: Vec<AppSrcSpec>,
39 pub warnings: Vec<DiscoveryWarning>,
40}
41
42pub const SKIP_DIRS: &[&str] = &[
48 ".git",
49 "_build",
50 "_rel",
51 "logs",
52 "node_modules",
53 ".direnv",
54 "target",
55];
56
57pub fn application_of_path<'a>(
61 apps: &'a [AppSrcSpec],
62 repo_root: &Path,
63 path: &Path,
64) -> Option<&'a ApplicationName> {
65 let abs = if path.is_absolute() {
66 path.to_path_buf()
67 } else {
68 repo_root.join(path)
69 };
70 let mut best: Option<(&'a ApplicationName, usize)> = None;
71 for app in apps {
72 let app_dir = app_root_dir(&app.path);
73 if abs.starts_with(&app_dir) {
74 let depth = app_dir.components().count();
75 if best.map(|(_, d)| depth > d).unwrap_or(true) {
76 best = Some((&app.name, depth));
77 }
78 }
79 }
80 best.map(|(name, _)| name)
81}
82
83pub(crate) fn app_root_dir(app_src_path: &Path) -> PathBuf {
86 let parent = app_src_path.parent().unwrap_or(Path::new(""));
87 if parent
88 .file_name()
89 .is_some_and(|n| n == "src" || n == "ebin")
90 {
91 parent.parent().unwrap_or(parent).to_path_buf()
92 } else {
93 parent.to_path_buf()
94 }
95}
96
97pub fn discover(root: &Path) -> DiscoveryOutput {
101 let mut out = DiscoveryOutput::default();
102 walk_into(root, &mut out);
103 out.specs.sort_by(|a, b| a.path.cmp(&b.path));
104 out
105}
106
107fn walk_into(dir: &Path, out: &mut DiscoveryOutput) {
108 let Ok(entries) = fs::read_dir(dir) else {
109 return;
110 };
111 for entry in entries.flatten() {
112 let p = entry.path();
113 let Ok(ft) = entry.file_type() else { continue };
114 if ft.is_dir() {
115 if let Some(name) = p.file_name().and_then(|n| n.to_str())
116 && SKIP_DIRS.contains(&name)
117 {
118 continue;
119 }
120 walk_into(&p, out);
121 continue;
122 }
123 if !ft.is_file() {
124 continue;
125 }
126 if !is_app_src_path(&p) {
127 continue;
128 }
129 match fs::read_to_string(&p) {
130 Ok(text) => match parse(&p, &text) {
131 Ok(spec) => out.specs.push(spec),
132 Err(reason) => out
133 .warnings
134 .push(DiscoveryWarning::ParseFailed { path: p, reason }),
135 },
136 Err(err) => out.warnings.push(DiscoveryWarning::ReadFailed {
137 path: p,
138 reason: err.to_string(),
139 }),
140 }
141 }
142}
143
144fn is_app_src_path(p: &Path) -> bool {
145 p.file_name()
146 .and_then(|n| n.to_str())
147 .is_some_and(|n| n.ends_with(".app.src") || n.ends_with(".app"))
148}
149
150pub fn parse(path: &Path, text: &str) -> Result<AppSrcSpec, String> {
154 let cleaned = strip_comments(text);
155 let mut p = Parser::new(&cleaned);
156 p.skip_ws();
157 p.expect('{')?;
158 p.skip_ws();
159 let head = p.atom()?;
160 if head != "application" {
161 return Err(format!("expected 'application' tag, found '{head}'"));
162 }
163 p.skip_ws();
164 p.expect(',')?;
165 p.skip_ws();
166 let app_name = p.atom()?;
167 let app = ApplicationName::new(&app_name)
168 .map_err(|e| format!("invalid application name '{app_name}': {e}"))?;
169 p.skip_ws();
170 p.expect(',')?;
171 p.skip_ws();
172 p.expect('[')?;
173 let mut vsn: Option<String> = None;
174 let mut modules: BTreeSet<ModuleName> = BTreeSet::new();
175 let mut applications: BTreeSet<ApplicationName> = BTreeSet::new();
176 loop {
177 p.skip_ws();
178 if p.peek() == Some(']') {
179 p.advance();
180 break;
181 }
182 if p.peek() == Some(',') {
183 p.advance();
184 continue;
185 }
186 if p.peek() != Some('{') {
187 return Err(format!(
188 "expected '{{' in props list at position {}",
189 p.pos()
190 ));
191 }
192 p.advance();
193 p.skip_ws();
194 let key = p.atom()?;
195 p.skip_ws();
196 p.expect(',')?;
197 p.skip_ws();
198 match key.as_str() {
199 "vsn" => {
200 if let Some(s) = p.try_string() {
201 vsn = Some(s);
202 } else {
203 p.skip_value()?;
204 }
205 }
206 "modules" => {
207 if p.peek() == Some('[') {
208 let atoms = p.atom_list()?;
209 for a in atoms {
210 if let Ok(m) = ModuleName::new(&a) {
211 modules.insert(m);
212 }
213 }
214 } else {
215 p.skip_value()?;
216 }
217 }
218 "applications" => {
219 if p.peek() == Some('[') {
220 let atoms = p.atom_list()?;
221 for a in atoms {
222 if let Ok(app) = ApplicationName::new(&a) {
223 applications.insert(app);
224 }
225 }
226 } else {
227 p.skip_value()?;
228 }
229 }
230 _ => {
231 p.skip_value()?;
232 }
233 }
234 p.skip_ws();
235 p.expect('}')?;
236 }
237 p.skip_ws();
238 p.expect('}')?;
239 Ok(AppSrcSpec {
240 path: path.to_path_buf(),
241 name: app,
242 vsn,
243 modules,
244 applications,
245 })
246}
247
248fn strip_comments(text: &str) -> String {
249 let mut out = String::with_capacity(text.len());
250 let mut in_string = false;
251 let mut prev_backslash = false;
252 let mut chars = text.chars().peekable();
253 while let Some(ch) = chars.next() {
254 if prev_backslash {
255 out.push(ch);
256 prev_backslash = false;
257 continue;
258 }
259 if in_string {
260 if ch == '\\' {
261 out.push(ch);
262 prev_backslash = true;
263 continue;
264 }
265 if ch == '"' {
266 in_string = false;
267 }
268 out.push(ch);
269 continue;
270 }
271 if ch == '"' {
272 in_string = true;
273 out.push(ch);
274 continue;
275 }
276 if ch == '%' {
277 while let Some(&next) = chars.peek() {
278 if next == '\n' {
279 break;
280 }
281 chars.next();
282 }
283 continue;
284 }
285 out.push(ch);
286 }
287 out
288}
289
290struct Parser<'a> {
291 bytes: &'a [u8],
292 text: &'a str,
293 cursor: usize,
294}
295
296impl<'a> Parser<'a> {
297 fn new(text: &'a str) -> Self {
298 Self {
299 bytes: text.as_bytes(),
300 text,
301 cursor: 0,
302 }
303 }
304
305 fn pos(&self) -> usize {
306 self.cursor
307 }
308
309 fn peek(&self) -> Option<char> {
310 self.bytes.get(self.cursor).copied().map(|b| b as char)
311 }
312
313 fn advance(&mut self) {
314 self.cursor += 1;
315 }
316
317 fn skip_ws(&mut self) {
318 while let Some(ch) = self.peek() {
319 if ch.is_whitespace() {
320 self.advance();
321 } else {
322 break;
323 }
324 }
325 }
326
327 fn expect(&mut self, expected: char) -> Result<(), String> {
328 match self.peek() {
329 Some(c) if c == expected => {
330 self.advance();
331 Ok(())
332 }
333 Some(c) => Err(format!(
334 "expected '{expected}' at position {}, found '{c}'",
335 self.cursor
336 )),
337 None => Err(format!(
338 "expected '{expected}' at position {}, found end of input",
339 self.cursor
340 )),
341 }
342 }
343
344 fn atom(&mut self) -> Result<String, String> {
345 let start = self.cursor;
346 if self.peek() == Some('\'') {
347 self.advance();
348 let s = self.cursor;
349 while let Some(c) = self.peek() {
350 if c == '\'' {
351 let out = self.text[s..self.cursor].to_string();
352 self.advance();
353 return Ok(out);
354 }
355 self.advance();
356 }
357 return Err(format!(
358 "unterminated quoted atom starting at position {start}"
359 ));
360 }
361 let Some(first) = self.peek() else {
362 return Err(format!("expected atom at position {start}"));
363 };
364 if !first.is_ascii_lowercase() {
365 return Err(format!(
366 "expected atom at position {start}, found '{first}'"
367 ));
368 }
369 while let Some(c) = self.peek() {
370 if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
371 self.advance();
372 } else {
373 break;
374 }
375 }
376 Ok(self.text[start..self.cursor].to_string())
377 }
378
379 fn try_string(&mut self) -> Option<String> {
380 if self.peek() != Some('"') {
381 return None;
382 }
383 self.advance();
384 let s = self.cursor;
385 let mut prev_backslash = false;
386 while let Some(c) = self.peek() {
387 if prev_backslash {
388 prev_backslash = false;
389 self.advance();
390 continue;
391 }
392 if c == '\\' {
393 prev_backslash = true;
394 self.advance();
395 continue;
396 }
397 if c == '"' {
398 let out = self.text[s..self.cursor].to_string();
399 self.advance();
400 return Some(out);
401 }
402 self.advance();
403 }
404 None
405 }
406
407 fn atom_list(&mut self) -> Result<Vec<String>, String> {
408 self.expect('[')?;
409 let mut out = Vec::new();
410 loop {
411 self.skip_ws();
412 match self.peek() {
413 Some(']') => {
414 self.advance();
415 return Ok(out);
416 }
417 Some(',') => {
418 self.advance();
419 }
420 Some(c) if c == '\'' || c.is_ascii_lowercase() => {
421 out.push(self.atom()?);
422 }
423 Some(c) => {
424 return Err(format!(
425 "unexpected '{c}' in atom list at position {}",
426 self.cursor
427 ));
428 }
429 None => {
430 return Err(format!(
431 "unterminated atom list starting at position {}",
432 self.cursor
433 ));
434 }
435 }
436 }
437 }
438
439 fn skip_value(&mut self) -> Result<(), String> {
442 let mut depth = 0i32;
443 let mut in_str = false;
444 let mut in_atom_quote = false;
445 let mut prev_backslash = false;
446 loop {
447 let Some(ch) = self.peek() else {
448 return Err(format!("unterminated value at position {}", self.cursor));
449 };
450 if prev_backslash {
451 prev_backslash = false;
452 self.advance();
453 continue;
454 }
455 let in_quote = in_str || in_atom_quote;
456 match ch {
457 '\\' if in_quote => prev_backslash = true,
458 '"' if !in_atom_quote => in_str = !in_str,
459 '\'' if !in_str => in_atom_quote = !in_atom_quote,
460 '[' | '{' | '(' if !in_quote => depth += 1,
461 ']' | '}' | ')' if !in_quote => {
462 if depth == 0 {
463 return Ok(());
464 }
465 depth -= 1;
466 }
467 ',' if !in_quote && depth == 0 => return Ok(()),
468 _ => {}
469 }
470 self.advance();
471 }
472 }
473}