Skip to main content

backhopper_core/
app_src.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! `.app.src` discovery and parsing.
6//!
7//! Walks a source tree, finds every `*.app.src` file, and extracts
8//! the application name, declared modules, and listed dependencies.
9//! The parser is targeted: it accepts the small handful of Erlang
10//! syntactic shapes that appear in real `.app.src` files and rejects
11//! anything else as a parse error. It is not a general Erlang term
12//! parser.
13
14use 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/// Parsed contents of one `.app.src` file.
22#[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
60/// Directories skipped entirely during discovery. Common build-output and
61/// vendored-dependency layouts: rebar3's `_build`, Erlang.mk's
62/// `_rel` and `logs`, npm-style `node_modules`, Nix's `.direnv`,
63/// plus `.git`. `target` is included so cross-language repos that
64/// vendor Rust artifacts don't bloat the scan.
65pub const SKIP_DIRS: &[&str] = &[
66    ".git",
67    "_build",
68    "_rel",
69    "logs",
70    "node_modules",
71    ".direnv",
72    "target",
73];
74
75/// Resolves a file path under `repo_root` to the application it
76/// belongs to: the app whose `.app.src` sits in the deepest shared
77/// prefix of `path`.
78pub 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
101/// The directory containing the application: one level up from the
102/// `.app.src` file's parent when that parent is `src/` or `ebin/`.
103pub(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
115/// Walks `root` recursively, parses every `*.app.src` found, returns
116/// a `DiscoveryOutput` carrying the parsed specs plus any per-file
117/// warnings.
118pub 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
168/// Parses a single `.app.src` file's text. The accepted shape is
169/// `{application, <name>, [<prop>, ...]}.` with optional whitespace,
170/// `%`-line comments, and any prop order. Unknown props are ignored.
171pub 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    /// Skip past a single value: an atom, integer, string, list,
462    /// tuple, or any nested combination. Stops at the matching depth.
463    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}