Skip to main content

zlayer_toolchain/
recipe.rs

1//! Homebrew formula recipe (`.rb`) fetcher + parser.
2//!
3//! This module turns a formula's Ruby source into a typed, executable
4//! [`InstallPlan`]: fetch the `.rb` from homebrew-core ([`fetch_recipe`] /
5//! [`fetch_recipe_cached`]), then parse its `def install` DSL with a
6//! hand-rolled line/state-machine parser ([`parse_install_plan`] — pure, no
7//! IO, no regex crate).
8//!
9//! The parser is deliberately conservative: every construct it does not fully
10//! understand becomes an [`InstallStep::Unsupported`] carrying the source
11//! text, and [`InstallPlan::is_fully_supported`] reports whether the plan can
12//! be executed natively. A wrong argv is worse than falling back to the
13//! brew-emulate path, so **when in doubt the parser emits `Unsupported`** —
14//! it never silently swallows a line.
15//!
16//! Platform/architecture conditionals (`on_macos` / `on_linux` / `on_arm` /
17//! `on_intel` blocks, `if OS.mac?` / `if Hardware::CPU.arm?` regions and
18//! trailing modifiers) are constant-folded at parse time against the
19//! [`RecipeCtx`] target. `build.head?` folds to `false` (we always build the
20//! stable tarball) and `build.stable?` to `true`. `test do` blocks are not
21//! part of the install and are skipped entirely.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use serde::{Deserialize, Serialize};
27use tracing::debug;
28
29use crate::error::{Result, ToolchainError};
30use zlayer_types::package_index::FormulaData;
31
32/// Primary raw-source host for homebrew-core formula files.
33const RAW_BASE: &str = "https://raw.githubusercontent.com/Homebrew/homebrew-core/HEAD";
34/// Fallback CDN mirror of the same tree.
35const CDN_BASE: &str = "https://cdn.jsdelivr.net/gh/Homebrew/homebrew-core@HEAD";
36
37// ---------------------------------------------------------------------------
38// Fetching
39// ---------------------------------------------------------------------------
40
41/// Fetch a formula's Ruby source from homebrew-core.
42///
43/// Uses `formula.ruby_source_path` (e.g. `Formula/g/git.rb`) against the
44/// GitHub raw host first, falling back to the jsDelivr CDN mirror on any
45/// failure.
46///
47/// # Errors
48///
49/// Returns [`ToolchainError::RegistryError`] when the formula JSON carries no
50/// `ruby_source_path`, or when both hosts fail to produce the file.
51pub async fn fetch_recipe(formula: &FormulaData) -> Result<String> {
52    let Some(path) = formula
53        .ruby_source_path
54        .as_deref()
55        .filter(|p| !p.is_empty())
56    else {
57        return Err(ToolchainError::RegistryError {
58            message: "formula JSON has no ruby_source_path; cannot fetch the Homebrew recipe \
59                      (package index entry predates ruby_source_path support?)"
60                .to_string(),
61        });
62    };
63    let client = reqwest::Client::builder()
64        .user_agent("zlayer-toolchain")
65        .build()
66        .unwrap_or_default();
67    let primary = format!("{RAW_BASE}/{path}");
68    match try_get_text(&client, &primary).await {
69        Ok(text) => Ok(text),
70        Err(primary_err) => {
71            debug!(path, error = %primary_err, "raw.githubusercontent fetch failed; trying jsDelivr fallback");
72            let fallback = format!("{CDN_BASE}/{path}");
73            try_get_text(&client, &fallback)
74                .await
75                .map_err(|fallback_err| ToolchainError::RegistryError {
76                    message: format!(
77                        "failed to fetch recipe {path}: {primary_err}; fallback: {fallback_err}"
78                    ),
79                })
80        }
81    }
82}
83
84/// Fetch a formula's Ruby source through a cache file.
85///
86/// Reads `cache_path` when it exists and is non-empty; otherwise fetches via
87/// [`fetch_recipe`] and writes the text to `cache_path` (creating parent
88/// directories). Callers cache at `<toolchain>/.build/recipe.rb`.
89///
90/// # Errors
91///
92/// Propagates [`fetch_recipe`] errors and filesystem write errors.
93pub async fn fetch_recipe_cached(formula: &FormulaData, cache_path: &Path) -> Result<String> {
94    if let Ok(cached) = tokio::fs::read_to_string(cache_path).await {
95        if !cached.is_empty() {
96            return Ok(cached);
97        }
98    }
99    let text = fetch_recipe(formula).await?;
100    if let Some(parent) = cache_path.parent() {
101        tokio::fs::create_dir_all(parent).await?;
102    }
103    tokio::fs::write(cache_path, &text).await?;
104    Ok(text)
105}
106
107/// GET `url` and return the body text; any non-success status is an error.
108async fn try_get_text(client: &reqwest::Client, url: &str) -> Result<String> {
109    let resp = client
110        .get(url)
111        .send()
112        .await
113        .map_err(|e| ToolchainError::RegistryError {
114            message: format!("failed to GET {url}: {e}"),
115        })?;
116    if !resp.status().is_success() {
117        return Err(ToolchainError::RegistryError {
118            message: format!("GET {url} returned status {}", resp.status()),
119        });
120    }
121    resp.text()
122        .await
123        .map_err(|e| ToolchainError::RegistryError {
124            message: format!("failed to read body from {url}: {e}"),
125        })
126}
127
128// ---------------------------------------------------------------------------
129// Types
130// ---------------------------------------------------------------------------
131
132/// Context a recipe is resolved against: identity, install prefix, resolved
133/// dependency prefixes, and the target platform/arch used to constant-fold
134/// `on_macos` / `on_linux` / `on_arm` / `on_intel` conditionals.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct RecipeCtx {
137    /// Formula/tool name (e.g. `jq`).
138    pub tool: String,
139    /// Version being built (resolves `#{version}`).
140    pub version: String,
141    /// The toolchain install prefix (resolves `#{prefix}`, `#{bin}`, ...).
142    pub prefix: PathBuf,
143    /// Resolved dependency toolchains: `Formula["x"].opt_*` maps through
144    /// `dep_prefixes["x"]`.
145    pub dep_prefixes: HashMap<String, PathBuf>,
146    /// Constant-fold `on_macos` / `on_linux` (and `OS.mac?` / `OS.linux?`).
147    pub target_macos: bool,
148    /// Constant-fold `on_arm` / `on_intel` (and `Hardware::CPU.arm?` /
149    /// `Hardware::CPU.intel?`).
150    pub target_arm: bool,
151}
152
153/// A `resource "name" do ... end` block: an extra artifact staged during the
154/// build.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct ResourceSpec {
157    /// Resource name as declared in the formula.
158    pub name: String,
159    /// Download URL.
160    pub url: String,
161    /// Expected sha256 of the download.
162    pub sha256: String,
163    /// Directory (relative to the source dir) the install body stages this
164    /// resource into, when it does so via `resource("name").stage(dir)`.
165    pub stage_to: Option<String>,
166}
167
168/// A `patch do ... end` block: a patch applied to the staged source before
169/// building.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct PatchSpec {
172    /// Patch download URL.
173    pub url: String,
174    /// Expected sha256 of the patch file.
175    pub sha256: String,
176    /// `-pN` strip level. Brew's default is 1; `patch :p0 do` sets 0.
177    pub strip: u32,
178}
179
180/// One executable step of an [`InstallPlan`]. Steps run in order, in the
181/// staged source directory, with the plan's accumulated environment.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub enum InstallStep {
184    /// A fully-resolved `system "cmd", ...` invocation (argv\[0\] is the
185    /// program).
186    System {
187        /// Full argv including the program.
188        argv: Vec<String>,
189    },
190    /// `system "./configure", ...` — args exclude the `./configure` itself.
191    Configure {
192        /// Arguments to `./configure`.
193        args: Vec<String>,
194    },
195    /// `system "make", ...` — args exclude the `make` itself.
196    Make {
197        /// Arguments to `make`.
198        args: Vec<String>,
199    },
200    /// `system "cmake", ...` — args exclude the `cmake` itself.
201    CMake {
202        /// Arguments to `cmake`.
203        args: Vec<String>,
204    },
205    /// `system "./bootstrap"` — args exclude the script itself.
206    Bootstrap {
207        /// Arguments to `./bootstrap`.
208        args: Vec<String>,
209    },
210    /// `system "cargo", ...` — args exclude the `cargo` itself.
211    Cargo {
212        /// Arguments to `cargo` (e.g. `install --locked ...`).
213        args: Vec<String>,
214    },
215    /// `system "go", ...` — args exclude the `go` itself.
216    Go {
217        /// Arguments to `go` (e.g. `build -trimpath ...`).
218        args: Vec<String>,
219    },
220    /// `system "meson", ...` or `system "ninja", ...`. Because this variant
221    /// covers two programs, `args` is the FULL argv including the program at
222    /// `args[0]` (`"meson"` or `"ninja"`).
223    Meson {
224        /// Full argv including the program (`meson` / `ninja`).
225        args: Vec<String>,
226    },
227    /// `bin.install "f"` (optionally renamed via `=> "g"`).
228    BinInstall {
229        /// Source path relative to the build dir.
230        from: String,
231        /// Optional rename inside `<prefix>/bin`.
232        to: Option<String>,
233    },
234    /// `lib.install "f"` (optionally renamed).
235    LibInstall {
236        /// Source path relative to the build dir.
237        from: String,
238        /// Optional rename inside `<prefix>/lib`.
239        to: Option<String>,
240    },
241    /// `include.install "f"` (optionally renamed).
242    IncludeInstall {
243        /// Source path relative to the build dir.
244        from: String,
245        /// Optional rename inside `<prefix>/include`.
246        to: Option<String>,
247    },
248    /// `prefix.install "f"`, and the subdir receivers (`libexec.install`,
249    /// `share.install`, `man1.install`, `pkgshare.install`) via `to` =
250    /// `Some("libexec")` / `Some("share/man/man1")` / etc. A rename form
251    /// appends the new name: `libexec.install "a" => "b"` yields
252    /// `to = Some("libexec/b")`.
253    PrefixInstall {
254        /// Source path relative to the build dir.
255        from: String,
256        /// Destination relative to the prefix (`None` = prefix root).
257        to: Option<String>,
258    },
259    /// `ENV["K"] = "v"` / `ENV.append` / `ENV.prepend` — `value` is the fully
260    /// merged value at this point of the plan.
261    EnvSet {
262        /// Environment variable name.
263        key: String,
264        /// Fully resolved (and append/prepend-merged) value.
265        value: String,
266    },
267    /// `inreplace "file", "from", "to"` with string literals only.
268    Inreplace {
269        /// File to edit, relative to the build dir.
270        file: String,
271        /// Literal text to replace.
272        from: String,
273        /// Replacement text.
274        to: String,
275    },
276    /// `resource("name").stage` / `.stage(dir)` — unpack the named resource
277    /// into `dir` (relative to the build dir) or the current dir.
278    StageResource {
279        /// Resource name (matches [`ResourceSpec::name`]).
280        name: String,
281        /// Optional destination dir relative to the build dir.
282        dir: Option<String>,
283    },
284    /// Apply `InstallPlan::patches[index]` to the staged source.
285    ApplyPatch {
286        /// Index into [`InstallPlan::patches`].
287        index: usize,
288    },
289    /// A construct the parser does not support; `construct` carries the
290    /// source text so callers can report exactly what blocked native
291    /// execution.
292    Unsupported {
293        /// The offending recipe source text.
294        construct: String,
295    },
296}
297
298/// A typed, executable install plan parsed from a formula's `def install`.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct InstallPlan {
301    /// Ordered steps ([`InstallStep::ApplyPatch`] steps come first, then the
302    /// install body in source order).
303    pub steps: Vec<InstallStep>,
304    /// Declared `resource` blocks.
305    pub resources: Vec<ResourceSpec>,
306    /// Declared `patch` blocks.
307    pub patches: Vec<PatchSpec>,
308    /// Final environment after all `ENV[...]=` / `ENV.append` / `ENV.prepend`
309    /// sets (each set is also a step, resolved at its point in the plan).
310    pub env: HashMap<String, String>,
311    /// `ENV.deparallelize` was called — run `make` with `-j1`.
312    pub deparallelize: bool,
313}
314
315impl InstallPlan {
316    /// `true` when the plan contains no [`InstallStep::Unsupported`] steps and
317    /// can therefore be executed natively.
318    #[must_use]
319    pub fn is_fully_supported(&self) -> bool {
320        !self
321            .steps
322            .iter()
323            .any(|s| matches!(s, InstallStep::Unsupported { .. }))
324    }
325
326    /// The source text of every unsupported construct, in plan order.
327    #[must_use]
328    pub fn unsupported_constructs(&self) -> Vec<&str> {
329        self.steps
330            .iter()
331            .filter_map(|s| match s {
332                InstallStep::Unsupported { construct } => Some(construct.as_str()),
333                _ => None,
334            })
335            .collect()
336    }
337}
338
339// ---------------------------------------------------------------------------
340// Parser entry point
341// ---------------------------------------------------------------------------
342
343/// Parse a formula's Ruby source into an [`InstallPlan`] against `ctx`.
344///
345/// Pure (no IO). Platform/arch conditionals are constant-folded per `ctx`;
346/// every construct the parser cannot execute lands as an
347/// [`InstallStep::Unsupported`] step rather than being dropped.
348///
349/// # Errors
350///
351/// Returns [`ToolchainError::RegistryError`] when the source contains no
352/// `def install` block (nothing to plan).
353pub fn parse_install_plan(rb: &str, ctx: &RecipeCtx) -> Result<InstallPlan> {
354    let mut parser = Parser {
355        ctx,
356        lines: logical_lines(rb),
357        pos: 0,
358        steps: Vec::new(),
359        resources: Vec::new(),
360        patches: Vec::new(),
361        env: HashMap::new(),
362        deparallelize: false,
363        saw_install: false,
364    };
365    parser.parse_top_level();
366    if !parser.saw_install {
367        return Err(ToolchainError::RegistryError {
368            message: format!(
369                "no `def install` block found in formula source for {}",
370                ctx.tool
371            ),
372        });
373    }
374    Ok(parser.finish())
375}
376
377// ---------------------------------------------------------------------------
378// Logical-line reader (comment stripping, continuations, heredocs, %w arrays)
379// ---------------------------------------------------------------------------
380
381/// Split Ruby source into logical lines: comments stripped, blank lines
382/// dropped, trailing-comma / trailing-backslash / trailing-open-paren
383/// continuations joined, heredoc bodies consumed, and multi-line `%w[` /
384/// `%W[` word arrays joined into their opening line.
385fn logical_lines(rb: &str) -> Vec<String> {
386    let phys: Vec<&str> = rb.lines().collect();
387    let mut out = Vec::new();
388    let mut i = 0;
389    while i < phys.len() {
390        let mut line = strip_comment(phys[i]).trim().to_string();
391        i += 1;
392        if line.is_empty() {
393            continue;
394        }
395        loop {
396            if let Some(id) = heredoc_id(&line) {
397                // Swallow the heredoc body (it is opaque to the parser; the
398                // opening line itself will surface as Unsupported).
399                while i < phys.len() {
400                    let body = phys[i].trim();
401                    i += 1;
402                    if body == id {
403                        break;
404                    }
405                }
406            }
407            if has_unclosed_word_array(&line) && i < phys.len() {
408                let next = strip_comment(phys[i]).trim().to_string();
409                i += 1;
410                line.push(' ');
411                line.push_str(&next);
412                continue;
413            }
414            if (line.ends_with(',') || line.ends_with('(')) && i < phys.len() {
415                let next = strip_comment(phys[i]).trim().to_string();
416                i += 1;
417                if !next.is_empty() {
418                    line.push(' ');
419                    line.push_str(&next);
420                }
421                continue;
422            }
423            if line.ends_with('\\') && i < phys.len() {
424                line.pop();
425                let next = strip_comment(phys[i]).trim().to_string();
426                i += 1;
427                if !next.is_empty() {
428                    line.push(' ');
429                    line.push_str(&next);
430                }
431                continue;
432            }
433            break;
434        }
435        out.push(line);
436    }
437    out
438}
439
440/// Strip a trailing Ruby comment, string-aware. `#` outside a string starts a
441/// comment UNLESS immediately followed by `{` (so `#{...}` interpolations in
442/// `%W[...]` word arrays — which live outside quotes — survive).
443fn strip_comment(raw: &str) -> String {
444    let mut out = String::new();
445    let mut in_double = false;
446    let mut in_single = false;
447    let mut escaped = false;
448    let mut chars = raw.chars().peekable();
449    while let Some(c) = chars.next() {
450        if escaped {
451            out.push(c);
452            escaped = false;
453            continue;
454        }
455        match c {
456            '\\' if in_double || in_single => {
457                out.push(c);
458                escaped = true;
459            }
460            '"' if !in_single => {
461                in_double = !in_double;
462                out.push(c);
463            }
464            '\'' if !in_double => {
465                in_single = !in_single;
466                out.push(c);
467            }
468            '#' if !in_double && !in_single => {
469                if chars.peek() == Some(&'{') {
470                    out.push(c);
471                } else {
472                    break;
473                }
474            }
475            _ => out.push(c),
476        }
477    }
478    out
479}
480
481/// Detect a heredoc introducer (`<<~ID` / `<<-ID` / `<<ID`, uppercase ID)
482/// outside strings; returns the terminator identifier.
483fn heredoc_id(line: &str) -> Option<String> {
484    let mut in_double = false;
485    let mut in_single = false;
486    let mut escaped = false;
487    let bytes = line.as_bytes();
488    for (i, c) in line.char_indices() {
489        if escaped {
490            escaped = false;
491            continue;
492        }
493        match c {
494            '\\' if in_double || in_single => escaped = true,
495            '"' if !in_single => in_double = !in_double,
496            '\'' if !in_double => in_single = !in_single,
497            '<' if !in_double && !in_single && bytes.get(i + 1) == Some(&b'<') => {
498                let mut j = i + 2;
499                while matches!(bytes.get(j), Some(b'~' | b'-')) {
500                    j += 1;
501                }
502                let start = j;
503                while matches!(bytes.get(j), Some(b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
504                    j += 1;
505                }
506                if j > start && bytes[start].is_ascii_uppercase() {
507                    return Some(line[start..j].to_string());
508                }
509            }
510            _ => {}
511        }
512    }
513    None
514}
515
516/// `true` when the line opens a `%w[` / `%W[` word array with no `]` after it
517/// (i.e. the array continues on following physical lines).
518fn has_unclosed_word_array(line: &str) -> bool {
519    let mut in_double = false;
520    let mut in_single = false;
521    let mut escaped = false;
522    let bytes = line.as_bytes();
523    for (i, c) in line.char_indices() {
524        if escaped {
525            escaped = false;
526            continue;
527        }
528        match c {
529            '\\' if in_double || in_single => escaped = true,
530            '"' if !in_single => in_double = !in_double,
531            '\'' if !in_double => in_single = !in_single,
532            '%' if !in_double
533                && !in_single
534                && matches!(bytes.get(i + 1), Some(b'w' | b'W'))
535                && bytes.get(i + 2) == Some(&b'[') =>
536            {
537                return !line[i + 3..].contains(']');
538            }
539            _ => {}
540        }
541    }
542    false
543}
544
545// ---------------------------------------------------------------------------
546// Block structure helpers
547// ---------------------------------------------------------------------------
548
549/// `true` when the logical line begins with the word `w`.
550fn starts_with_word(line: &str, w: &str) -> bool {
551    line == w || (line.starts_with(w) && line[w.len()..].starts_with(' '))
552}
553
554/// `true` when the line closes a block (`end`, `end.join(...)`, ...).
555fn closes_block(line: &str) -> bool {
556    line == "end"
557        || line.starts_with("end ")
558        || line.starts_with("end.")
559        || line.starts_with("end,")
560}
561
562/// `true` when the line ends with a `do` (optionally `do |params|`) block
563/// opener.
564fn ends_with_do(line: &str) -> bool {
565    let t = line.trim_end();
566    let t = if let Some(without_bar) = t.strip_suffix('|') {
567        match without_bar.rfind('|') {
568            Some(p) => t[..p].trim_end(),
569            None => return false,
570        }
571    } else {
572        t
573    };
574    t == "do" || t.ends_with(" do")
575}
576
577/// Locate the LAST top-level ` if ` / ` unless ` (outside strings, outside
578/// parens/brackets) — the trailing-conditional position. Returns the byte
579/// position of the leading space and whether it is `unless`.
580fn find_trailing_conditional(line: &str) -> Option<(usize, bool)> {
581    let mut best = None;
582    let mut in_double = false;
583    let mut in_single = false;
584    let mut escaped = false;
585    let mut depth = 0i32;
586    for (i, c) in line.char_indices() {
587        if escaped {
588            escaped = false;
589            continue;
590        }
591        match c {
592            '\\' if in_double || in_single => escaped = true,
593            '"' if !in_single => in_double = !in_double,
594            '\'' if !in_double => in_single = !in_single,
595            '(' | '[' | '{' if !in_double && !in_single => depth += 1,
596            ')' | ']' | '}' if !in_double && !in_single => depth -= 1,
597            ' ' if !in_double && !in_single && depth == 0 => {
598                let rest = &line[i..];
599                if rest.starts_with(" if ") {
600                    best = Some((i, false));
601                } else if rest.starts_with(" unless ") {
602                    best = Some((i, true));
603                }
604            }
605            _ => {}
606        }
607    }
608    best
609}
610
611/// `true` when a mid-line `if`/`unless` is an *expression* form (`x = if
612/// cond`, `args += if cond`) — which opens a block — rather than a trailing
613/// modifier.
614fn is_expression_if(line: &str) -> bool {
615    if let Some((pos, _)) = find_trailing_conditional(line) {
616        matches!(
617            line[..pos].trim_end().chars().last(),
618            Some('=' | '(' | ',' | '{')
619        )
620    } else {
621        false
622    }
623}
624
625/// A trailing-modifier conditional: `head if cond` / `head unless cond`.
626struct Modifier<'a> {
627    /// The statement guarded by the modifier.
628    head: &'a str,
629    /// The condition text.
630    cond: &'a str,
631    /// `true` for `unless` (inverted).
632    negated: bool,
633}
634
635/// Split a trailing modifier off a line, when present (and not an
636/// expression-if or a statement-if).
637fn analyze_modifier(line: &str) -> Option<Modifier<'_>> {
638    if starts_with_word(line, "if") || starts_with_word(line, "unless") {
639        return None;
640    }
641    let (pos, negated) = find_trailing_conditional(line)?;
642    let head = line[..pos].trim_end();
643    if matches!(head.chars().last(), Some('=' | '(' | ',' | '{')) {
644        return None; // expression form, not a modifier
645    }
646    let kw_len = if negated {
647        " unless ".len()
648    } else {
649        " if ".len()
650    };
651    Some(Modifier {
652        head,
653        cond: line[pos + kw_len..].trim(),
654        negated,
655    })
656}
657
658/// `true` when the logical line opens a block that a matching `end` closes.
659fn opens_block(line: &str) -> bool {
660    const OPENER_WORDS: [&str; 9] = [
661        "if", "unless", "def", "class", "module", "case", "while", "until", "begin",
662    ];
663    if OPENER_WORDS.iter().any(|w| starts_with_word(line, w)) {
664        return true;
665    }
666    ends_with_do(line) || is_expression_if(line)
667}
668
669// ---------------------------------------------------------------------------
670// Argument-list tokenizing
671// ---------------------------------------------------------------------------
672
673/// Split a Ruby argument list on top-level commas (string-, escape- and
674/// bracket-aware). Tokens come back trimmed.
675fn split_args(s: &str) -> Vec<String> {
676    let mut out = Vec::new();
677    let mut cur = String::new();
678    let mut in_double = false;
679    let mut in_single = false;
680    let mut escaped = false;
681    let mut depth = 0i32;
682    for c in s.chars() {
683        if escaped {
684            cur.push(c);
685            escaped = false;
686            continue;
687        }
688        match c {
689            '\\' if in_double || in_single => {
690                cur.push(c);
691                escaped = true;
692            }
693            '"' if !in_single => {
694                in_double = !in_double;
695                cur.push(c);
696            }
697            '\'' if !in_double => {
698                in_single = !in_single;
699                cur.push(c);
700            }
701            '(' | '[' | '{' if !in_double && !in_single => {
702                depth += 1;
703                cur.push(c);
704            }
705            ')' | ']' | '}' if !in_double && !in_single => {
706                depth -= 1;
707                cur.push(c);
708            }
709            ',' if !in_double && !in_single && depth == 0 => {
710                out.push(cur.trim().to_string());
711                cur.clear();
712            }
713            _ => cur.push(c),
714        }
715    }
716    if !cur.trim().is_empty() {
717        out.push(cur.trim().to_string());
718    }
719    out
720}
721
722/// Split an install token on a top-level ` => ` rename arrow (string-aware).
723fn split_rename(token: &str) -> (String, Option<String>) {
724    let mut in_double = false;
725    let mut in_single = false;
726    let mut escaped = false;
727    for (i, c) in token.char_indices() {
728        if escaped {
729            escaped = false;
730            continue;
731        }
732        match c {
733            '\\' if in_double || in_single => escaped = true,
734            '"' if !in_single => in_double = !in_double,
735            '\'' if !in_double => in_single = !in_single,
736            ' ' if !in_double && !in_single && token[i..].starts_with(" => ") => {
737                return (
738                    token[..i].trim().to_string(),
739                    Some(token[i + 4..].trim().to_string()),
740                );
741            }
742            _ => {}
743        }
744    }
745    (token.to_string(), None)
746}
747
748/// Extract the inner text of a plain quoted literal (`"x"` or `'x'`, no
749/// interpolation resolution).
750fn quoted_literal(s: &str) -> Option<String> {
751    let s = s.trim();
752    for q in ['"', '\''] {
753        if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) && !s[1..s.len() - 1].contains(q) {
754            return Some(s[1..s.len() - 1].to_string());
755        }
756    }
757    None
758}
759
760/// Extract the first quoted-string argument of a `kw "arg", ...` call line.
761fn string_call_arg(line: &str, kw: &str) -> Option<String> {
762    let rest = line.strip_prefix(kw)?;
763    let rest = rest.strip_prefix(' ').or_else(|| rest.strip_prefix('('))?;
764    let toks = split_args(rest.trim_end_matches(')'));
765    quoted_literal(toks.first()?)
766}
767
768// ---------------------------------------------------------------------------
769// The parser proper
770// ---------------------------------------------------------------------------
771
772/// Recursive-descent state machine over the logical lines of a formula.
773struct Parser<'a> {
774    ctx: &'a RecipeCtx,
775    lines: Vec<String>,
776    pos: usize,
777    steps: Vec<InstallStep>,
778    resources: Vec<ResourceSpec>,
779    patches: Vec<PatchSpec>,
780    env: HashMap<String, String>,
781    deparallelize: bool,
782    saw_install: bool,
783}
784
785/// How a branch region of an `if`/`else` construct ended.
786enum BranchEnd {
787    /// `end` — construct closed.
788    End,
789    /// `else` — the alternate branch follows.
790    Else,
791    /// `elsif <cond>` — carries the full `elsif` line.
792    Elsif(String),
793}
794
795impl Parser<'_> {
796    /// Consume and return the next logical line.
797    fn take(&mut self) -> String {
798        let line = self.lines[self.pos].clone();
799        self.pos += 1;
800        line
801    }
802
803    /// Record an unsupported construct.
804    fn unsupported(&mut self, construct: &str) {
805        self.steps.push(InstallStep::Unsupported {
806            construct: construct.to_string(),
807        });
808    }
809
810    /// Record an unsupported line and, when it opens a block, skip the whole
811    /// block so the region folds into ONE `Unsupported` step.
812    fn unsupported_line(&mut self, line: &str) {
813        self.unsupported(line);
814        if opens_block(line) {
815            self.skip_block();
816        }
817    }
818
819    /// Skip to the `end` matching an already-consumed opener.
820    fn skip_block(&mut self) {
821        let mut depth = 1usize;
822        while self.pos < self.lines.len() {
823            let line = self.take();
824            if closes_block(&line) {
825                depth -= 1;
826                if depth == 0 {
827                    return;
828                }
829            } else if opens_block(&line) {
830                depth += 1;
831            }
832        }
833    }
834
835    /// Skip a block as [`skip_block`](Self::skip_block) does, returning how
836    /// many logical lines the body contained (excluding the closing `end`).
837    fn skip_block_counting(&mut self) -> usize {
838        let mut depth = 1usize;
839        let mut count = 0usize;
840        while self.pos < self.lines.len() {
841            let line = self.take();
842            if closes_block(&line) {
843                depth -= 1;
844                if depth == 0 {
845                    return count;
846                }
847            } else if opens_block(&line) {
848                depth += 1;
849            }
850            count += 1;
851        }
852        count
853    }
854
855    // -- top level / class scope ---------------------------------------------
856
857    /// Find the `class X < Formula` line and process its body.
858    fn parse_top_level(&mut self) {
859        while self.pos < self.lines.len() {
860            let line = self.take();
861            if starts_with_word(&line, "class") && line.contains("< Formula") {
862                self.class_region();
863                return;
864            }
865        }
866    }
867
868    /// Process class-scope lines until the matching `end`.
869    fn class_region(&mut self) {
870        while self.pos < self.lines.len() {
871            let line = self.take();
872            if closes_block(&line) {
873                return;
874            }
875            self.class_line(&line);
876        }
877    }
878
879    /// Dispatch one class-scope logical line.
880    fn class_line(&mut self, line: &str) {
881        if let Some(name) = parse_resource_open(line) {
882            self.resource_block(name, line);
883            return;
884        }
885        if starts_with_word(line, "patch") {
886            self.patch_line(line);
887            return;
888        }
889        if let Some(keep) = self.on_block_keep(line) {
890            if keep {
891                self.class_region();
892            } else {
893                self.skip_block();
894            }
895            return;
896        }
897        if line == "def install" {
898            self.saw_install = true;
899            self.install_region();
900            return;
901        }
902        if opens_block(line) {
903            // Metadata blocks (livecheck / bottle / head / test / caveats /
904            // ...) do not affect the install plan — skip silently.
905            self.skip_block();
906        }
907        // Plain metadata line (desc / homepage / url / sha256 / depends_on /
908        // license / ...) — the formula JSON is authoritative; ignore.
909    }
910
911    /// Handle a class-scope `patch ...` line.
912    fn patch_line(&mut self, line: &str) {
913        if line == "patch :DATA" {
914            self.unsupported("patch :DATA");
915            return;
916        }
917        let strip = match line {
918            "patch do" => Some(1),
919            _ => line
920                .strip_prefix("patch :p")
921                .and_then(|r| r.strip_suffix(" do"))
922                .and_then(|n| n.parse::<u32>().ok()),
923        };
924        match strip {
925            Some(strip) => {
926                let (url, sha256) = self.url_sha_block();
927                match (url, sha256) {
928                    (Some(url), Some(sha256)) => {
929                        self.patches.push(PatchSpec { url, sha256, strip });
930                    }
931                    _ => self.unsupported(line),
932                }
933            }
934            None => self.unsupported_line(line),
935        }
936    }
937
938    /// Handle a `resource "name" do ... end` block.
939    fn resource_block(&mut self, name: String, open_line: &str) {
940        let (url, sha256) = self.url_sha_block();
941        match (url, sha256) {
942            (Some(url), Some(sha256)) => self.resources.push(ResourceSpec {
943                name,
944                url,
945                sha256,
946                stage_to: None,
947            }),
948            _ => self.unsupported(open_line),
949        }
950    }
951
952    /// Collect the first depth-1 `url "..."` and `sha256 "..."` of a block,
953    /// consuming through its matching `end` (nested blocks are ignored).
954    fn url_sha_block(&mut self) -> (Option<String>, Option<String>) {
955        let mut depth = 1usize;
956        let mut url = None;
957        let mut sha = None;
958        while self.pos < self.lines.len() {
959            let line = self.take();
960            if closes_block(&line) {
961                depth -= 1;
962                if depth == 0 {
963                    break;
964                }
965                continue;
966            }
967            if opens_block(&line) {
968                depth += 1;
969                continue;
970            }
971            if depth == 1 {
972                if url.is_none() {
973                    if let Some(u) = string_call_arg(&line, "url") {
974                        url = Some(u);
975                        continue;
976                    }
977                }
978                if sha.is_none() {
979                    if let Some(s) = string_call_arg(&line, "sha256") {
980                        sha = Some(s);
981                    }
982                }
983            }
984        }
985        (url, sha)
986    }
987
988    /// Fold an `on_macos do` / `on_linux do` / `on_arm do` / `on_intel do`
989    /// opener against the target: `Some(keep_contents)`.
990    fn on_block_keep(&self, line: &str) -> Option<bool> {
991        match line {
992            "on_macos do" => Some(self.ctx.target_macos),
993            "on_linux do" => Some(!self.ctx.target_macos),
994            "on_arm do" => Some(self.ctx.target_arm),
995            "on_intel do" => Some(!self.ctx.target_arm),
996            _ => None,
997        }
998    }
999
1000    /// Fold a single-condition platform/arch/build conditional.
1001    fn fold_condition(&self, cond: &str) -> Option<bool> {
1002        let cond = cond.trim();
1003        let (cond, negated) = cond
1004            .strip_prefix('!')
1005            .map_or((cond, false), |rest| (rest.trim_start(), true));
1006        let value = match cond {
1007            "OS.mac?" => self.ctx.target_macos,
1008            "OS.linux?" => !self.ctx.target_macos,
1009            "Hardware::CPU.arm?" => self.ctx.target_arm,
1010            "Hardware::CPU.intel?" => !self.ctx.target_arm,
1011            "build.head?" => false,
1012            "build.stable?" => true,
1013            _ => return None,
1014        };
1015        Some(value != negated)
1016    }
1017
1018    // -- install scope ---------------------------------------------------------
1019
1020    /// Process install-scope lines until the matching `end`.
1021    fn install_region(&mut self) {
1022        while self.pos < self.lines.len() {
1023            let line = self.take();
1024            if closes_block(&line) {
1025                return;
1026            }
1027            self.install_line(&line);
1028        }
1029    }
1030
1031    /// Dispatch one install-scope logical line.
1032    fn install_line(&mut self, line: &str) {
1033        // Statement-form conditionals.
1034        if let Some(cond) = line.strip_prefix("if ") {
1035            self.if_construct(line, cond, false);
1036            return;
1037        }
1038        if let Some(cond) = line.strip_prefix("unless ") {
1039            self.if_construct(line, cond, true);
1040            return;
1041        }
1042        if let Some(keep) = self.on_block_keep(line) {
1043            if keep {
1044                self.install_region();
1045            } else {
1046                self.skip_block();
1047            }
1048            return;
1049        }
1050        // Trailing modifier (`stmt if cond`).
1051        if let Some(m) = analyze_modifier(line) {
1052            match self.fold_condition(m.cond).map(|v| v != m.negated) {
1053                Some(true) => {
1054                    let head = m.head.to_string();
1055                    self.install_line(&head);
1056                }
1057                Some(false) => {}
1058                None => self.unsupported_line(line),
1059            }
1060            return;
1061        }
1062        if self.env_line(line)
1063            || self.system_line(line)
1064            || self.inreplace_line(line)
1065            || self.receiver_install_line(line)
1066            || self.resource_stage_line(line)
1067        {
1068            return;
1069        }
1070        self.unsupported_line(line);
1071    }
1072
1073    /// Handle a statement `if`/`unless` construct (already-consumed opener).
1074    fn if_construct(&mut self, line: &str, cond: &str, negated: bool) {
1075        if let Some(keep_then) = self.fold_condition(cond).map(|v| v != negated) {
1076            self.if_branches(keep_then);
1077        } else {
1078            self.unsupported(line);
1079            self.skip_block();
1080        }
1081    }
1082
1083    /// Process/skip the branches of a folded `if` whose opener was consumed.
1084    fn if_branches(&mut self, keep_then: bool) {
1085        match self.branch_region(keep_then) {
1086            BranchEnd::End => {}
1087            BranchEnd::Else => {
1088                let _ = self.branch_region(!keep_then);
1089            }
1090            BranchEnd::Elsif(elsif_line) => {
1091                if keep_then {
1092                    // A taken branch ends the chain: skip the rest.
1093                    self.skip_block();
1094                } else {
1095                    let cond = elsif_line.trim_start_matches("elsif").trim().to_string();
1096                    if let Some(keep) = self.fold_condition(&cond) {
1097                        self.if_branches(keep);
1098                    } else {
1099                        self.unsupported(&elsif_line);
1100                        self.skip_block();
1101                    }
1102                }
1103            }
1104        }
1105    }
1106
1107    /// Process (or skip) one branch until an `else` / `elsif` / `end` at this
1108    /// nesting level.
1109    fn branch_region(&mut self, execute: bool) -> BranchEnd {
1110        while self.pos < self.lines.len() {
1111            let line = self.take();
1112            if closes_block(&line) {
1113                return BranchEnd::End;
1114            }
1115            if line == "else" {
1116                return BranchEnd::Else;
1117            }
1118            if starts_with_word(&line, "elsif") {
1119                return BranchEnd::Elsif(line);
1120            }
1121            if execute {
1122                self.install_line(&line);
1123            } else if opens_block(&line) {
1124                self.skip_block();
1125            }
1126        }
1127        BranchEnd::End
1128    }
1129
1130    // -- install-scope handlers -------------------------------------------------
1131
1132    /// `ENV.deparallelize` / `ENV["K"] = v` / `ENV.append` / `ENV.prepend`.
1133    fn env_line(&mut self, line: &str) -> bool {
1134        if line == "ENV.deparallelize" {
1135            self.deparallelize = true;
1136            return true;
1137        }
1138        if let Some(rest) = line.strip_prefix("ENV[") {
1139            let Some((key_part, after)) = rest.split_once(']') else {
1140                self.unsupported_line(line);
1141                return true;
1142            };
1143            let (Some(key), Some(expr)) = (
1144                quoted_literal(key_part),
1145                after.trim_start().strip_prefix('=').map(str::trim),
1146            ) else {
1147                self.unsupported_line(line);
1148                return true;
1149            };
1150            if expr.starts_with('=') {
1151                // `ENV["K"] == x` is a comparison, not a set.
1152                self.unsupported_line(line);
1153                return true;
1154            }
1155            match self.resolve_value(expr) {
1156                Some(value) => self.set_env(key, value),
1157                None => self.unsupported_line(line),
1158            }
1159            return true;
1160        }
1161        for (kw, prepend) in [("ENV.append ", false), ("ENV.prepend ", true)] {
1162            if let Some(rest) = line.strip_prefix(kw) {
1163                self.env_merge(line, rest, prepend);
1164                return true;
1165            }
1166        }
1167        false
1168    }
1169
1170    /// Merge-set from `ENV.append "K", v` / `ENV.prepend "K", v`.
1171    fn env_merge(&mut self, line: &str, rest: &str, prepend: bool) {
1172        let toks = split_args(rest);
1173        let (Some(key), Some(value)) = (
1174            toks.first().and_then(|t| quoted_literal(t)),
1175            toks.get(1).and_then(|t| self.resolve_value(t)),
1176        ) else {
1177            self.unsupported_line(line);
1178            return;
1179        };
1180        if toks.len() != 2 {
1181            self.unsupported_line(line);
1182            return;
1183        }
1184        let merged = match self.env.get(&key) {
1185            Some(existing) if prepend => format!("{value} {existing}"),
1186            Some(existing) => format!("{existing} {value}"),
1187            None => value,
1188        };
1189        self.set_env(key, merged);
1190    }
1191
1192    /// Record an env set as both a step and the running environment.
1193    fn set_env(&mut self, key: String, value: String) {
1194        self.steps.push(InstallStep::EnvSet {
1195            key: key.clone(),
1196            value: value.clone(),
1197        });
1198        self.env.insert(key, value);
1199    }
1200
1201    /// `system "cmd", args...` — resolve every token or mark the line
1202    /// unsupported.
1203    fn system_line(&mut self, line: &str) -> bool {
1204        let rest = if let Some(r) = line.strip_prefix("system ") {
1205            r
1206        } else if let Some(r) = line
1207            .strip_prefix("system(")
1208            .and_then(|r| r.strip_suffix(')'))
1209        {
1210            r
1211        } else {
1212            return false;
1213        };
1214        let mut argv = Vec::new();
1215        for token in split_args(rest) {
1216            if let Some(mut vals) = self.resolve_arg(&token) {
1217                argv.append(&mut vals);
1218            } else {
1219                self.unsupported_line(line);
1220                return true;
1221            }
1222        }
1223        if argv.is_empty() {
1224            self.unsupported_line(line);
1225            return true;
1226        }
1227        self.steps.push(classify_system(argv));
1228        true
1229    }
1230
1231    /// Resolve one `system` argument token to one-or-more argv strings.
1232    fn resolve_arg(&self, token: &str) -> Option<Vec<String>> {
1233        if let Some(splat) = token.strip_prefix('*') {
1234            return self.resolve_splat(splat);
1235        }
1236        self.resolve_value(token).map(|v| vec![v])
1237    }
1238
1239    /// Expand a `*std_*_args` splat.
1240    fn resolve_splat(&self, splat: &str) -> Option<Vec<String>> {
1241        let prefix = self.ctx.prefix.display().to_string();
1242        match splat {
1243            "std_configure_args" => Some(vec![
1244                format!("--prefix={prefix}"),
1245                format!("--libdir={prefix}/lib"),
1246                "--disable-debug".to_string(),
1247                "--disable-dependency-tracking".to_string(),
1248                "--disable-silent-rules".to_string(),
1249            ]),
1250            "std_cmake_args" => Some(vec![
1251                format!("-DCMAKE_INSTALL_PREFIX={prefix}"),
1252                "-DCMAKE_BUILD_TYPE=Release".to_string(),
1253                "-DCMAKE_FIND_FRAMEWORK=LAST".to_string(),
1254                "-DBUILD_TESTING=OFF".to_string(),
1255            ]),
1256            "std_cargo_args" => Some(vec![
1257                "--locked".to_string(),
1258                "--root".to_string(),
1259                prefix,
1260                "--path".to_string(),
1261                ".".to_string(),
1262            ]),
1263            "std_go_args" => self.std_go_args(None),
1264            _ => splat
1265                .strip_prefix("std_go_args(")
1266                .and_then(|r| r.strip_suffix(')'))
1267                .and_then(|kwargs| self.std_go_args(Some(kwargs))),
1268        }
1269    }
1270
1271    /// Expand `std_go_args` with optional `output:` / `ldflags:` / `gcflags:`
1272    /// / `tags:` keyword arguments (mirrors brew's expansion).
1273    fn std_go_args(&self, kwargs: Option<&str>) -> Option<Vec<String>> {
1274        let mut output = format!("{}/bin/{}", self.ctx.prefix.display(), self.ctx.tool);
1275        let mut ldflags = None;
1276        let mut gcflags = None;
1277        let mut tags = None;
1278        if let Some(kwargs) = kwargs {
1279            for part in split_args(kwargs) {
1280                let (key, value) = part.split_once(':')?;
1281                let value = self.resolve_value(value.trim())?;
1282                match key.trim() {
1283                    "output" => output = value,
1284                    "ldflags" => ldflags = Some(value),
1285                    "gcflags" => gcflags = Some(value),
1286                    "tags" => tags = Some(value),
1287                    _ => return None,
1288                }
1289            }
1290        }
1291        let mut args = vec!["-trimpath".to_string(), format!("-o={output}")];
1292        if let Some(tags) = tags {
1293            args.push(format!("-tags={tags}"));
1294        }
1295        if let Some(ldflags) = ldflags {
1296            args.push(format!("-ldflags={ldflags}"));
1297        }
1298        if let Some(gcflags) = gcflags {
1299            args.push(format!("-gcflags={gcflags}"));
1300        }
1301        Some(args)
1302    }
1303
1304    /// `inreplace "file", "lit1", "lit2"` (string literals only); the block
1305    /// form is unsupported.
1306    fn inreplace_line(&mut self, line: &str) -> bool {
1307        let Some(rest) = line.strip_prefix("inreplace ") else {
1308            return false;
1309        };
1310        if ends_with_do(line) {
1311            self.unsupported(line);
1312            self.skip_block();
1313            return true;
1314        }
1315        let toks = split_args(rest);
1316        if toks.len() == 3
1317            && toks
1318                .iter()
1319                .all(|t| t.starts_with('"') || t.starts_with('\''))
1320        {
1321            if let (Some(file), Some(from), Some(to)) = (
1322                self.resolve_value(&toks[0]),
1323                self.resolve_value(&toks[1]),
1324                self.resolve_value(&toks[2]),
1325            ) {
1326                self.steps.push(InstallStep::Inreplace { file, from, to });
1327                return true;
1328            }
1329        }
1330        self.unsupported(line);
1331        true
1332    }
1333
1334    /// `bin.install` / `lib.install` / `include.install` / `libexec.install`
1335    /// / `share.install` / `man1.install` / `pkgshare.install` /
1336    /// `prefix.install`.
1337    fn receiver_install_line(&mut self, line: &str) -> bool {
1338        const RECEIVERS: [&str; 8] = [
1339            "bin", "lib", "include", "libexec", "share", "man1", "pkgshare", "prefix",
1340        ];
1341        let Some((recv, rest)) = RECEIVERS.iter().find_map(|r| {
1342            line.strip_prefix(&format!("{r}.install "))
1343                .map(|rest| (*r, rest))
1344        }) else {
1345            return false;
1346        };
1347        let mut staged = Vec::new();
1348        for token in split_args(rest) {
1349            let (from_expr, rename_expr) = split_rename(&token);
1350            let Some(from) = self.resolve_value(&from_expr) else {
1351                self.unsupported(line);
1352                return true;
1353            };
1354            let rename = match rename_expr {
1355                None => None,
1356                Some(expr) => {
1357                    if let Some(v) = self.resolve_value(&expr) {
1358                        Some(v)
1359                    } else {
1360                        self.unsupported(line);
1361                        return true;
1362                    }
1363                }
1364            };
1365            staged.push(make_install_step(recv, &self.ctx.tool, from, rename));
1366        }
1367        if staged.is_empty() {
1368            self.unsupported(line);
1369        } else {
1370            self.steps.extend(staged);
1371        }
1372        true
1373    }
1374
1375    /// `resource("name").stage` / `.stage(dir)` / `.stage do` (empty body
1376    /// only).
1377    fn resource_stage_line(&mut self, line: &str) -> bool {
1378        let Some(rest) = line.strip_prefix("resource(") else {
1379            return false;
1380        };
1381        let Some((name_part, tail)) = rest.split_once(')') else {
1382            self.unsupported_line(line);
1383            return true;
1384        };
1385        let Some(name) = quoted_literal(name_part) else {
1386            self.unsupported_line(line);
1387            return true;
1388        };
1389        if tail == ".stage" {
1390            self.steps
1391                .push(InstallStep::StageResource { name, dir: None });
1392        } else if tail == ".stage do" || (tail.starts_with(".stage do |") && tail.ends_with('|')) {
1393            // Block form: only an EMPTY body is equivalent to a plain stage;
1394            // anything else is opaque Ruby.
1395            if self.skip_block_counting() == 0 {
1396                self.steps
1397                    .push(InstallStep::StageResource { name, dir: None });
1398            } else {
1399                self.unsupported(line);
1400            }
1401        } else if let Some(dir_expr) = tail
1402            .strip_prefix(".stage(")
1403            .and_then(|t| t.strip_suffix(')'))
1404        {
1405            match self.resolve_value(dir_expr) {
1406                Some(dir) => self.steps.push(InstallStep::StageResource {
1407                    name,
1408                    dir: normalize_stage_dir(&dir),
1409                }),
1410                None => self.unsupported(line),
1411            }
1412        } else {
1413            self.unsupported_line(line);
1414        }
1415        true
1416    }
1417
1418    // -- value resolution --------------------------------------------------------
1419
1420    /// Resolve a full Ruby value expression (quoted string with
1421    /// interpolations, path expression, known helper) to a concrete string.
1422    fn resolve_value(&self, expr: &str) -> Option<String> {
1423        let expr = expr.trim();
1424        if let Some(body) = full_quoted_body(expr, '"') {
1425            return self.resolve_dquote(body);
1426        }
1427        if let Some(body) = full_quoted_body(expr, '\'') {
1428            return Some(unescape_single(body));
1429        }
1430        self.resolve_expr(expr)
1431    }
1432
1433    /// Resolve a double-quoted string body: escapes + `#{...}` interpolations.
1434    fn resolve_dquote(&self, body: &str) -> Option<String> {
1435        let mut out = String::new();
1436        let bytes = body.as_bytes();
1437        let mut i = 0;
1438        while i < body.len() {
1439            let c = body[i..].chars().next()?;
1440            if c == '\\' {
1441                let next = body[i + 1..].chars().next()?;
1442                out.push(match next {
1443                    'n' => '\n',
1444                    't' => '\t',
1445                    's' => ' ',
1446                    '0' => '\0',
1447                    'e' => '\u{1b}',
1448                    other => other,
1449                });
1450                i += 1 + next.len_utf8();
1451                continue;
1452            }
1453            if c == '#' && bytes.get(i + 1) == Some(&b'{') {
1454                let close = body[i + 2..].find('}')? + i + 2;
1455                out.push_str(&self.resolve_interp(&body[i + 2..close])?);
1456                i = close + 1;
1457                continue;
1458            }
1459            out.push(c);
1460            i += c.len_utf8();
1461        }
1462        Some(out)
1463    }
1464
1465    /// Resolve the inside of a `#{...}` interpolation.
1466    fn resolve_interp(&self, inner: &str) -> Option<String> {
1467        self.resolve_value(inner)
1468    }
1469
1470    /// Resolve a bare (non-string) expression: known idents, `Formula[...]`
1471    /// accessors, `formula_opt_prefix(...)`, and `/`-joined path expressions.
1472    fn resolve_expr(&self, expr: &str) -> Option<String> {
1473        if let Some(v) = self.resolve_atom(expr) {
1474            return Some(v);
1475        }
1476        let segs = split_top_level_slash(expr);
1477        if segs.len() >= 2 {
1478            let mut path = self.resolve_atom(segs[0].trim())?;
1479            for seg in &segs[1..] {
1480                let part = self.resolve_value(seg.trim())?;
1481                let base = path.trim_end_matches('/').to_string();
1482                path = format!("{base}/{part}");
1483            }
1484            return Some(path);
1485        }
1486        None
1487    }
1488
1489    /// Resolve a single known identifier / accessor.
1490    fn resolve_atom(&self, expr: &str) -> Option<String> {
1491        let prefix = self.ctx.prefix.display().to_string();
1492        let subdir = |d: &str| Some(format!("{prefix}/{d}"));
1493        match expr {
1494            "prefix" | "opt_prefix" => Some(prefix),
1495            "bin" | "opt_bin" => subdir("bin"),
1496            "lib" | "opt_lib" => subdir("lib"),
1497            "include" | "opt_include" => subdir("include"),
1498            "libexec" | "opt_libexec" => subdir("libexec"),
1499            "share" | "opt_share" => subdir("share"),
1500            "etc" => subdir("etc"),
1501            "var" => subdir("var"),
1502            "pkgshare" => subdir(&format!("share/{}", self.ctx.tool)),
1503            "man" => subdir("share/man"),
1504            "buildpath" => Some(".".to_string()),
1505            "version" => Some(self.ctx.version.clone()),
1506            "name" => Some(self.ctx.tool.clone()),
1507            "ENV.cc" => Some("cc".to_string()),
1508            "ENV.cxx" => Some("c++".to_string()),
1509            _ => {
1510                if let Some(rest) = expr.strip_prefix("man") {
1511                    if rest.len() == 1 && rest.chars().all(|c| c.is_ascii_digit()) {
1512                        return subdir(&format!("share/man/man{rest}"));
1513                    }
1514                }
1515                self.resolve_formula_accessor(expr)
1516            }
1517        }
1518    }
1519
1520    /// Resolve `Formula["dep"].opt_*` / `formula_opt_prefix("dep")` through
1521    /// [`RecipeCtx::dep_prefixes`].
1522    fn resolve_formula_accessor(&self, expr: &str) -> Option<String> {
1523        if let Some(rest) = expr.strip_prefix("Formula[") {
1524            let (name_part, attr_part) = rest.split_once(']')?;
1525            let dep = quoted_literal(name_part)?;
1526            let attr = attr_part.strip_prefix('.')?;
1527            let base = self.ctx.dep_prefixes.get(&dep)?.display().to_string();
1528            return match attr {
1529                "opt_prefix" | "prefix" => Some(base),
1530                "opt_bin" | "bin" => Some(format!("{base}/bin")),
1531                "opt_lib" | "lib" => Some(format!("{base}/lib")),
1532                "opt_include" | "include" => Some(format!("{base}/include")),
1533                "opt_libexec" | "libexec" => Some(format!("{base}/libexec")),
1534                "opt_share" | "share" => Some(format!("{base}/share")),
1535                _ => None,
1536            };
1537        }
1538        let dep = expr
1539            .strip_prefix("formula_opt_prefix(")
1540            .and_then(|r| r.strip_suffix(')'))
1541            .and_then(quoted_literal)?;
1542        self.ctx
1543            .dep_prefixes
1544            .get(&dep)
1545            .map(|p| p.display().to_string())
1546    }
1547
1548    // -- finalization ---------------------------------------------------------
1549
1550    /// Backfill `stage_to`, prepend `ApplyPatch` steps, and build the plan.
1551    fn finish(mut self) -> InstallPlan {
1552        let stages: Vec<(String, Option<String>)> = self
1553            .steps
1554            .iter()
1555            .filter_map(|s| match s {
1556                InstallStep::StageResource { name, dir } => Some((name.clone(), dir.clone())),
1557                _ => None,
1558            })
1559            .collect();
1560        for (name, dir) in stages {
1561            if let Some(resource) = self.resources.iter_mut().find(|r| r.name == name) {
1562                resource.stage_to = dir;
1563            }
1564        }
1565        let mut steps: Vec<InstallStep> = (0..self.patches.len())
1566            .map(|index| InstallStep::ApplyPatch { index })
1567            .collect();
1568        steps.append(&mut self.steps);
1569        InstallPlan {
1570            steps,
1571            resources: self.resources,
1572            patches: self.patches,
1573            env: self.env,
1574            deparallelize: self.deparallelize,
1575        }
1576    }
1577}
1578
1579/// Parse a class-scope `resource "name" do` opener.
1580fn parse_resource_open(line: &str) -> Option<String> {
1581    let rest = line.strip_prefix("resource ")?;
1582    let body = rest.strip_suffix(" do")?;
1583    quoted_literal(body)
1584}
1585
1586/// Classify a fully-resolved `system` argv into its typed step.
1587fn classify_system(argv: Vec<String>) -> InstallStep {
1588    let rest = || argv[1..].to_vec();
1589    match argv[0].as_str() {
1590        "./configure" => InstallStep::Configure { args: rest() },
1591        "make" => InstallStep::Make { args: rest() },
1592        "cmake" => InstallStep::CMake { args: rest() },
1593        "./bootstrap" => InstallStep::Bootstrap { args: rest() },
1594        "cargo" => InstallStep::Cargo { args: rest() },
1595        "go" => InstallStep::Go { args: rest() },
1596        "meson" | "ninja" => InstallStep::Meson { args: argv },
1597        _ => InstallStep::System { argv },
1598    }
1599}
1600
1601/// Map an install receiver + resolved from/rename to its step.
1602fn make_install_step(recv: &str, tool: &str, from: String, rename: Option<String>) -> InstallStep {
1603    let under = |dir: &str, rename: Option<String>| {
1604        Some(rename.map_or_else(|| dir.to_string(), |r| format!("{dir}/{r}")))
1605    };
1606    match recv {
1607        "bin" => InstallStep::BinInstall { from, to: rename },
1608        "lib" => InstallStep::LibInstall { from, to: rename },
1609        "include" => InstallStep::IncludeInstall { from, to: rename },
1610        "libexec" => InstallStep::PrefixInstall {
1611            from,
1612            to: under("libexec", rename),
1613        },
1614        "share" => InstallStep::PrefixInstall {
1615            from,
1616            to: under("share", rename),
1617        },
1618        "man1" => InstallStep::PrefixInstall {
1619            from,
1620            to: under("share/man/man1", rename),
1621        },
1622        "pkgshare" => InstallStep::PrefixInstall {
1623            from,
1624            to: under(&format!("share/{tool}"), rename),
1625        },
1626        _ => InstallStep::PrefixInstall { from, to: rename },
1627    }
1628}
1629
1630/// Normalize a resolved stage dir: strip the `./` buildpath prefix; a bare
1631/// `.` means "current dir" (`None`).
1632fn normalize_stage_dir(dir: &str) -> Option<String> {
1633    let trimmed = dir.strip_prefix("./").unwrap_or(dir);
1634    if trimmed.is_empty() || trimmed == "." {
1635        None
1636    } else {
1637        Some(trimmed.to_string())
1638    }
1639}
1640
1641/// Return the body of `expr` when it is EXACTLY one quoted string (the
1642/// closing quote is the final character). Double-quoted strings may carry
1643/// `#{...}` interpolations whose contents — including nested quotes, e.g.
1644/// `"#{Formula["dep"].opt_prefix}"` — are skipped opaquely.
1645fn full_quoted_body(expr: &str, quote: char) -> Option<&str> {
1646    if expr.len() < 2 || !expr.starts_with(quote) {
1647        return None;
1648    }
1649    let body = &expr[1..];
1650    let bytes = body.as_bytes();
1651    let mut escaped = false;
1652    let mut in_interp = false;
1653    for (i, c) in body.char_indices() {
1654        if escaped {
1655            escaped = false;
1656            continue;
1657        }
1658        if in_interp {
1659            if c == '}' {
1660                in_interp = false;
1661            }
1662            continue;
1663        }
1664        if c == '\\' {
1665            escaped = true;
1666        } else if quote == '"' && c == '#' && bytes.get(i + 1) == Some(&b'{') {
1667            in_interp = true;
1668        } else if c == quote {
1669            return (i == body.len() - 1).then(|| &body[..i]);
1670        }
1671    }
1672    None
1673}
1674
1675/// Unescape a single-quoted Ruby string body (`\'` and `\\` only).
1676fn unescape_single(body: &str) -> String {
1677    let mut out = String::new();
1678    let mut chars = body.chars();
1679    while let Some(c) = chars.next() {
1680        if c == '\\' {
1681            match chars.next() {
1682                Some(next @ ('\'' | '\\')) => out.push(next),
1683                Some(next) => {
1684                    out.push('\\');
1685                    out.push(next);
1686                }
1687                None => out.push('\\'),
1688            }
1689        } else {
1690            out.push(c);
1691        }
1692    }
1693    out
1694}
1695
1696/// Split an expression on top-level `/` (outside strings and brackets) for
1697/// path expressions like `libexec/"git-core"`.
1698fn split_top_level_slash(expr: &str) -> Vec<&str> {
1699    let mut out = Vec::new();
1700    let mut in_double = false;
1701    let mut in_single = false;
1702    let mut escaped = false;
1703    let mut depth = 0i32;
1704    let mut start = 0;
1705    for (i, c) in expr.char_indices() {
1706        if escaped {
1707            escaped = false;
1708            continue;
1709        }
1710        match c {
1711            '\\' if in_double || in_single => escaped = true,
1712            '"' if !in_single => in_double = !in_double,
1713            '\'' if !in_double => in_single = !in_single,
1714            '(' | '[' | '{' if !in_double && !in_single => depth += 1,
1715            ')' | ']' | '}' if !in_double && !in_single => depth -= 1,
1716            '/' if !in_double && !in_single && depth == 0 => {
1717                out.push(&expr[start..i]);
1718                start = i + 1;
1719            }
1720            _ => {}
1721        }
1722    }
1723    out.push(&expr[start..]);
1724    out
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    /// Test context: `/tc/<tool>` prefix, version 1.0.0.
1732    fn ctx(tool: &str, mac: bool, arm: bool) -> RecipeCtx {
1733        RecipeCtx {
1734            tool: tool.to_string(),
1735            version: "1.0.0".to_string(),
1736            prefix: PathBuf::from(format!("/tc/{tool}")),
1737            dep_prefixes: HashMap::new(),
1738            target_macos: mac,
1739            target_arm: arm,
1740        }
1741    }
1742
1743    fn plan(rb: &str, ctx: &RecipeCtx) -> InstallPlan {
1744        parse_install_plan(rb, ctx).expect("plan should parse")
1745    }
1746
1747    /// 1. jq-style autotools formula → exact expected plan.
1748    #[test]
1749    fn autotools_std_configure_args_exact_plan() {
1750        let rb = r#"
1751class Foo < Formula
1752  url "https://example.com/foo-1.0.0.tar.gz"
1753  sha256 "0000000000000000000000000000000000000000000000000000000000000000"
1754
1755  def install
1756    system "./configure", *std_configure_args
1757    system "make"
1758    system "make", "install"
1759  end
1760end
1761"#;
1762        let p = plan(rb, &ctx("foo", true, true));
1763        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
1764        assert_eq!(
1765            p.steps,
1766            vec![
1767                InstallStep::Configure {
1768                    args: vec![
1769                        "--prefix=/tc/foo".into(),
1770                        "--libdir=/tc/foo/lib".into(),
1771                        "--disable-debug".into(),
1772                        "--disable-dependency-tracking".into(),
1773                        "--disable-silent-rules".into(),
1774                    ]
1775                },
1776                InstallStep::Make { args: vec![] },
1777                InstallStep::Make {
1778                    args: vec!["install".into()]
1779                },
1780            ]
1781        );
1782        assert!(p.resources.is_empty() && p.patches.is_empty() && p.env.is_empty());
1783        assert!(!p.deparallelize);
1784    }
1785
1786    /// 2. git-style big-args `make install` with resolved interpolations.
1787    #[test]
1788    fn make_install_big_args_resolves_prefix() {
1789        let rb = r#"
1790class Foo < Formula
1791  def install
1792    system "make", "install", "prefix=#{prefix}", "sysconfdir=#{etc}", "CC=#{ENV.cc}", "CXX=#{ENV.cxx}"
1793  end
1794end
1795"#;
1796        let p = plan(rb, &ctx("foo", true, true));
1797        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
1798        assert_eq!(
1799            p.steps,
1800            vec![InstallStep::Make {
1801                args: vec![
1802                    "install".into(),
1803                    "prefix=/tc/foo".into(),
1804                    "sysconfdir=/tc/foo/etc".into(),
1805                    "CC=cc".into(),
1806                    "CXX=c++".into(),
1807                ]
1808            }]
1809        );
1810    }
1811
1812    /// 3. cargo formula → `std_cargo_args` expansion.
1813    #[test]
1814    fn cargo_std_args_expand() {
1815        let rb = r#"
1816class Foo < Formula
1817  def install
1818    system "cargo", "install", *std_cargo_args
1819  end
1820end
1821"#;
1822        let p = plan(rb, &ctx("foo", true, true));
1823        assert_eq!(
1824            p.steps,
1825            vec![InstallStep::Cargo {
1826                args: vec![
1827                    "install".into(),
1828                    "--locked".into(),
1829                    "--root".into(),
1830                    "/tc/foo".into(),
1831                    "--path".into(),
1832                    ".".into(),
1833                ]
1834            }]
1835        );
1836    }
1837
1838    /// 4. cmake formula → `std_cmake_args` expansion, one step per invocation.
1839    #[test]
1840    fn cmake_std_args_expand_per_invocation() {
1841        let rb = r#"
1842class Foo < Formula
1843  def install
1844    system "cmake", "-S", ".", "-B", "build", *std_cmake_args
1845    system "cmake", "--build", "build"
1846    system "cmake", "--install", "build"
1847  end
1848end
1849"#;
1850        let p = plan(rb, &ctx("foo", true, true));
1851        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
1852        assert_eq!(
1853            p.steps,
1854            vec![
1855                InstallStep::CMake {
1856                    args: vec![
1857                        "-S".into(),
1858                        ".".into(),
1859                        "-B".into(),
1860                        "build".into(),
1861                        "-DCMAKE_INSTALL_PREFIX=/tc/foo".into(),
1862                        "-DCMAKE_BUILD_TYPE=Release".into(),
1863                        "-DCMAKE_FIND_FRAMEWORK=LAST".into(),
1864                        "-DBUILD_TESTING=OFF".into(),
1865                    ]
1866                },
1867                InstallStep::CMake {
1868                    args: vec!["--build".into(), "build".into()]
1869                },
1870                InstallStep::CMake {
1871                    args: vec!["--install".into(), "build".into()]
1872                },
1873            ]
1874        );
1875    }
1876
1877    /// 5. `on_macos` / `on_linux` blocks constant-fold per target.
1878    #[test]
1879    fn on_macos_on_linux_blocks_fold() {
1880        let rb = r#"
1881class Foo < Formula
1882  def install
1883    on_macos do
1884      ENV["MAC"] = "1"
1885    end
1886    on_linux do
1887      ENV["LINUX"] = "1"
1888    end
1889    system "make"
1890  end
1891end
1892"#;
1893        let mac = plan(rb, &ctx("foo", true, true));
1894        assert!(mac.is_fully_supported());
1895        assert_eq!(mac.env.get("MAC"), Some(&"1".to_string()));
1896        assert!(!mac.env.contains_key("LINUX"));
1897
1898        let linux = plan(rb, &ctx("foo", false, false));
1899        assert_eq!(linux.env.get("LINUX"), Some(&"1".to_string()));
1900        assert!(!linux.env.contains_key("MAC"));
1901    }
1902
1903    /// 6. `if Hardware::CPU.arm?` / `else` folds both ways.
1904    #[test]
1905    fn hardware_cpu_arm_if_else_folds_both_ways() {
1906        let rb = r#"
1907class Foo < Formula
1908  def install
1909    if Hardware::CPU.arm?
1910      ENV["ARCH"] = "arm64"
1911    else
1912      ENV["ARCH"] = "x86_64"
1913    end
1914    system "make"
1915  end
1916end
1917"#;
1918        let arm = plan(rb, &ctx("foo", true, true));
1919        assert!(arm.is_fully_supported());
1920        assert_eq!(arm.env.get("ARCH"), Some(&"arm64".to_string()));
1921
1922        let intel = plan(rb, &ctx("foo", true, false));
1923        assert!(intel.is_fully_supported());
1924        assert_eq!(intel.env.get("ARCH"), Some(&"x86_64".to_string()));
1925    }
1926
1927    /// 7. resource block + install-body stage → `ResourceSpec` (with
1928    ///    `stage_to` backfilled) + `StageResource`.
1929    #[test]
1930    fn resource_block_and_stage_dir() {
1931        let rb = r#"
1932class Foo < Formula
1933  resource "vendor" do
1934    url "https://example.com/vendor-2.0.tar.gz"
1935    sha256 "1111111111111111111111111111111111111111111111111111111111111111"
1936  end
1937
1938  def install
1939    resource("vendor").stage(buildpath/"vendor")
1940    system "make"
1941  end
1942end
1943"#;
1944        let p = plan(rb, &ctx("foo", true, true));
1945        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
1946        assert_eq!(
1947            p.resources,
1948            vec![ResourceSpec {
1949                name: "vendor".into(),
1950                url: "https://example.com/vendor-2.0.tar.gz".into(),
1951                sha256: "1111111111111111111111111111111111111111111111111111111111111111".into(),
1952                stage_to: Some("vendor".into()),
1953            }]
1954        );
1955        assert_eq!(
1956            p.steps[0],
1957            InstallStep::StageResource {
1958                name: "vendor".into(),
1959                dir: Some("vendor".into())
1960            }
1961        );
1962    }
1963
1964    /// 8. patch blocks → `PatchSpec` (strip 1 default, `:p0` → 0) with
1965    ///    `ApplyPatch` steps prepended; `patch :DATA` → Unsupported.
1966    #[test]
1967    fn patch_blocks_and_patch_data() {
1968        let rb = r#"
1969class Foo < Formula
1970  patch do
1971    url "https://example.com/fix.patch"
1972    sha256 "2222222222222222222222222222222222222222222222222222222222222222"
1973  end
1974
1975  patch :p0 do
1976    url "https://example.com/fix0.patch"
1977    sha256 "3333333333333333333333333333333333333333333333333333333333333333"
1978  end
1979
1980  patch :DATA
1981
1982  def install
1983    system "make"
1984  end
1985end
1986"#;
1987        let p = plan(rb, &ctx("foo", true, true));
1988        assert_eq!(
1989            p.patches,
1990            vec![
1991                PatchSpec {
1992                    url: "https://example.com/fix.patch".into(),
1993                    sha256: "2222222222222222222222222222222222222222222222222222222222222222"
1994                        .into(),
1995                    strip: 1
1996                },
1997                PatchSpec {
1998                    url: "https://example.com/fix0.patch".into(),
1999                    sha256: "3333333333333333333333333333333333333333333333333333333333333333"
2000                        .into(),
2001                    strip: 0
2002                },
2003            ]
2004        );
2005        assert_eq!(p.steps[0], InstallStep::ApplyPatch { index: 0 });
2006        assert_eq!(p.steps[1], InstallStep::ApplyPatch { index: 1 });
2007        assert!(!p.is_fully_supported());
2008        assert_eq!(p.unsupported_constructs(), vec!["patch :DATA"]);
2009    }
2010
2011    /// 9. Loops, unknown helpers, and unresolvable interpolations surface as
2012    ///    Unsupported with the construct text.
2013    #[test]
2014    fn unsupported_constructs_surface_with_text() {
2015        let rb = r##"
2016class Foo < Formula
2017  def install
2018    %w[a b].each do |f|
2019      bin.install f
2020    end
2021    system "make", *args
2022    ENV["X"] = which("python3")
2023    system "install", "-m", "0755", "#{testpath}/x", bin
2024    cd "sub" do
2025      system "make"
2026    end
2027  end
2028end
2029"##;
2030        let p = plan(rb, &ctx("foo", true, true));
2031        assert!(!p.is_fully_supported());
2032        let constructs = p.unsupported_constructs();
2033        assert_eq!(constructs.len(), 5, "{constructs:?}");
2034        assert!(constructs[0].starts_with("%w[a b].each do"));
2035        assert_eq!(constructs[1], "system \"make\", *args");
2036        assert_eq!(constructs[2], "ENV[\"X\"] = which(\"python3\")");
2037        assert!(constructs[3].starts_with("system \"install\""));
2038        assert!(constructs[4].starts_with("cd \"sub\" do"));
2039        // The loop body's bin.install must NOT leak out as a step.
2040        assert!(!p
2041            .steps
2042            .iter()
2043            .any(|s| matches!(s, InstallStep::BinInstall { .. })));
2044    }
2045
2046    /// 10. `bin.install` rename + multi-arg forms; subdir receivers.
2047    #[test]
2048    fn install_receivers_rename_and_subdirs() {
2049        let rb = r#"
2050class Foo < Formula
2051  def install
2052    bin.install "foo.sh" => "foo"
2053    bin.install "a", "b"
2054    lib.install "libfoo.a"
2055    include.install "foo.h" => "foo/foo.h"
2056    libexec.install "helper" => "h2"
2057    share.install "data.dat"
2058    man1.install "foo.1"
2059    pkgshare.install "examples"
2060    prefix.install "README"
2061  end
2062end
2063"#;
2064        let p = plan(rb, &ctx("foo", true, true));
2065        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
2066        assert_eq!(
2067            p.steps,
2068            vec![
2069                InstallStep::BinInstall {
2070                    from: "foo.sh".into(),
2071                    to: Some("foo".into())
2072                },
2073                InstallStep::BinInstall {
2074                    from: "a".into(),
2075                    to: None
2076                },
2077                InstallStep::BinInstall {
2078                    from: "b".into(),
2079                    to: None
2080                },
2081                InstallStep::LibInstall {
2082                    from: "libfoo.a".into(),
2083                    to: None
2084                },
2085                InstallStep::IncludeInstall {
2086                    from: "foo.h".into(),
2087                    to: Some("foo/foo.h".into())
2088                },
2089                InstallStep::PrefixInstall {
2090                    from: "helper".into(),
2091                    to: Some("libexec/h2".into())
2092                },
2093                InstallStep::PrefixInstall {
2094                    from: "data.dat".into(),
2095                    to: Some("share".into())
2096                },
2097                InstallStep::PrefixInstall {
2098                    from: "foo.1".into(),
2099                    to: Some("share/man/man1".into())
2100                },
2101                InstallStep::PrefixInstall {
2102                    from: "examples".into(),
2103                    to: Some("share/foo".into())
2104                },
2105                InstallStep::PrefixInstall {
2106                    from: "README".into(),
2107                    to: None
2108                },
2109            ]
2110        );
2111    }
2112
2113    /// 11. ENV set / append / prepend / deparallelize.
2114    #[test]
2115    fn env_set_append_prepend_deparallelize() {
2116        let rb = r#"
2117class Foo < Formula
2118  def install
2119    ENV["CFLAGS"] = "-O2"
2120    ENV.append "CFLAGS", "-fPIC"
2121    ENV.prepend "LDFLAGS", "-L#{lib}"
2122    ENV.deparallelize
2123    system "make"
2124  end
2125end
2126"#;
2127        let p = plan(rb, &ctx("foo", true, true));
2128        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
2129        assert!(p.deparallelize);
2130        assert_eq!(
2131            p.steps[..3],
2132            [
2133                InstallStep::EnvSet {
2134                    key: "CFLAGS".into(),
2135                    value: "-O2".into()
2136                },
2137                InstallStep::EnvSet {
2138                    key: "CFLAGS".into(),
2139                    value: "-O2 -fPIC".into()
2140                },
2141                InstallStep::EnvSet {
2142                    key: "LDFLAGS".into(),
2143                    value: "-L/tc/foo/lib".into()
2144                },
2145            ]
2146        );
2147        assert_eq!(p.env.get("CFLAGS"), Some(&"-O2 -fPIC".to_string()));
2148        assert_eq!(p.env.get("LDFLAGS"), Some(&"-L/tc/foo/lib".to_string()));
2149    }
2150
2151    /// 12. `inreplace` literal form works; block form is Unsupported.
2152    #[test]
2153    fn inreplace_literal_and_block_forms() {
2154        let rb = r##"
2155class Foo < Formula
2156  def install
2157    inreplace "Makefile", "@PREFIX@", "#{prefix}"
2158    inreplace "config" do |s|
2159      s.gsub! "a", "b"
2160    end
2161  end
2162end
2163"##;
2164        let p = plan(rb, &ctx("foo", true, true));
2165        assert_eq!(
2166            p.steps[0],
2167            InstallStep::Inreplace {
2168                file: "Makefile".into(),
2169                from: "@PREFIX@".into(),
2170                to: "/tc/foo".into()
2171            }
2172        );
2173        assert_eq!(p.unsupported_constructs().len(), 1);
2174        assert!(p.unsupported_constructs()[0].starts_with("inreplace \"config\" do"));
2175    }
2176
2177    /// `Formula["dep"].opt_*` resolves through `dep_prefixes`; a missing dep
2178    /// is Unsupported.
2179    #[test]
2180    fn formula_dep_accessors_resolve_or_unsupported() {
2181        let rb = r#"
2182class Foo < Formula
2183  def install
2184    system "./configure", "--with-ssl=#{Formula["openssl@3"].opt_prefix}", "--with-zlib=#{Formula["missing"].opt_lib}"
2185  end
2186end
2187"#;
2188        let mut c = ctx("foo", true, true);
2189        c.dep_prefixes
2190            .insert("openssl@3".into(), PathBuf::from("/tc/openssl@3"));
2191        let p = plan(rb, &c);
2192        // "missing" is not in dep_prefixes → the whole line is Unsupported.
2193        assert!(!p.is_fully_supported());
2194
2195        let rb_ok = r#"
2196class Foo < Formula
2197  def install
2198    system "./configure", "--with-ssl=#{Formula["openssl@3"].opt_prefix}", "--sbin=#{Formula["openssl@3"].opt_bin}"
2199  end
2200end
2201"#;
2202        let p = plan(rb_ok, &c);
2203        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
2204        assert_eq!(
2205            p.steps,
2206            vec![InstallStep::Configure {
2207                args: vec![
2208                    "--with-ssl=/tc/openssl@3".into(),
2209                    "--sbin=/tc/openssl@3/bin".into(),
2210                ]
2211            }]
2212        );
2213    }
2214
2215    /// `std_go_args` expansion with kwargs; unknown kwargs → Unsupported.
2216    #[test]
2217    fn go_std_args_kwargs() {
2218        let rb = r#"
2219class Foo < Formula
2220  def install
2221    system "go", "build", *std_go_args(ldflags: "-s -w")
2222    system "go", "build", *std_go_args(output: bin/"gadget")
2223    system "go", "build", *std_go_args(wat: "x")
2224  end
2225end
2226"#;
2227        let p = plan(rb, &ctx("foo", true, true));
2228        assert_eq!(
2229            p.steps[0],
2230            InstallStep::Go {
2231                args: vec![
2232                    "build".into(),
2233                    "-trimpath".into(),
2234                    "-o=/tc/foo/bin/foo".into(),
2235                    "-ldflags=-s -w".into(),
2236                ]
2237            }
2238        );
2239        assert_eq!(
2240            p.steps[1],
2241            InstallStep::Go {
2242                args: vec![
2243                    "build".into(),
2244                    "-trimpath".into(),
2245                    "-o=/tc/foo/bin/gadget".into(),
2246                ]
2247            }
2248        );
2249        assert_eq!(p.unsupported_constructs().len(), 1);
2250    }
2251
2252    /// meson/ninja land as Meson steps carrying the full argv; unknown but
2253    /// fully-literal commands land as System.
2254    #[test]
2255    fn meson_ninja_and_generic_system() {
2256        let rb = r##"
2257class Foo < Formula
2258  def install
2259    system "meson", "setup", "build"
2260    system "ninja", "-C", "build", "install"
2261    system "install_name_tool", "-id", "#{lib}/libfoo.dylib", "libfoo.dylib"
2262  end
2263end
2264"##;
2265        let p = plan(rb, &ctx("foo", true, true));
2266        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
2267        assert_eq!(
2268            p.steps,
2269            vec![
2270                InstallStep::Meson {
2271                    args: vec!["meson".into(), "setup".into(), "build".into()]
2272                },
2273                InstallStep::Meson {
2274                    args: vec![
2275                        "ninja".into(),
2276                        "-C".into(),
2277                        "build".into(),
2278                        "install".into()
2279                    ]
2280                },
2281                InstallStep::System {
2282                    argv: vec![
2283                        "install_name_tool".into(),
2284                        "-id".into(),
2285                        "/tc/foo/lib/libfoo.dylib".into(),
2286                        "libfoo.dylib".into(),
2287                    ]
2288                },
2289            ]
2290        );
2291    }
2292
2293    /// `fetch_recipe` on a formula with no `ruby_source_path` errors clearly.
2294    #[tokio::test]
2295    async fn fetch_recipe_without_source_path_errors() {
2296        let formula = FormulaData::default();
2297        let err = fetch_recipe(&formula).await.unwrap_err();
2298        assert!(matches!(err, ToolchainError::RegistryError { .. }));
2299        assert!(err.to_string().contains("ruby_source_path"));
2300    }
2301
2302    /// `fetch_recipe_cached` returns the cache file without any network.
2303    #[tokio::test]
2304    async fn fetch_recipe_cached_reads_cache_file() {
2305        let tmp = tempfile::tempdir().unwrap();
2306        let cache = tmp.path().join(".build/recipe.rb");
2307        tokio::fs::create_dir_all(cache.parent().unwrap())
2308            .await
2309            .unwrap();
2310        tokio::fs::write(&cache, "class Foo < Formula\nend\n")
2311            .await
2312            .unwrap();
2313        // No ruby_source_path — fetch would fail, so success proves the cache
2314        // was used.
2315        let text = fetch_recipe_cached(&FormulaData::default(), &cache)
2316            .await
2317            .unwrap();
2318        assert_eq!(text, "class Foo < Formula\nend\n");
2319    }
2320
2321    /// A formula without a `def install` block is an error.
2322    #[test]
2323    fn missing_install_block_errors() {
2324        let rb = "class Foo < Formula\n  url \"x\"\nend\n";
2325        let err = parse_install_plan(rb, &ctx("foo", true, true)).unwrap_err();
2326        assert!(err.to_string().contains("def install"));
2327    }
2328
2329    // -- 13. real-world formulas (fetched 2026-07-06 from
2330    //    raw.githubusercontent.com/Homebrew/homebrew-core HEAD) ---------------
2331
2332    /// The REAL current jq.rb parses fully supported: the `build.head?`
2333    /// autoreconf line folds away, `std_configure_args` expands, and the
2334    /// multi-line configure call joins.
2335    #[test]
2336    fn real_jq_formula_is_fully_supported() {
2337        let p = plan(JQ_RB, &ctx("jq", true, true));
2338        assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
2339        assert_eq!(
2340            p.steps,
2341            vec![
2342                InstallStep::Configure {
2343                    args: vec![
2344                        "--prefix=/tc/jq".into(),
2345                        "--libdir=/tc/jq/lib".into(),
2346                        "--disable-debug".into(),
2347                        "--disable-dependency-tracking".into(),
2348                        "--disable-silent-rules".into(),
2349                        "--disable-silent-rules".into(),
2350                        "--disable-maintainer-mode".into(),
2351                    ]
2352                },
2353                InstallStep::Make {
2354                    args: vec!["install".into()]
2355                },
2356            ]
2357        );
2358        assert!(p.resources.is_empty() && p.patches.is_empty());
2359    }
2360
2361    /// The REAL current git.rb parses with its 4 resources, its 6 literal ENV
2362    /// sets, and a LOCKED set of Unsupported constructs (git's install body
2363    /// is heavy Ruby — contrib cd-blocks, %W arrays, resource-stage blocks —
2364    /// so it is expected to route to the fallback path).
2365    #[test]
2366    fn real_git_formula_locks_unsupported_set() {
2367        let mut c = ctx("git", true, true);
2368        c.dep_prefixes
2369            .insert("pcre2".into(), PathBuf::from("/tc/pcre2"));
2370        let p = plan(GIT_RB, &c);
2371        assert!(!p.is_fully_supported());
2372
2373        // Resources: Authen::SASL, html, man, Net::SMTP::SSL.
2374        let names: Vec<&str> = p.resources.iter().map(|r| r.name.as_str()).collect();
2375        assert_eq!(names, vec!["Authen::SASL", "html", "man", "Net::SMTP::SSL"]);
2376
2377        // The literal ENV sets all resolve (LIBPCREDIR via formula_opt_prefix).
2378        for (key, value) in [
2379            ("NO_FINK", "1"),
2380            ("NO_DARWIN_PORTS", "1"),
2381            ("USE_LIBPCRE2", "1"),
2382            ("INSTALL_SYMLINKS", "1"),
2383            ("LIBPCREDIR", "/tc/pcre2"),
2384            ("V", "1"),
2385        ] {
2386            assert_eq!(p.env.get(key), Some(&value.to_string()), "env {key}");
2387        }
2388        assert_eq!(p.env.len(), 6);
2389
2390        // Every supported step is an EnvSet; everything else is Unsupported.
2391        assert!(p.steps.iter().all(|s| matches!(
2392            s,
2393            InstallStep::EnvSet { .. } | InstallStep::Unsupported { .. }
2394        )));
2395
2396        // Lock the Unsupported set: every construct must match one of these
2397        // expected markers, and the count is pinned.
2398        let constructs = p.unsupported_constructs();
2399        let expected_markers = [
2400            "odie ",
2401            "ENV[\"PYTHON_PATH\"] = which(\"python3\")",
2402            "ENV[\"PERL_PATH\"] = which(\"perl\")",
2403            "perl_version = ",
2404            "ENV[\"PERLLIB_EXTRA\"] = %W[",
2405            "args = %W[",
2406            "args += if OS.mac?",
2407            "inreplace \"Makefile\"",
2408            "system \"make\", \"install\", *args",
2409            "git_core = ",
2410            "rm ",
2411            "cd \"contrib/",
2412            "bash_completion.install ",
2413            "zsh_completion.install ",
2414            "cp \"#{bash_completion}/",
2415            "(share/\"git-core\").install ",
2416            "man.install ",
2417            "(share/\"doc/git-doc\").install ",
2418            "chmod ",
2419            "resource(\"Net::SMTP::SSL\").stage do",
2420            "resource(\"Authen::SASL\").stage do",
2421            "perl_dir = ",
2422            "rm_r ",
2423            "(buildpath/\"gitconfig\").write ",
2424            "etc.install ",
2425        ];
2426        for construct in &constructs {
2427            assert!(
2428                expected_markers.iter().any(|m| construct.starts_with(m)),
2429                "unexpected unsupported construct: {construct}"
2430            );
2431        }
2432        assert_eq!(constructs.len(), 33, "{constructs:#?}");
2433    }
2434
2435    // Real formula sources, embedded verbatim (see test docs above).
2436    const JQ_RB: &str = r##"class Jq < Formula
2437  desc "Lightweight and flexible command-line JSON processor"
2438  homepage "https://jqlang.github.io/jq/"
2439  url "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-1.8.2.tar.gz"
2440  sha256 "71b8d6e8f5fe81f6c6d0d110e3892251f6ce76ed095abd315e26e6e1193af3af"
2441  license "MIT"
2442  compatibility_version 1
2443
2444  livecheck do
2445    url :stable
2446    regex(/^(?:jq[._-])?v?(\d+(?:\.\d+)+)$/i)
2447  end
2448
2449  bottle do
2450    sha256 cellar: :any, arm64_tahoe:   "f6ec821177b4c55fefb4c58f8e6f5d8386a5fce0a3c5d8811c4d6a0cc818dead"
2451    sha256 cellar: :any, arm64_sequoia: "29c079abc7e165583b7fc4d3feba7a21a12160a414a7d4202731f7ffa2e349de"
2452    sha256 cellar: :any, arm64_sonoma:  "c5710de7fe77793fda8d4c550e5450b9fd77f9caf4a90c01be3a0969a8c078e8"
2453    sha256 cellar: :any, sonoma:        "0a471afde49b03dd7a8a1100c2581dc35ae49b3877a9c37fa219aeccdb998154"
2454    sha256 cellar: :any, arm64_linux:   "30137e8a68ffb92bc8e16494f1509f0c08b404cae8a0842e15b63f46e4baf028"
2455    sha256 cellar: :any, x86_64_linux:  "c3e26f0bff91db3e6606c3fc23ebe5dc2b1e9854252e6e4a3e03b17f7a3cde4e"
2456  end
2457
2458  head do
2459    url "https://github.com/jqlang/jq.git", branch: "master"
2460
2461    depends_on "autoconf" => :build
2462    depends_on "automake" => :build
2463    depends_on "libtool" => :build
2464  end
2465
2466  depends_on "oniguruma"
2467
2468  def install
2469    system "autoreconf", "--force", "--install", "--verbose" if build.head?
2470    system "./configure", *std_configure_args,
2471                          "--disable-silent-rules",
2472                          "--disable-maintainer-mode"
2473    system "make", "install"
2474  end
2475
2476  test do
2477    assert_equal "2\n", pipe_output("#{bin}/jq .bar", '{"foo":1, "bar":2}')
2478  end
2479end
2480"##;
2481
2482    const GIT_RB: &str = r##"class Git < Formula
2483  desc "Distributed revision control system"
2484  homepage "https://git-scm.com"
2485  url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.55.0.tar.xz"
2486  sha256 "457fdb04dc8728e007d4688695e6912e6f680727920f2a40bf11eacc17505357"
2487  license all_of: [
2488    "GPL-2.0-only",
2489    "GPL-2.0-or-later",  # imap-send.c; trace.c; ...
2490    "LGPL-2.1-or-later", # xdiff/
2491    "BSD-3-Clause",      # xdiff/xhistogram.c; reftable/
2492    "MIT",               # khash.h; sha1dc/
2493  ]
2494  compatibility_version 1
2495  head "https://github.com/git/git.git", branch: "master"
2496
2497  livecheck do
2498    url "https://mirrors.edge.kernel.org/pub/software/scm/git/"
2499    regex(/href=.*?git[._-]v?(\d+(?:\.\d+)+)\.t/i)
2500  end
2501
2502  bottle do
2503    sha256 arm64_tahoe:   "8cae58826d317457128023d07c07f00ac00aaf3019356ff72a837e804f5d0306"
2504    sha256 arm64_sequoia: "5df08160c5e705ffcce25edfe1bb189d50fd3366a0e451fc254a8d70904adc5f"
2505    sha256 arm64_sonoma:  "3d7a66500cc7bf1530d7a5e862f4b4c2e317ed53c95bf76c012ce8ff7ec92ae7"
2506    sha256 sonoma:        "f7477c65f53669d1f9fd90ad2d633e6c009d5f6b0e10e039c15ebc022ac18bde"
2507    sha256 arm64_linux:   "ea73cb7a909a7c80c2c5474b15b53659dd04b1222efd678e9a693bb06a7ed314"
2508    sha256 x86_64_linux:  "01365c33768a2cb170a34f8f201ea3f3a7bb8c0d2140640ff3fbc58584e9758e"
2509  end
2510
2511  depends_on "gettext" => :build
2512  depends_on "pkgconf" => :build
2513  depends_on "pcre2"
2514
2515  uses_from_macos "curl"
2516  uses_from_macos "expat"
2517
2518  # Don't try to add a libiconv dependency without reading this PR first:
2519  # https://github.com/Homebrew/homebrew-core/pull/258461
2520
2521  on_macos do
2522    depends_on "gettext"
2523  end
2524
2525  on_linux do
2526    depends_on "openssl@3" # for git-imap-send (GPL-2.0-or-later), uses CommonCrypto on macOS
2527    depends_on "zlib-ng-compat"
2528  end
2529
2530  resource "Authen::SASL" do
2531    url "https://cpan.metacpan.org/authors/id/E/EH/EHUELS/Authen-SASL-2.2000.tar.gz"
2532    sha256 "8cdf5a7f185448b614471675dae5b26f8c6e330b62264c3ff5d91172d6889b99"
2533  end
2534
2535  resource "html" do
2536    url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-htmldocs-2.55.0.tar.xz"
2537    sha256 "d1142c4e28b469d297d6df6519653e92a76c952f55202fde17a72a3b03d49437"
2538
2539    livecheck do
2540      formula :parent
2541    end
2542  end
2543
2544  resource "man" do
2545    url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-manpages-2.55.0.tar.xz"
2546    sha256 "a32d432f80df46a14a05d1104c72d5a13fe27e9feba9aa0f017e54131db6b982"
2547
2548    livecheck do
2549      formula :parent
2550    end
2551  end
2552
2553  resource "Net::SMTP::SSL" do
2554    url "https://cpan.metacpan.org/authors/id/R/RJ/RJBS/Net-SMTP-SSL-1.04.tar.gz"
2555    sha256 "7b29c45add19d3d5084b751f7ba89a8e40479a446ce21cfd9cc741e558332a00"
2556  end
2557
2558  deny_network_access! [:build, :postinstall]
2559
2560  def install
2561    odie "html resource needs to be updated" if build.stable? && version != resource("html").version
2562    odie "man resource needs to be updated" if build.stable? && version != resource("man").version
2563
2564    # If these things are installed, tell Git build system not to use them
2565    ENV["NO_FINK"] = "1"
2566    ENV["NO_DARWIN_PORTS"] = "1"
2567    ENV["PYTHON_PATH"] = which("python3")
2568    ENV["PERL_PATH"] = which("perl")
2569    ENV["USE_LIBPCRE2"] = "1"
2570    ENV["INSTALL_SYMLINKS"] = "1"
2571    ENV["LIBPCREDIR"] = formula_opt_prefix("pcre2")
2572    ENV["V"] = "1" # build verbosely
2573
2574    perl_version = Utils.safe_popen_read("perl", "--version")[/v(\d+\.\d+)(?:\.\d+)?/, 1]
2575
2576    if OS.mac?
2577      ENV["PERLLIB_EXTRA"] = %W[
2578        #{MacOS.active_developer_dir}
2579        /Library/Developer/CommandLineTools
2580        /Applications/Xcode.app/Contents/Developer
2581      ].uniq.map do |p|
2582        "#{p}/Library/Perl/#{perl_version}/darwin-thread-multi-2level"
2583      end.join(":")
2584    end
2585
2586    # The git-gui and gitk tools are installed by a separate formula (git-gui)
2587    # to avoid a dependency on tcl-tk and to avoid using the broken system
2588    # tcl-tk (see https://github.com/Homebrew/homebrew-core/issues/36390)
2589    # This is done by setting the NO_TCLTK make variable.
2590    args = %W[
2591      prefix=#{prefix}
2592      sysconfdir=#{etc}
2593      CC=#{ENV.cc}
2594      CFLAGS=#{ENV.cflags}
2595      LDFLAGS=#{ENV.ldflags}
2596      NO_TCLTK=1
2597      NO_RUST=1
2598    ]
2599
2600    args += if OS.mac?
2601      %w[NO_OPENSSL=1 APPLE_COMMON_CRYPTO=1]
2602    else
2603      openssl_prefix = formula_opt_prefix("openssl@3")
2604
2605      %W[NO_APPLE_COMMON_CRYPTO=1 OPENSSLDIR=#{openssl_prefix}]
2606    end
2607
2608    # Make sure `git` looks in `opt_prefix` instead of the Cellar.
2609    # Otherwise, Cellar references propagate to generated plists from `git maintenance`.
2610    inreplace "Makefile", /(-DFALLBACK_RUNTIME_PREFIX=")[^"]+/, "\\1#{opt_prefix}"
2611
2612    system "make", "install", *args
2613
2614    git_core = libexec/"git-core"
2615    rm git_core/"git-svn"
2616
2617    # Install the macOS keychain credential helper
2618    if OS.mac?
2619      cd "contrib/credential/osxkeychain" do
2620        system "make", *args
2621        git_core.install "git-credential-osxkeychain"
2622        system "make", "clean"
2623      end
2624    end
2625
2626    # Generate diff-highlight perl script executable
2627    cd "contrib/diff-highlight" do
2628      system "make"
2629    end
2630
2631    # Install the netrc credential helper
2632    cd "contrib/credential/netrc" do
2633      system "make", "test"
2634      git_core.install "git-credential-netrc"
2635    end
2636
2637    # Install git-subtree
2638    cd "contrib/subtree" do
2639      system "make", "CC=#{ENV.cc}",
2640                     "CFLAGS=#{ENV.cflags}",
2641                     "LDFLAGS=#{ENV.ldflags}"
2642      git_core.install "git-subtree"
2643    end
2644
2645    # Install git-jump
2646    cd "contrib/git-jump" do
2647      git_core.install "git-jump"
2648    end
2649
2650    # install the completion script first because it is inside "contrib"
2651    bash_completion.install "contrib/completion/git-completion.bash"
2652    bash_completion.install "contrib/completion/git-prompt.sh"
2653    zsh_completion.install "contrib/completion/git-completion.zsh" => "_git"
2654    cp "#{bash_completion}/git-completion.bash", zsh_completion
2655    cp "#{bash_completion}/git-prompt.sh", zsh_completion
2656
2657    (share/"git-core").install "contrib"
2658
2659    # We could build the manpages ourselves, but the build process depends
2660    # on many other packages, and is somewhat crazy, this way is easier.
2661    man.install resource("man")
2662    (share/"doc/git-doc").install resource("html")
2663
2664    # Make html docs world-readable
2665    chmod 0644, Dir["#{share}/doc/git-doc/**/*.{html,txt}"]
2666    chmod 0755, Dir["#{share}/doc/git-doc/{RelNotes,howto,technical}"]
2667
2668    # git-send-email needs Net::SMTP::SSL or Net::SMTP >= 2.34 and Authen::SASL
2669    resource("Net::SMTP::SSL").stage do
2670      (share/"perl5").install "lib/Net"
2671    end
2672
2673    resource("Authen::SASL").stage do
2674      (share/"perl5").install "lib/Authen"
2675    end
2676
2677    # This is only created when building against system Perl, but it isn't
2678    # purged by Homebrew's post-install cleaner because that doesn't check
2679    # "Library" directories. It is however pointless to keep around as it
2680    # only contains the perllocal.pod installation file.
2681    perl_dir = prefix/"Library/Perl"
2682    rm_r perl_dir if perl_dir.exist?
2683
2684    # Set the macOS keychain credential helper by default
2685    # (as Apple's CLT's git also does this).
2686    if OS.mac?
2687      (buildpath/"gitconfig").write <<~EOS
2688        [credential]
2689        \thelper = osxkeychain
2690      EOS
2691      etc.install "gitconfig"
2692    end
2693  end
2694
2695  def caveats
2696    <<~EOS
2697      The Tcl/Tk GUIs (e.g. gitk, git-gui) are now in the `git-gui` formula.
2698      Subversion interoperability (git-svn) is now in the `git-svn` formula.
2699    EOS
2700  end
2701
2702  test do
2703    system bin/"git", "init"
2704    %w[haunted house].each { |f| touch testpath/f }
2705    system bin/"git", "add", "haunted", "house"
2706    system bin/"git", "config", "user.name", "'A U Thor'"
2707    system bin/"git", "config", "user.email", "author@example.com"
2708    system bin/"git", "commit", "-a", "-m", "Initial Commit"
2709    assert_equal "haunted\nhouse", shell_output("#{bin}/git ls-files").strip
2710
2711    # Check that our `inreplace` for the `Makefile` does not break.
2712    # If this assertion fails, please fix the `inreplace` instead of removing this test.
2713    # The failure of this test means that `git` will generate broken launchctl plist files.
2714    refute_match HOMEBREW_CELLAR.to_s, shell_output("#{bin}/git --exec-path")
2715
2716    return unless OS.mac?
2717
2718    # Check Net::SMTP or Net::SMTP::SSL works for git-send-email
2719    %w[foo bar].each { |f| touch testpath/f }
2720    system bin/"git", "add", "foo", "bar"
2721    system bin/"git", "commit", "-a", "-m", "Second Commit"
2722    assert_match "Authentication Required", pipe_output(
2723      "#{bin}/git send-email --from=test@example.com --to=dev@null.com " \
2724      "--smtp-server=smtp.gmail.com --smtp-server-port=587 " \
2725      "--smtp-encryption=tls --confirm=never HEAD^ 2>&1",
2726    )
2727  end
2728end
2729"##;
2730}