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