Skip to main content

backhopper_core/
erlang_mk.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//! A reader for erlang.mk's variable lines in a `Makefile`.
6//!
7//! It reads assignments (`=`, `:=`, `::=`, `?=`, `+=`) with `\` line
8//! continuations and ignores everything else, including `include` lines
9//! and conditionals. It is not a `make` implementation: a value that
10//! references another variable is reported as unexpanded instead of
11//! being evaluated.
12
13use std::borrow::Borrow;
14use std::collections::{BTreeMap, BTreeSet};
15use std::str::FromStr;
16use std::sync::LazyLock;
17
18use regex::Regex;
19
20use crate::errors::NameError;
21use crate::model::app_manifest::{AppManifest, ManifestDeps, ManifestSource, ManifestUnreadable};
22use crate::model::names::{ApplicationName, DependencyName, DependencyVersion, MakeVariableName};
23pub use crate::model::snapshot::{VendoredDep as DepPin, VendoredDepSource as DepSource};
24
25const ASSIGNMENT_OPERATORS: [&str; 5] = ["::=", ":=", "?=", "+=", "="];
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum VariableValue {
29    Words(Vec<String>),
30    Unreadable(ManifestUnreadable),
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct MakeVariables {
35    values: BTreeMap<MakeVariableName, VariableValue>,
36}
37
38impl MakeVariables {
39    #[must_use]
40    pub fn parse(text: &str) -> Self {
41        let mut variables = Self::default();
42        let mut in_define = false;
43        for line in logical_lines(text) {
44            let trimmed = line.trim();
45            if in_define {
46                in_define = trimmed != "endef";
47                continue;
48            }
49            if trimmed.starts_with("define ") {
50                in_define = true;
51                continue;
52            }
53            if line.starts_with('\t') {
54                continue;
55            }
56            if let Some(assignment) = Assignment::parse(trimmed) {
57                variables.apply(assignment);
58            }
59        }
60        variables
61    }
62
63    #[must_use]
64    pub fn get<Q>(&self, name: &Q) -> Option<&VariableValue>
65    where
66        MakeVariableName: Borrow<Q>,
67        Q: Ord + ?Sized,
68    {
69        self.values.get(name)
70    }
71
72    pub fn words<Q>(&self, name: &Q) -> Result<&[String], ManifestUnreadable>
73    where
74        MakeVariableName: Borrow<Q>,
75        Q: Ord + ?Sized,
76    {
77        match self.values.get(name) {
78            Some(VariableValue::Words(words)) => Ok(words),
79            Some(VariableValue::Unreadable(reason)) => Err(reason.clone()),
80            None => Err(ManifestUnreadable::Missing),
81        }
82    }
83
84    fn apply(&mut self, assignment: Assignment) {
85        let Assignment {
86            name,
87            operator,
88            value,
89        } = assignment;
90        let previous = self.values.get_mut(&name);
91        match (operator, previous) {
92            (Operator::IfUnset, Some(_)) => {}
93            (Operator::Append, Some(VariableValue::Words(words))) => match value {
94                VariableValue::Words(more) => words.extend(more),
95                unreadable @ VariableValue::Unreadable(_) => {
96                    self.values.insert(name, unreadable);
97                }
98            },
99            (Operator::Append, Some(VariableValue::Unreadable(_))) => {}
100            _ => {
101                self.values.insert(name, value);
102            }
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum Operator {
109    Replace,
110    IfUnset,
111    Append,
112}
113
114struct Assignment {
115    name: MakeVariableName,
116    operator: Operator,
117    value: VariableValue,
118}
119
120impl Assignment {
121    fn parse(line: &str) -> Option<Self> {
122        let name_end = line
123            .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')))
124            .unwrap_or(line.len());
125        let name = MakeVariableName::from_str(&line[..name_end]).ok()?;
126        let after = line[name_end..].trim_start();
127        let operator_text = ASSIGNMENT_OPERATORS
128            .iter()
129            .find(|op| after.starts_with(**op))?;
130        let operator = match *operator_text {
131            "?=" => Operator::IfUnset,
132            "+=" => Operator::Append,
133            _ => Operator::Replace,
134        };
135        let value = read_value(&after[operator_text.len()..]);
136        Some(Self {
137            name,
138            operator,
139            value,
140        })
141    }
142}
143
144fn read_value(text: &str) -> VariableValue {
145    let words: Vec<String> = text.split_whitespace().map(str::to_owned).collect();
146    match words.iter().find_map(|w| first_reference(w)) {
147        Some(Ok(name)) => VariableValue::Unreadable(ManifestUnreadable::UnexpandedVariable(name)),
148        Some(Err(_)) => VariableValue::Unreadable(ManifestUnreadable::ParseFailed),
149        None => VariableValue::Words(words),
150    }
151}
152
153fn first_reference(word: &str) -> Option<Result<MakeVariableName, NameError>> {
154    let start = word.find("$(").or_else(|| word.find("${"))? + 2;
155    let rest = &word[start..];
156    let end = rest
157        .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')))
158        .unwrap_or(rest.len());
159    Some(MakeVariableName::from_str(&rest[..end]))
160}
161
162fn logical_lines(text: &str) -> Vec<String> {
163    let mut out = Vec::new();
164    let mut current = String::new();
165    for raw in text.lines() {
166        let raw = raw.trim_end_matches('\r');
167        let code = raw.split_once('#').map_or(raw, |(code, _)| code);
168        if let Some(continued) = code.strip_suffix('\\') {
169            current.push_str(continued);
170            current.push(' ');
171            continue;
172        }
173        current.push_str(code);
174        out.push(std::mem::take(&mut current));
175    }
176    if !current.is_empty() {
177        out.push(current);
178    }
179    out
180}
181
182/// What an erlang.mk `Makefile` declares about its app: `PROJECT`, and
183/// `DEPS` with `LOCAL_DEPS` as the apps the runtime needs. `BUILD_DEPS`,
184/// `TEST_DEPS` and `DEP_PLUGINS` are not needed at run time.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ErlangMkManifest {
187    pub project: Option<ApplicationName>,
188    pub requires: ManifestDeps,
189}
190
191impl ErlangMkManifest {
192    #[must_use]
193    pub fn parse(text: &str) -> Self {
194        let variables = MakeVariables::parse(text);
195        let project = variables
196            .words("PROJECT")
197            .ok()
198            .and_then(|words| words.first())
199            .and_then(|word| ApplicationName::from_str(word).ok());
200        Self {
201            project,
202            requires: read_requires(&variables),
203        }
204    }
205
206    #[must_use]
207    pub fn into_manifest(self, name: ApplicationName) -> AppManifest {
208        AppManifest {
209            name,
210            source: ManifestSource::ErlangMk,
211            requires: self.requires,
212        }
213    }
214}
215
216fn read_requires(variables: &MakeVariables) -> ManifestDeps {
217    let mut requires = BTreeSet::new();
218    for variable in ["DEPS", "LOCAL_DEPS"] {
219        let words = match variables.words(variable) {
220            Ok(words) => words,
221            Err(ManifestUnreadable::Missing) => continue,
222            Err(reason) => return ManifestDeps::Unreadable(reason),
223        };
224        for word in words {
225            match ApplicationName::from_str(word) {
226                Ok(app) => requires.insert(app),
227                Err(_) => return ManifestDeps::Unreadable(ManifestUnreadable::ParseFailed),
228            };
229        }
230    }
231    ManifestDeps::Listed(requires)
232}
233
234static DEP_RE: LazyLock<Regex> = LazyLock::new(|| {
235    Regex::new(r"^\s*dep_([a-z0-9_]+)\s*=\s*(hex|git_rmq|git)\s+(.+?)\s*$").expect("regex")
236});
237
238impl DepPin {
239    /// The envelope display form, e.g. `hex 2.17.1`.
240    #[must_use]
241    pub fn display(&self) -> String {
242        format!("{} {}", self.source.as_label(), self.version)
243    }
244
245    /// Inverse of `display`, for reading the form back out of an envelope.
246    #[must_use]
247    pub fn parse_display(name: &DependencyName, s: &str) -> Option<Self> {
248        let (label, version) = s.split_once(' ')?;
249        Some(Self {
250            name: name.clone(),
251            version: DependencyVersion::new(version).ok()?,
252            source: DepSource::from_label(label)?,
253        })
254    }
255}
256
257/// A `dep_` line: a pin, or the name of one the name types refuse.
258enum PinLine {
259    Pin(DepPin),
260    Refused(String),
261}
262
263fn scan_pin_line(line: &str) -> Option<PinLine> {
264    if line.trim_start().starts_with('#') {
265        return None;
266    }
267    let caps = DEP_RE.captures(line)?;
268    let name = &caps[1];
269    if name.ends_with("_commit") || name.ends_with("_branch") || name.ends_with("_repo") {
270        return None;
271    }
272    let source = DepSource::from_label(&caps[2])?;
273    let rest = caps[3].trim();
274    let mut words = rest.split_whitespace();
275    let version = match source {
276        DepSource::Hex => words.next()?,
277        DepSource::Git | DepSource::GitRmq => words.nth(1)?,
278    };
279    Some(
280        match (DependencyName::new(name), DependencyVersion::new(version)) {
281            (Ok(name), Ok(version)) => PinLine::Pin(DepPin {
282                name,
283                version,
284                source,
285            }),
286            _ => PinLine::Refused(name.to_owned()),
287        },
288    )
289}
290
291/// Parses one `dep_NAME = hex|git|git_rmq ...` line. A line whose name or
292/// version the name types refuse is not a pin.
293#[must_use]
294pub fn parse_pin_line(line: &str) -> Option<DepPin> {
295    match scan_pin_line(line)? {
296        PinLine::Pin(pin) => Some(pin),
297        PinLine::Refused(_) => None,
298    }
299}
300
301/// The pins of a components file, and the names of the `dep_` lines the
302/// name types refused, so a caller can report them.
303#[derive(Debug, Clone, Default, PartialEq, Eq)]
304pub struct PinScan {
305    pub pins: Vec<DepPin>,
306    pub refused_names: Vec<String>,
307}
308
309#[must_use]
310pub fn scan_components_mk(text: &str) -> PinScan {
311    let mut scan = PinScan::default();
312    for line in text.lines().filter_map(scan_pin_line) {
313        match line {
314            PinLine::Pin(pin) => scan.pins.push(pin),
315            PinLine::Refused(name) => scan.refused_names.push(name),
316        }
317    }
318    scan
319}
320
321#[must_use]
322pub fn parse_components_mk(text: &str) -> Vec<DepPin> {
323    scan_components_mk(text).pins
324}
325
326/// A hex version is a bare release number, so the project's tag prefix is
327/// applied unless the version already carries it.
328#[must_use]
329pub fn version_to_tag(version: &str, tag_prefix: &str) -> String {
330    if version.starts_with(tag_prefix) {
331        version.to_owned()
332    } else {
333        format!("{tag_prefix}{version}")
334    }
335}
336
337/// Hex deps get the project's tag prefix; git deps use the version verbatim.
338#[must_use]
339pub fn dep_to_tag(pin: &DepPin, tag_prefix: &str) -> String {
340    match pin.source {
341        DepSource::Hex => version_to_tag(pin.version.as_str(), tag_prefix),
342        DepSource::Git | DepSource::GitRmq => pin.version.to_string(),
343    }
344}