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::names::{ApplicationName, ModuleName};
19
20/// Parsed contents of one `.app.src` file.
21#[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
42/// Directories skipped entirely during discovery. Common build-output and
43/// vendored-dependency layouts: rebar3's `_build`, Erlang.mk's
44/// `_rel` and `logs`, npm-style `node_modules`, Nix's `.direnv`,
45/// plus `.git`. `target` is included so cross-language repos that
46/// vendor Rust artifacts don't bloat the scan.
47pub const SKIP_DIRS: &[&str] = &[
48    ".git",
49    "_build",
50    "_rel",
51    "logs",
52    "node_modules",
53    ".direnv",
54    "target",
55];
56
57/// Resolves a file path under `repo_root` to the application it
58/// belongs to: the app whose `.app.src` sits in the deepest shared
59/// prefix of `path`.
60pub 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
83/// The directory containing the application: one level up from the
84/// `.app.src` file's parent when that parent is `src/` or `ebin/`.
85pub(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
97/// Walks `root` recursively, parses every `*.app.src` found, returns
98/// a `DiscoveryOutput` carrying the parsed specs plus any per-file
99/// warnings.
100pub 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
150/// Parses a single `.app.src` file's text. The accepted shape is
151/// `{application, <name>, [<prop>, ...]}.` with optional whitespace,
152/// `%`-line comments, and any prop order. Unknown props are ignored.
153pub 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    /// Skip past a single value: an atom, integer, string, list,
440    /// tuple, or any nested combination. Stops at the matching depth.
441    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}