use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::error::{Result, ToolchainError};
use zlayer_types::package_index::FormulaData;
const RAW_BASE: &str = "https://raw.githubusercontent.com/Homebrew/homebrew-core/HEAD";
const CDN_BASE: &str = "https://cdn.jsdelivr.net/gh/Homebrew/homebrew-core@HEAD";
pub async fn fetch_recipe(formula: &FormulaData) -> Result<String> {
let Some(path) = formula
.ruby_source_path
.as_deref()
.filter(|p| !p.is_empty())
else {
return Err(ToolchainError::RegistryError {
message: "formula JSON has no ruby_source_path; cannot fetch the Homebrew recipe \
(package index entry predates ruby_source_path support?)"
.to_string(),
});
};
let client = reqwest::Client::builder()
.user_agent("zlayer-toolchain")
.build()
.unwrap_or_default();
let primary = format!("{RAW_BASE}/{path}");
match try_get_text(&client, &primary).await {
Ok(text) => Ok(text),
Err(primary_err) => {
debug!(path, error = %primary_err, "raw.githubusercontent fetch failed; trying jsDelivr fallback");
let fallback = format!("{CDN_BASE}/{path}");
try_get_text(&client, &fallback)
.await
.map_err(|fallback_err| ToolchainError::RegistryError {
message: format!(
"failed to fetch recipe {path}: {primary_err}; fallback: {fallback_err}"
),
})
}
}
}
pub async fn fetch_recipe_cached(formula: &FormulaData, cache_path: &Path) -> Result<String> {
if let Ok(cached) = tokio::fs::read_to_string(cache_path).await {
if !cached.is_empty() {
return Ok(cached);
}
}
let text = fetch_recipe(formula).await?;
if let Some(parent) = cache_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(cache_path, &text).await?;
Ok(text)
}
async fn try_get_text(client: &reqwest::Client, url: &str) -> Result<String> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to GET {url}: {e}"),
})?;
if !resp.status().is_success() {
return Err(ToolchainError::RegistryError {
message: format!("GET {url} returned status {}", resp.status()),
});
}
resp.text()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read body from {url}: {e}"),
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecipeCtx {
pub tool: String,
pub version: String,
pub prefix: PathBuf,
pub dep_prefixes: HashMap<String, PathBuf>,
pub target_macos: bool,
pub target_arm: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceSpec {
pub name: String,
pub url: String,
pub sha256: String,
pub stage_to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PatchSpec {
pub url: String,
pub sha256: String,
pub strip: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum InstallStep {
System {
argv: Vec<String>,
},
Configure {
args: Vec<String>,
},
Make {
args: Vec<String>,
},
CMake {
args: Vec<String>,
},
Bootstrap {
args: Vec<String>,
},
Cargo {
args: Vec<String>,
},
Go {
args: Vec<String>,
},
Meson {
args: Vec<String>,
},
BinInstall {
from: String,
to: Option<String>,
},
LibInstall {
from: String,
to: Option<String>,
},
IncludeInstall {
from: String,
to: Option<String>,
},
PrefixInstall {
from: String,
to: Option<String>,
},
EnvSet {
key: String,
value: String,
},
Inreplace {
file: String,
from: String,
to: String,
},
StageResource {
name: String,
dir: Option<String>,
},
ApplyPatch {
index: usize,
},
Unsupported {
construct: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstallPlan {
pub steps: Vec<InstallStep>,
pub resources: Vec<ResourceSpec>,
pub patches: Vec<PatchSpec>,
pub env: HashMap<String, String>,
pub deparallelize: bool,
}
impl InstallPlan {
#[must_use]
pub fn is_fully_supported(&self) -> bool {
!self
.steps
.iter()
.any(|s| matches!(s, InstallStep::Unsupported { .. }))
}
#[must_use]
pub fn unsupported_constructs(&self) -> Vec<&str> {
self.steps
.iter()
.filter_map(|s| match s {
InstallStep::Unsupported { construct } => Some(construct.as_str()),
_ => None,
})
.collect()
}
}
pub fn parse_install_plan(rb: &str, ctx: &RecipeCtx) -> Result<InstallPlan> {
let mut parser = Parser {
ctx,
lines: logical_lines(rb),
pos: 0,
steps: Vec::new(),
resources: Vec::new(),
patches: Vec::new(),
env: HashMap::new(),
deparallelize: false,
saw_install: false,
};
parser.parse_top_level();
if !parser.saw_install {
return Err(ToolchainError::RegistryError {
message: format!(
"no `def install` block found in formula source for {}",
ctx.tool
),
});
}
Ok(parser.finish())
}
fn logical_lines(rb: &str) -> Vec<String> {
let phys: Vec<&str> = rb.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < phys.len() {
let mut line = strip_comment(phys[i]).trim().to_string();
i += 1;
if line.is_empty() {
continue;
}
loop {
if let Some(id) = heredoc_id(&line) {
while i < phys.len() {
let body = phys[i].trim();
i += 1;
if body == id {
break;
}
}
}
if has_unclosed_word_array(&line) && i < phys.len() {
let next = strip_comment(phys[i]).trim().to_string();
i += 1;
line.push(' ');
line.push_str(&next);
continue;
}
if (line.ends_with(',') || line.ends_with('(')) && i < phys.len() {
let next = strip_comment(phys[i]).trim().to_string();
i += 1;
if !next.is_empty() {
line.push(' ');
line.push_str(&next);
}
continue;
}
if line.ends_with('\\') && i < phys.len() {
line.pop();
let next = strip_comment(phys[i]).trim().to_string();
i += 1;
if !next.is_empty() {
line.push(' ');
line.push_str(&next);
}
continue;
}
break;
}
out.push(line);
}
out
}
fn strip_comment(raw: &str) -> String {
let mut out = String::new();
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if escaped {
out.push(c);
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => {
out.push(c);
escaped = true;
}
'"' if !in_single => {
in_double = !in_double;
out.push(c);
}
'\'' if !in_double => {
in_single = !in_single;
out.push(c);
}
'#' if !in_double && !in_single => {
if chars.peek() == Some(&'{') {
out.push(c);
} else {
break;
}
}
_ => out.push(c),
}
}
out
}
fn heredoc_id(line: &str) -> Option<String> {
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => escaped = true,
'"' if !in_single => in_double = !in_double,
'\'' if !in_double => in_single = !in_single,
'<' if !in_double && !in_single && bytes.get(i + 1) == Some(&b'<') => {
let mut j = i + 2;
while matches!(bytes.get(j), Some(b'~' | b'-')) {
j += 1;
}
let start = j;
while matches!(bytes.get(j), Some(b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
j += 1;
}
if j > start && bytes[start].is_ascii_uppercase() {
return Some(line[start..j].to_string());
}
}
_ => {}
}
}
None
}
fn has_unclosed_word_array(line: &str) -> bool {
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => escaped = true,
'"' if !in_single => in_double = !in_double,
'\'' if !in_double => in_single = !in_single,
'%' if !in_double
&& !in_single
&& matches!(bytes.get(i + 1), Some(b'w' | b'W'))
&& bytes.get(i + 2) == Some(&b'[') =>
{
return !line[i + 3..].contains(']');
}
_ => {}
}
}
false
}
fn starts_with_word(line: &str, w: &str) -> bool {
line == w || (line.starts_with(w) && line[w.len()..].starts_with(' '))
}
fn closes_block(line: &str) -> bool {
line == "end"
|| line.starts_with("end ")
|| line.starts_with("end.")
|| line.starts_with("end,")
}
fn ends_with_do(line: &str) -> bool {
let t = line.trim_end();
let t = if let Some(without_bar) = t.strip_suffix('|') {
match without_bar.rfind('|') {
Some(p) => t[..p].trim_end(),
None => return false,
}
} else {
t
};
t == "do" || t.ends_with(" do")
}
fn find_trailing_conditional(line: &str) -> Option<(usize, bool)> {
let mut best = None;
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let mut depth = 0i32;
for (i, c) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => escaped = true,
'"' if !in_single => in_double = !in_double,
'\'' if !in_double => in_single = !in_single,
'(' | '[' | '{' if !in_double && !in_single => depth += 1,
')' | ']' | '}' if !in_double && !in_single => depth -= 1,
' ' if !in_double && !in_single && depth == 0 => {
let rest = &line[i..];
if rest.starts_with(" if ") {
best = Some((i, false));
} else if rest.starts_with(" unless ") {
best = Some((i, true));
}
}
_ => {}
}
}
best
}
fn is_expression_if(line: &str) -> bool {
if let Some((pos, _)) = find_trailing_conditional(line) {
matches!(
line[..pos].trim_end().chars().last(),
Some('=' | '(' | ',' | '{')
)
} else {
false
}
}
struct Modifier<'a> {
head: &'a str,
cond: &'a str,
negated: bool,
}
fn analyze_modifier(line: &str) -> Option<Modifier<'_>> {
if starts_with_word(line, "if") || starts_with_word(line, "unless") {
return None;
}
let (pos, negated) = find_trailing_conditional(line)?;
let head = line[..pos].trim_end();
if matches!(head.chars().last(), Some('=' | '(' | ',' | '{')) {
return None; }
let kw_len = if negated {
" unless ".len()
} else {
" if ".len()
};
Some(Modifier {
head,
cond: line[pos + kw_len..].trim(),
negated,
})
}
fn opens_block(line: &str) -> bool {
const OPENER_WORDS: [&str; 9] = [
"if", "unless", "def", "class", "module", "case", "while", "until", "begin",
];
if OPENER_WORDS.iter().any(|w| starts_with_word(line, w)) {
return true;
}
ends_with_do(line) || is_expression_if(line)
}
fn split_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let mut depth = 0i32;
for c in s.chars() {
if escaped {
cur.push(c);
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => {
cur.push(c);
escaped = true;
}
'"' if !in_single => {
in_double = !in_double;
cur.push(c);
}
'\'' if !in_double => {
in_single = !in_single;
cur.push(c);
}
'(' | '[' | '{' if !in_double && !in_single => {
depth += 1;
cur.push(c);
}
')' | ']' | '}' if !in_double && !in_single => {
depth -= 1;
cur.push(c);
}
',' if !in_double && !in_single && depth == 0 => {
out.push(cur.trim().to_string());
cur.clear();
}
_ => cur.push(c),
}
}
if !cur.trim().is_empty() {
out.push(cur.trim().to_string());
}
out
}
fn split_rename(token: &str) -> (String, Option<String>) {
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
for (i, c) in token.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => escaped = true,
'"' if !in_single => in_double = !in_double,
'\'' if !in_double => in_single = !in_single,
' ' if !in_double && !in_single && token[i..].starts_with(" => ") => {
return (
token[..i].trim().to_string(),
Some(token[i + 4..].trim().to_string()),
);
}
_ => {}
}
}
(token.to_string(), None)
}
fn quoted_literal(s: &str) -> Option<String> {
let s = s.trim();
for q in ['"', '\''] {
if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) && !s[1..s.len() - 1].contains(q) {
return Some(s[1..s.len() - 1].to_string());
}
}
None
}
fn string_call_arg(line: &str, kw: &str) -> Option<String> {
let rest = line.strip_prefix(kw)?;
let rest = rest.strip_prefix(' ').or_else(|| rest.strip_prefix('('))?;
let toks = split_args(rest.trim_end_matches(')'));
quoted_literal(toks.first()?)
}
struct Parser<'a> {
ctx: &'a RecipeCtx,
lines: Vec<String>,
pos: usize,
steps: Vec<InstallStep>,
resources: Vec<ResourceSpec>,
patches: Vec<PatchSpec>,
env: HashMap<String, String>,
deparallelize: bool,
saw_install: bool,
}
enum BranchEnd {
End,
Else,
Elsif(String),
}
impl Parser<'_> {
fn take(&mut self) -> String {
let line = self.lines[self.pos].clone();
self.pos += 1;
line
}
fn unsupported(&mut self, construct: &str) {
self.steps.push(InstallStep::Unsupported {
construct: construct.to_string(),
});
}
fn unsupported_line(&mut self, line: &str) {
self.unsupported(line);
if opens_block(line) {
self.skip_block();
}
}
fn skip_block(&mut self) {
let mut depth = 1usize;
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
depth -= 1;
if depth == 0 {
return;
}
} else if opens_block(&line) {
depth += 1;
}
}
}
fn skip_block_counting(&mut self) -> usize {
let mut depth = 1usize;
let mut count = 0usize;
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
depth -= 1;
if depth == 0 {
return count;
}
} else if opens_block(&line) {
depth += 1;
}
count += 1;
}
count
}
fn parse_top_level(&mut self) {
while self.pos < self.lines.len() {
let line = self.take();
if starts_with_word(&line, "class") && line.contains("< Formula") {
self.class_region();
return;
}
}
}
fn class_region(&mut self) {
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
return;
}
self.class_line(&line);
}
}
fn class_line(&mut self, line: &str) {
if let Some(name) = parse_resource_open(line) {
self.resource_block(name, line);
return;
}
if starts_with_word(line, "patch") {
self.patch_line(line);
return;
}
if let Some(keep) = self.on_block_keep(line) {
if keep {
self.class_region();
} else {
self.skip_block();
}
return;
}
if line == "def install" {
self.saw_install = true;
self.install_region();
return;
}
if opens_block(line) {
self.skip_block();
}
}
fn patch_line(&mut self, line: &str) {
if line == "patch :DATA" {
self.unsupported("patch :DATA");
return;
}
let strip = match line {
"patch do" => Some(1),
_ => line
.strip_prefix("patch :p")
.and_then(|r| r.strip_suffix(" do"))
.and_then(|n| n.parse::<u32>().ok()),
};
match strip {
Some(strip) => {
let (url, sha256) = self.url_sha_block();
match (url, sha256) {
(Some(url), Some(sha256)) => {
self.patches.push(PatchSpec { url, sha256, strip });
}
_ => self.unsupported(line),
}
}
None => self.unsupported_line(line),
}
}
fn resource_block(&mut self, name: String, open_line: &str) {
let (url, sha256) = self.url_sha_block();
match (url, sha256) {
(Some(url), Some(sha256)) => self.resources.push(ResourceSpec {
name,
url,
sha256,
stage_to: None,
}),
_ => self.unsupported(open_line),
}
}
fn url_sha_block(&mut self) -> (Option<String>, Option<String>) {
let mut depth = 1usize;
let mut url = None;
let mut sha = None;
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
depth -= 1;
if depth == 0 {
break;
}
continue;
}
if opens_block(&line) {
depth += 1;
continue;
}
if depth == 1 {
if url.is_none() {
if let Some(u) = string_call_arg(&line, "url") {
url = Some(u);
continue;
}
}
if sha.is_none() {
if let Some(s) = string_call_arg(&line, "sha256") {
sha = Some(s);
}
}
}
}
(url, sha)
}
fn on_block_keep(&self, line: &str) -> Option<bool> {
match line {
"on_macos do" => Some(self.ctx.target_macos),
"on_linux do" => Some(!self.ctx.target_macos),
"on_arm do" => Some(self.ctx.target_arm),
"on_intel do" => Some(!self.ctx.target_arm),
_ => None,
}
}
fn fold_condition(&self, cond: &str) -> Option<bool> {
let cond = cond.trim();
let (cond, negated) = cond
.strip_prefix('!')
.map_or((cond, false), |rest| (rest.trim_start(), true));
let value = match cond {
"OS.mac?" => self.ctx.target_macos,
"OS.linux?" => !self.ctx.target_macos,
"Hardware::CPU.arm?" => self.ctx.target_arm,
"Hardware::CPU.intel?" => !self.ctx.target_arm,
"build.head?" => false,
"build.stable?" => true,
_ => return None,
};
Some(value != negated)
}
fn install_region(&mut self) {
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
return;
}
self.install_line(&line);
}
}
fn install_line(&mut self, line: &str) {
if let Some(cond) = line.strip_prefix("if ") {
self.if_construct(line, cond, false);
return;
}
if let Some(cond) = line.strip_prefix("unless ") {
self.if_construct(line, cond, true);
return;
}
if let Some(keep) = self.on_block_keep(line) {
if keep {
self.install_region();
} else {
self.skip_block();
}
return;
}
if let Some(m) = analyze_modifier(line) {
match self.fold_condition(m.cond).map(|v| v != m.negated) {
Some(true) => {
let head = m.head.to_string();
self.install_line(&head);
}
Some(false) => {}
None => self.unsupported_line(line),
}
return;
}
if self.env_line(line)
|| self.system_line(line)
|| self.inreplace_line(line)
|| self.receiver_install_line(line)
|| self.resource_stage_line(line)
{
return;
}
self.unsupported_line(line);
}
fn if_construct(&mut self, line: &str, cond: &str, negated: bool) {
if let Some(keep_then) = self.fold_condition(cond).map(|v| v != negated) {
self.if_branches(keep_then);
} else {
self.unsupported(line);
self.skip_block();
}
}
fn if_branches(&mut self, keep_then: bool) {
match self.branch_region(keep_then) {
BranchEnd::End => {}
BranchEnd::Else => {
let _ = self.branch_region(!keep_then);
}
BranchEnd::Elsif(elsif_line) => {
if keep_then {
self.skip_block();
} else {
let cond = elsif_line.trim_start_matches("elsif").trim().to_string();
if let Some(keep) = self.fold_condition(&cond) {
self.if_branches(keep);
} else {
self.unsupported(&elsif_line);
self.skip_block();
}
}
}
}
}
fn branch_region(&mut self, execute: bool) -> BranchEnd {
while self.pos < self.lines.len() {
let line = self.take();
if closes_block(&line) {
return BranchEnd::End;
}
if line == "else" {
return BranchEnd::Else;
}
if starts_with_word(&line, "elsif") {
return BranchEnd::Elsif(line);
}
if execute {
self.install_line(&line);
} else if opens_block(&line) {
self.skip_block();
}
}
BranchEnd::End
}
fn env_line(&mut self, line: &str) -> bool {
if line == "ENV.deparallelize" {
self.deparallelize = true;
return true;
}
if let Some(rest) = line.strip_prefix("ENV[") {
let Some((key_part, after)) = rest.split_once(']') else {
self.unsupported_line(line);
return true;
};
let (Some(key), Some(expr)) = (
quoted_literal(key_part),
after.trim_start().strip_prefix('=').map(str::trim),
) else {
self.unsupported_line(line);
return true;
};
if expr.starts_with('=') {
self.unsupported_line(line);
return true;
}
match self.resolve_value(expr) {
Some(value) => self.set_env(key, value),
None => self.unsupported_line(line),
}
return true;
}
for (kw, prepend) in [("ENV.append ", false), ("ENV.prepend ", true)] {
if let Some(rest) = line.strip_prefix(kw) {
self.env_merge(line, rest, prepend);
return true;
}
}
false
}
fn env_merge(&mut self, line: &str, rest: &str, prepend: bool) {
let toks = split_args(rest);
let (Some(key), Some(value)) = (
toks.first().and_then(|t| quoted_literal(t)),
toks.get(1).and_then(|t| self.resolve_value(t)),
) else {
self.unsupported_line(line);
return;
};
if toks.len() != 2 {
self.unsupported_line(line);
return;
}
let merged = match self.env.get(&key) {
Some(existing) if prepend => format!("{value} {existing}"),
Some(existing) => format!("{existing} {value}"),
None => value,
};
self.set_env(key, merged);
}
fn set_env(&mut self, key: String, value: String) {
self.steps.push(InstallStep::EnvSet {
key: key.clone(),
value: value.clone(),
});
self.env.insert(key, value);
}
fn system_line(&mut self, line: &str) -> bool {
let rest = if let Some(r) = line.strip_prefix("system ") {
r
} else if let Some(r) = line
.strip_prefix("system(")
.and_then(|r| r.strip_suffix(')'))
{
r
} else {
return false;
};
let mut argv = Vec::new();
for token in split_args(rest) {
if let Some(mut vals) = self.resolve_arg(&token) {
argv.append(&mut vals);
} else {
self.unsupported_line(line);
return true;
}
}
if argv.is_empty() {
self.unsupported_line(line);
return true;
}
self.steps.push(classify_system(argv));
true
}
fn resolve_arg(&self, token: &str) -> Option<Vec<String>> {
if let Some(splat) = token.strip_prefix('*') {
return self.resolve_splat(splat);
}
self.resolve_value(token).map(|v| vec![v])
}
fn resolve_splat(&self, splat: &str) -> Option<Vec<String>> {
let prefix = self.ctx.prefix.display().to_string();
match splat {
"std_configure_args" => Some(vec![
format!("--prefix={prefix}"),
format!("--libdir={prefix}/lib"),
"--disable-debug".to_string(),
"--disable-dependency-tracking".to_string(),
"--disable-silent-rules".to_string(),
]),
"std_cmake_args" => Some(vec![
format!("-DCMAKE_INSTALL_PREFIX={prefix}"),
"-DCMAKE_BUILD_TYPE=Release".to_string(),
"-DCMAKE_FIND_FRAMEWORK=LAST".to_string(),
"-DBUILD_TESTING=OFF".to_string(),
]),
"std_cargo_args" => Some(vec![
"--locked".to_string(),
"--root".to_string(),
prefix,
"--path".to_string(),
".".to_string(),
]),
"std_go_args" => self.std_go_args(None),
_ => splat
.strip_prefix("std_go_args(")
.and_then(|r| r.strip_suffix(')'))
.and_then(|kwargs| self.std_go_args(Some(kwargs))),
}
}
fn std_go_args(&self, kwargs: Option<&str>) -> Option<Vec<String>> {
let mut output = format!("{}/bin/{}", self.ctx.prefix.display(), self.ctx.tool);
let mut ldflags = None;
let mut gcflags = None;
let mut tags = None;
if let Some(kwargs) = kwargs {
for part in split_args(kwargs) {
let (key, value) = part.split_once(':')?;
let value = self.resolve_value(value.trim())?;
match key.trim() {
"output" => output = value,
"ldflags" => ldflags = Some(value),
"gcflags" => gcflags = Some(value),
"tags" => tags = Some(value),
_ => return None,
}
}
}
let mut args = vec!["-trimpath".to_string(), format!("-o={output}")];
if let Some(tags) = tags {
args.push(format!("-tags={tags}"));
}
if let Some(ldflags) = ldflags {
args.push(format!("-ldflags={ldflags}"));
}
if let Some(gcflags) = gcflags {
args.push(format!("-gcflags={gcflags}"));
}
Some(args)
}
fn inreplace_line(&mut self, line: &str) -> bool {
let Some(rest) = line.strip_prefix("inreplace ") else {
return false;
};
if ends_with_do(line) {
self.unsupported(line);
self.skip_block();
return true;
}
let toks = split_args(rest);
if toks.len() == 3
&& toks
.iter()
.all(|t| t.starts_with('"') || t.starts_with('\''))
{
if let (Some(file), Some(from), Some(to)) = (
self.resolve_value(&toks[0]),
self.resolve_value(&toks[1]),
self.resolve_value(&toks[2]),
) {
self.steps.push(InstallStep::Inreplace { file, from, to });
return true;
}
}
self.unsupported(line);
true
}
fn receiver_install_line(&mut self, line: &str) -> bool {
const RECEIVERS: [&str; 8] = [
"bin", "lib", "include", "libexec", "share", "man1", "pkgshare", "prefix",
];
let Some((recv, rest)) = RECEIVERS.iter().find_map(|r| {
line.strip_prefix(&format!("{r}.install "))
.map(|rest| (*r, rest))
}) else {
return false;
};
let mut staged = Vec::new();
for token in split_args(rest) {
let (from_expr, rename_expr) = split_rename(&token);
let Some(from) = self.resolve_value(&from_expr) else {
self.unsupported(line);
return true;
};
let rename = match rename_expr {
None => None,
Some(expr) => {
if let Some(v) = self.resolve_value(&expr) {
Some(v)
} else {
self.unsupported(line);
return true;
}
}
};
staged.push(make_install_step(recv, &self.ctx.tool, from, rename));
}
if staged.is_empty() {
self.unsupported(line);
} else {
self.steps.extend(staged);
}
true
}
fn resource_stage_line(&mut self, line: &str) -> bool {
let Some(rest) = line.strip_prefix("resource(") else {
return false;
};
let Some((name_part, tail)) = rest.split_once(')') else {
self.unsupported_line(line);
return true;
};
let Some(name) = quoted_literal(name_part) else {
self.unsupported_line(line);
return true;
};
if tail == ".stage" {
self.steps
.push(InstallStep::StageResource { name, dir: None });
} else if tail == ".stage do" || (tail.starts_with(".stage do |") && tail.ends_with('|')) {
if self.skip_block_counting() == 0 {
self.steps
.push(InstallStep::StageResource { name, dir: None });
} else {
self.unsupported(line);
}
} else if let Some(dir_expr) = tail
.strip_prefix(".stage(")
.and_then(|t| t.strip_suffix(')'))
{
match self.resolve_value(dir_expr) {
Some(dir) => self.steps.push(InstallStep::StageResource {
name,
dir: normalize_stage_dir(&dir),
}),
None => self.unsupported(line),
}
} else {
self.unsupported_line(line);
}
true
}
fn resolve_value(&self, expr: &str) -> Option<String> {
let expr = expr.trim();
if let Some(body) = full_quoted_body(expr, '"') {
return self.resolve_dquote(body);
}
if let Some(body) = full_quoted_body(expr, '\'') {
return Some(unescape_single(body));
}
self.resolve_expr(expr)
}
fn resolve_dquote(&self, body: &str) -> Option<String> {
let mut out = String::new();
let bytes = body.as_bytes();
let mut i = 0;
while i < body.len() {
let c = body[i..].chars().next()?;
if c == '\\' {
let next = body[i + 1..].chars().next()?;
out.push(match next {
'n' => '\n',
't' => '\t',
's' => ' ',
'0' => '\0',
'e' => '\u{1b}',
other => other,
});
i += 1 + next.len_utf8();
continue;
}
if c == '#' && bytes.get(i + 1) == Some(&b'{') {
let close = body[i + 2..].find('}')? + i + 2;
out.push_str(&self.resolve_interp(&body[i + 2..close])?);
i = close + 1;
continue;
}
out.push(c);
i += c.len_utf8();
}
Some(out)
}
fn resolve_interp(&self, inner: &str) -> Option<String> {
self.resolve_value(inner)
}
fn resolve_expr(&self, expr: &str) -> Option<String> {
if let Some(v) = self.resolve_atom(expr) {
return Some(v);
}
let segs = split_top_level_slash(expr);
if segs.len() >= 2 {
let mut path = self.resolve_atom(segs[0].trim())?;
for seg in &segs[1..] {
let part = self.resolve_value(seg.trim())?;
let base = path.trim_end_matches('/').to_string();
path = format!("{base}/{part}");
}
return Some(path);
}
None
}
fn resolve_atom(&self, expr: &str) -> Option<String> {
let prefix = self.ctx.prefix.display().to_string();
let subdir = |d: &str| Some(format!("{prefix}/{d}"));
match expr {
"prefix" | "opt_prefix" => Some(prefix),
"bin" | "opt_bin" => subdir("bin"),
"lib" | "opt_lib" => subdir("lib"),
"include" | "opt_include" => subdir("include"),
"libexec" | "opt_libexec" => subdir("libexec"),
"share" | "opt_share" => subdir("share"),
"etc" => subdir("etc"),
"var" => subdir("var"),
"pkgshare" => subdir(&format!("share/{}", self.ctx.tool)),
"man" => subdir("share/man"),
"buildpath" => Some(".".to_string()),
"version" => Some(self.ctx.version.clone()),
"name" => Some(self.ctx.tool.clone()),
"ENV.cc" => Some("cc".to_string()),
"ENV.cxx" => Some("c++".to_string()),
_ => {
if let Some(rest) = expr.strip_prefix("man") {
if rest.len() == 1 && rest.chars().all(|c| c.is_ascii_digit()) {
return subdir(&format!("share/man/man{rest}"));
}
}
self.resolve_formula_accessor(expr)
}
}
}
fn resolve_formula_accessor(&self, expr: &str) -> Option<String> {
if let Some(rest) = expr.strip_prefix("Formula[") {
let (name_part, attr_part) = rest.split_once(']')?;
let dep = quoted_literal(name_part)?;
let attr = attr_part.strip_prefix('.')?;
let base = self.ctx.dep_prefixes.get(&dep)?.display().to_string();
return match attr {
"opt_prefix" | "prefix" => Some(base),
"opt_bin" | "bin" => Some(format!("{base}/bin")),
"opt_lib" | "lib" => Some(format!("{base}/lib")),
"opt_include" | "include" => Some(format!("{base}/include")),
"opt_libexec" | "libexec" => Some(format!("{base}/libexec")),
"opt_share" | "share" => Some(format!("{base}/share")),
_ => None,
};
}
let dep = expr
.strip_prefix("formula_opt_prefix(")
.and_then(|r| r.strip_suffix(')'))
.and_then(quoted_literal)?;
self.ctx
.dep_prefixes
.get(&dep)
.map(|p| p.display().to_string())
}
fn finish(mut self) -> InstallPlan {
let stages: Vec<(String, Option<String>)> = self
.steps
.iter()
.filter_map(|s| match s {
InstallStep::StageResource { name, dir } => Some((name.clone(), dir.clone())),
_ => None,
})
.collect();
for (name, dir) in stages {
if let Some(resource) = self.resources.iter_mut().find(|r| r.name == name) {
resource.stage_to = dir;
}
}
let mut steps: Vec<InstallStep> = (0..self.patches.len())
.map(|index| InstallStep::ApplyPatch { index })
.collect();
steps.append(&mut self.steps);
InstallPlan {
steps,
resources: self.resources,
patches: self.patches,
env: self.env,
deparallelize: self.deparallelize,
}
}
}
fn parse_resource_open(line: &str) -> Option<String> {
let rest = line.strip_prefix("resource ")?;
let body = rest.strip_suffix(" do")?;
quoted_literal(body)
}
fn classify_system(argv: Vec<String>) -> InstallStep {
let rest = || argv[1..].to_vec();
match argv[0].as_str() {
"./configure" => InstallStep::Configure { args: rest() },
"make" => InstallStep::Make { args: rest() },
"cmake" => InstallStep::CMake { args: rest() },
"./bootstrap" => InstallStep::Bootstrap { args: rest() },
"cargo" => InstallStep::Cargo { args: rest() },
"go" => InstallStep::Go { args: rest() },
"meson" | "ninja" => InstallStep::Meson { args: argv },
_ => InstallStep::System { argv },
}
}
fn make_install_step(recv: &str, tool: &str, from: String, rename: Option<String>) -> InstallStep {
let under = |dir: &str, rename: Option<String>| {
Some(rename.map_or_else(|| dir.to_string(), |r| format!("{dir}/{r}")))
};
match recv {
"bin" => InstallStep::BinInstall { from, to: rename },
"lib" => InstallStep::LibInstall { from, to: rename },
"include" => InstallStep::IncludeInstall { from, to: rename },
"libexec" => InstallStep::PrefixInstall {
from,
to: under("libexec", rename),
},
"share" => InstallStep::PrefixInstall {
from,
to: under("share", rename),
},
"man1" => InstallStep::PrefixInstall {
from,
to: under("share/man/man1", rename),
},
"pkgshare" => InstallStep::PrefixInstall {
from,
to: under(&format!("share/{tool}"), rename),
},
_ => InstallStep::PrefixInstall { from, to: rename },
}
}
fn normalize_stage_dir(dir: &str) -> Option<String> {
let trimmed = dir.strip_prefix("./").unwrap_or(dir);
if trimmed.is_empty() || trimmed == "." {
None
} else {
Some(trimmed.to_string())
}
}
fn full_quoted_body(expr: &str, quote: char) -> Option<&str> {
if expr.len() < 2 || !expr.starts_with(quote) {
return None;
}
let body = &expr[1..];
let bytes = body.as_bytes();
let mut escaped = false;
let mut in_interp = false;
for (i, c) in body.char_indices() {
if escaped {
escaped = false;
continue;
}
if in_interp {
if c == '}' {
in_interp = false;
}
continue;
}
if c == '\\' {
escaped = true;
} else if quote == '"' && c == '#' && bytes.get(i + 1) == Some(&b'{') {
in_interp = true;
} else if c == quote {
return (i == body.len() - 1).then(|| &body[..i]);
}
}
None
}
fn unescape_single(body: &str) -> String {
let mut out = String::new();
let mut chars = body.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some(next @ ('\'' | '\\')) => out.push(next),
Some(next) => {
out.push('\\');
out.push(next);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
fn split_top_level_slash(expr: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
let mut depth = 0i32;
let mut start = 0;
for (i, c) in expr.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_double || in_single => escaped = true,
'"' if !in_single => in_double = !in_double,
'\'' if !in_double => in_single = !in_single,
'(' | '[' | '{' if !in_double && !in_single => depth += 1,
')' | ']' | '}' if !in_double && !in_single => depth -= 1,
'/' if !in_double && !in_single && depth == 0 => {
out.push(&expr[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&expr[start..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(tool: &str, mac: bool, arm: bool) -> RecipeCtx {
RecipeCtx {
tool: tool.to_string(),
version: "1.0.0".to_string(),
prefix: PathBuf::from(format!("/tc/{tool}")),
dep_prefixes: HashMap::new(),
target_macos: mac,
target_arm: arm,
}
}
fn plan(rb: &str, ctx: &RecipeCtx) -> InstallPlan {
parse_install_plan(rb, ctx).expect("plan should parse")
}
#[test]
fn autotools_std_configure_args_exact_plan() {
let rb = r#"
class Foo < Formula
url "https://example.com/foo-1.0.0.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
def install
system "./configure", *std_configure_args
system "make"
system "make", "install"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![
InstallStep::Configure {
args: vec![
"--prefix=/tc/foo".into(),
"--libdir=/tc/foo/lib".into(),
"--disable-debug".into(),
"--disable-dependency-tracking".into(),
"--disable-silent-rules".into(),
]
},
InstallStep::Make { args: vec![] },
InstallStep::Make {
args: vec!["install".into()]
},
]
);
assert!(p.resources.is_empty() && p.patches.is_empty() && p.env.is_empty());
assert!(!p.deparallelize);
}
#[test]
fn make_install_big_args_resolves_prefix() {
let rb = r#"
class Foo < Formula
def install
system "make", "install", "prefix=#{prefix}", "sysconfdir=#{etc}", "CC=#{ENV.cc}", "CXX=#{ENV.cxx}"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![InstallStep::Make {
args: vec![
"install".into(),
"prefix=/tc/foo".into(),
"sysconfdir=/tc/foo/etc".into(),
"CC=cc".into(),
"CXX=c++".into(),
]
}]
);
}
#[test]
fn cargo_std_args_expand() {
let rb = r#"
class Foo < Formula
def install
system "cargo", "install", *std_cargo_args
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert_eq!(
p.steps,
vec![InstallStep::Cargo {
args: vec![
"install".into(),
"--locked".into(),
"--root".into(),
"/tc/foo".into(),
"--path".into(),
".".into(),
]
}]
);
}
#[test]
fn cmake_std_args_expand_per_invocation() {
let rb = r#"
class Foo < Formula
def install
system "cmake", "-S", ".", "-B", "build", *std_cmake_args
system "cmake", "--build", "build"
system "cmake", "--install", "build"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![
InstallStep::CMake {
args: vec![
"-S".into(),
".".into(),
"-B".into(),
"build".into(),
"-DCMAKE_INSTALL_PREFIX=/tc/foo".into(),
"-DCMAKE_BUILD_TYPE=Release".into(),
"-DCMAKE_FIND_FRAMEWORK=LAST".into(),
"-DBUILD_TESTING=OFF".into(),
]
},
InstallStep::CMake {
args: vec!["--build".into(), "build".into()]
},
InstallStep::CMake {
args: vec!["--install".into(), "build".into()]
},
]
);
}
#[test]
fn on_macos_on_linux_blocks_fold() {
let rb = r#"
class Foo < Formula
def install
on_macos do
ENV["MAC"] = "1"
end
on_linux do
ENV["LINUX"] = "1"
end
system "make"
end
end
"#;
let mac = plan(rb, &ctx("foo", true, true));
assert!(mac.is_fully_supported());
assert_eq!(mac.env.get("MAC"), Some(&"1".to_string()));
assert!(!mac.env.contains_key("LINUX"));
let linux = plan(rb, &ctx("foo", false, false));
assert_eq!(linux.env.get("LINUX"), Some(&"1".to_string()));
assert!(!linux.env.contains_key("MAC"));
}
#[test]
fn hardware_cpu_arm_if_else_folds_both_ways() {
let rb = r#"
class Foo < Formula
def install
if Hardware::CPU.arm?
ENV["ARCH"] = "arm64"
else
ENV["ARCH"] = "x86_64"
end
system "make"
end
end
"#;
let arm = plan(rb, &ctx("foo", true, true));
assert!(arm.is_fully_supported());
assert_eq!(arm.env.get("ARCH"), Some(&"arm64".to_string()));
let intel = plan(rb, &ctx("foo", true, false));
assert!(intel.is_fully_supported());
assert_eq!(intel.env.get("ARCH"), Some(&"x86_64".to_string()));
}
#[test]
fn resource_block_and_stage_dir() {
let rb = r#"
class Foo < Formula
resource "vendor" do
url "https://example.com/vendor-2.0.tar.gz"
sha256 "1111111111111111111111111111111111111111111111111111111111111111"
end
def install
resource("vendor").stage(buildpath/"vendor")
system "make"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.resources,
vec![ResourceSpec {
name: "vendor".into(),
url: "https://example.com/vendor-2.0.tar.gz".into(),
sha256: "1111111111111111111111111111111111111111111111111111111111111111".into(),
stage_to: Some("vendor".into()),
}]
);
assert_eq!(
p.steps[0],
InstallStep::StageResource {
name: "vendor".into(),
dir: Some("vendor".into())
}
);
}
#[test]
fn patch_blocks_and_patch_data() {
let rb = r#"
class Foo < Formula
patch do
url "https://example.com/fix.patch"
sha256 "2222222222222222222222222222222222222222222222222222222222222222"
end
patch :p0 do
url "https://example.com/fix0.patch"
sha256 "3333333333333333333333333333333333333333333333333333333333333333"
end
patch :DATA
def install
system "make"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert_eq!(
p.patches,
vec![
PatchSpec {
url: "https://example.com/fix.patch".into(),
sha256: "2222222222222222222222222222222222222222222222222222222222222222"
.into(),
strip: 1
},
PatchSpec {
url: "https://example.com/fix0.patch".into(),
sha256: "3333333333333333333333333333333333333333333333333333333333333333"
.into(),
strip: 0
},
]
);
assert_eq!(p.steps[0], InstallStep::ApplyPatch { index: 0 });
assert_eq!(p.steps[1], InstallStep::ApplyPatch { index: 1 });
assert!(!p.is_fully_supported());
assert_eq!(p.unsupported_constructs(), vec!["patch :DATA"]);
}
#[test]
fn unsupported_constructs_surface_with_text() {
let rb = r##"
class Foo < Formula
def install
%w[a b].each do |f|
bin.install f
end
system "make", *args
ENV["X"] = which("python3")
system "install", "-m", "0755", "#{testpath}/x", bin
cd "sub" do
system "make"
end
end
end
"##;
let p = plan(rb, &ctx("foo", true, true));
assert!(!p.is_fully_supported());
let constructs = p.unsupported_constructs();
assert_eq!(constructs.len(), 5, "{constructs:?}");
assert!(constructs[0].starts_with("%w[a b].each do"));
assert_eq!(constructs[1], "system \"make\", *args");
assert_eq!(constructs[2], "ENV[\"X\"] = which(\"python3\")");
assert!(constructs[3].starts_with("system \"install\""));
assert!(constructs[4].starts_with("cd \"sub\" do"));
assert!(!p
.steps
.iter()
.any(|s| matches!(s, InstallStep::BinInstall { .. })));
}
#[test]
fn install_receivers_rename_and_subdirs() {
let rb = r#"
class Foo < Formula
def install
bin.install "foo.sh" => "foo"
bin.install "a", "b"
lib.install "libfoo.a"
include.install "foo.h" => "foo/foo.h"
libexec.install "helper" => "h2"
share.install "data.dat"
man1.install "foo.1"
pkgshare.install "examples"
prefix.install "README"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![
InstallStep::BinInstall {
from: "foo.sh".into(),
to: Some("foo".into())
},
InstallStep::BinInstall {
from: "a".into(),
to: None
},
InstallStep::BinInstall {
from: "b".into(),
to: None
},
InstallStep::LibInstall {
from: "libfoo.a".into(),
to: None
},
InstallStep::IncludeInstall {
from: "foo.h".into(),
to: Some("foo/foo.h".into())
},
InstallStep::PrefixInstall {
from: "helper".into(),
to: Some("libexec/h2".into())
},
InstallStep::PrefixInstall {
from: "data.dat".into(),
to: Some("share".into())
},
InstallStep::PrefixInstall {
from: "foo.1".into(),
to: Some("share/man/man1".into())
},
InstallStep::PrefixInstall {
from: "examples".into(),
to: Some("share/foo".into())
},
InstallStep::PrefixInstall {
from: "README".into(),
to: None
},
]
);
}
#[test]
fn env_set_append_prepend_deparallelize() {
let rb = r#"
class Foo < Formula
def install
ENV["CFLAGS"] = "-O2"
ENV.append "CFLAGS", "-fPIC"
ENV.prepend "LDFLAGS", "-L#{lib}"
ENV.deparallelize
system "make"
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert!(p.deparallelize);
assert_eq!(
p.steps[..3],
[
InstallStep::EnvSet {
key: "CFLAGS".into(),
value: "-O2".into()
},
InstallStep::EnvSet {
key: "CFLAGS".into(),
value: "-O2 -fPIC".into()
},
InstallStep::EnvSet {
key: "LDFLAGS".into(),
value: "-L/tc/foo/lib".into()
},
]
);
assert_eq!(p.env.get("CFLAGS"), Some(&"-O2 -fPIC".to_string()));
assert_eq!(p.env.get("LDFLAGS"), Some(&"-L/tc/foo/lib".to_string()));
}
#[test]
fn inreplace_literal_and_block_forms() {
let rb = r##"
class Foo < Formula
def install
inreplace "Makefile", "@PREFIX@", "#{prefix}"
inreplace "config" do |s|
s.gsub! "a", "b"
end
end
end
"##;
let p = plan(rb, &ctx("foo", true, true));
assert_eq!(
p.steps[0],
InstallStep::Inreplace {
file: "Makefile".into(),
from: "@PREFIX@".into(),
to: "/tc/foo".into()
}
);
assert_eq!(p.unsupported_constructs().len(), 1);
assert!(p.unsupported_constructs()[0].starts_with("inreplace \"config\" do"));
}
#[test]
fn formula_dep_accessors_resolve_or_unsupported() {
let rb = r#"
class Foo < Formula
def install
system "./configure", "--with-ssl=#{Formula["openssl@3"].opt_prefix}", "--with-zlib=#{Formula["missing"].opt_lib}"
end
end
"#;
let mut c = ctx("foo", true, true);
c.dep_prefixes
.insert("openssl@3".into(), PathBuf::from("/tc/openssl@3"));
let p = plan(rb, &c);
assert!(!p.is_fully_supported());
let rb_ok = r#"
class Foo < Formula
def install
system "./configure", "--with-ssl=#{Formula["openssl@3"].opt_prefix}", "--sbin=#{Formula["openssl@3"].opt_bin}"
end
end
"#;
let p = plan(rb_ok, &c);
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![InstallStep::Configure {
args: vec![
"--with-ssl=/tc/openssl@3".into(),
"--sbin=/tc/openssl@3/bin".into(),
]
}]
);
}
#[test]
fn go_std_args_kwargs() {
let rb = r#"
class Foo < Formula
def install
system "go", "build", *std_go_args(ldflags: "-s -w")
system "go", "build", *std_go_args(output: bin/"gadget")
system "go", "build", *std_go_args(wat: "x")
end
end
"#;
let p = plan(rb, &ctx("foo", true, true));
assert_eq!(
p.steps[0],
InstallStep::Go {
args: vec![
"build".into(),
"-trimpath".into(),
"-o=/tc/foo/bin/foo".into(),
"-ldflags=-s -w".into(),
]
}
);
assert_eq!(
p.steps[1],
InstallStep::Go {
args: vec![
"build".into(),
"-trimpath".into(),
"-o=/tc/foo/bin/gadget".into(),
]
}
);
assert_eq!(p.unsupported_constructs().len(), 1);
}
#[test]
fn meson_ninja_and_generic_system() {
let rb = r##"
class Foo < Formula
def install
system "meson", "setup", "build"
system "ninja", "-C", "build", "install"
system "install_name_tool", "-id", "#{lib}/libfoo.dylib", "libfoo.dylib"
end
end
"##;
let p = plan(rb, &ctx("foo", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![
InstallStep::Meson {
args: vec!["meson".into(), "setup".into(), "build".into()]
},
InstallStep::Meson {
args: vec![
"ninja".into(),
"-C".into(),
"build".into(),
"install".into()
]
},
InstallStep::System {
argv: vec![
"install_name_tool".into(),
"-id".into(),
"/tc/foo/lib/libfoo.dylib".into(),
"libfoo.dylib".into(),
]
},
]
);
}
#[tokio::test]
async fn fetch_recipe_without_source_path_errors() {
let formula = FormulaData::default();
let err = fetch_recipe(&formula).await.unwrap_err();
assert!(matches!(err, ToolchainError::RegistryError { .. }));
assert!(err.to_string().contains("ruby_source_path"));
}
#[tokio::test]
async fn fetch_recipe_cached_reads_cache_file() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join(".build/recipe.rb");
tokio::fs::create_dir_all(cache.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&cache, "class Foo < Formula\nend\n")
.await
.unwrap();
let text = fetch_recipe_cached(&FormulaData::default(), &cache)
.await
.unwrap();
assert_eq!(text, "class Foo < Formula\nend\n");
}
#[test]
fn missing_install_block_errors() {
let rb = "class Foo < Formula\n url \"x\"\nend\n";
let err = parse_install_plan(rb, &ctx("foo", true, true)).unwrap_err();
assert!(err.to_string().contains("def install"));
}
#[test]
fn real_jq_formula_is_fully_supported() {
let p = plan(JQ_RB, &ctx("jq", true, true));
assert!(p.is_fully_supported(), "{:?}", p.unsupported_constructs());
assert_eq!(
p.steps,
vec![
InstallStep::Configure {
args: vec![
"--prefix=/tc/jq".into(),
"--libdir=/tc/jq/lib".into(),
"--disable-debug".into(),
"--disable-dependency-tracking".into(),
"--disable-silent-rules".into(),
"--disable-silent-rules".into(),
"--disable-maintainer-mode".into(),
]
},
InstallStep::Make {
args: vec!["install".into()]
},
]
);
assert!(p.resources.is_empty() && p.patches.is_empty());
}
#[test]
fn real_git_formula_locks_unsupported_set() {
let mut c = ctx("git", true, true);
c.dep_prefixes
.insert("pcre2".into(), PathBuf::from("/tc/pcre2"));
let p = plan(GIT_RB, &c);
assert!(!p.is_fully_supported());
let names: Vec<&str> = p.resources.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["Authen::SASL", "html", "man", "Net::SMTP::SSL"]);
for (key, value) in [
("NO_FINK", "1"),
("NO_DARWIN_PORTS", "1"),
("USE_LIBPCRE2", "1"),
("INSTALL_SYMLINKS", "1"),
("LIBPCREDIR", "/tc/pcre2"),
("V", "1"),
] {
assert_eq!(p.env.get(key), Some(&value.to_string()), "env {key}");
}
assert_eq!(p.env.len(), 6);
assert!(p.steps.iter().all(|s| matches!(
s,
InstallStep::EnvSet { .. } | InstallStep::Unsupported { .. }
)));
let constructs = p.unsupported_constructs();
let expected_markers = [
"odie ",
"ENV[\"PYTHON_PATH\"] = which(\"python3\")",
"ENV[\"PERL_PATH\"] = which(\"perl\")",
"perl_version = ",
"ENV[\"PERLLIB_EXTRA\"] = %W[",
"args = %W[",
"args += if OS.mac?",
"inreplace \"Makefile\"",
"system \"make\", \"install\", *args",
"git_core = ",
"rm ",
"cd \"contrib/",
"bash_completion.install ",
"zsh_completion.install ",
"cp \"#{bash_completion}/",
"(share/\"git-core\").install ",
"man.install ",
"(share/\"doc/git-doc\").install ",
"chmod ",
"resource(\"Net::SMTP::SSL\").stage do",
"resource(\"Authen::SASL\").stage do",
"perl_dir = ",
"rm_r ",
"(buildpath/\"gitconfig\").write ",
"etc.install ",
];
for construct in &constructs {
assert!(
expected_markers.iter().any(|m| construct.starts_with(m)),
"unexpected unsupported construct: {construct}"
);
}
assert_eq!(constructs.len(), 33, "{constructs:#?}");
}
const JQ_RB: &str = r##"class Jq < Formula
desc "Lightweight and flexible command-line JSON processor"
homepage "https://jqlang.github.io/jq/"
url "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-1.8.2.tar.gz"
sha256 "71b8d6e8f5fe81f6c6d0d110e3892251f6ce76ed095abd315e26e6e1193af3af"
license "MIT"
compatibility_version 1
livecheck do
url :stable
regex(/^(?:jq[._-])?v?(\d+(?:\.\d+)+)$/i)
end
bottle do
sha256 cellar: :any, arm64_tahoe: "f6ec821177b4c55fefb4c58f8e6f5d8386a5fce0a3c5d8811c4d6a0cc818dead"
sha256 cellar: :any, arm64_sequoia: "29c079abc7e165583b7fc4d3feba7a21a12160a414a7d4202731f7ffa2e349de"
sha256 cellar: :any, arm64_sonoma: "c5710de7fe77793fda8d4c550e5450b9fd77f9caf4a90c01be3a0969a8c078e8"
sha256 cellar: :any, sonoma: "0a471afde49b03dd7a8a1100c2581dc35ae49b3877a9c37fa219aeccdb998154"
sha256 cellar: :any, arm64_linux: "30137e8a68ffb92bc8e16494f1509f0c08b404cae8a0842e15b63f46e4baf028"
sha256 cellar: :any, x86_64_linux: "c3e26f0bff91db3e6606c3fc23ebe5dc2b1e9854252e6e4a3e03b17f7a3cde4e"
end
head do
url "https://github.com/jqlang/jq.git", branch: "master"
depends_on "autoconf" => :build
depends_on "automake" => :build
depends_on "libtool" => :build
end
depends_on "oniguruma"
def install
system "autoreconf", "--force", "--install", "--verbose" if build.head?
system "./configure", *std_configure_args,
"--disable-silent-rules",
"--disable-maintainer-mode"
system "make", "install"
end
test do
assert_equal "2\n", pipe_output("#{bin}/jq .bar", '{"foo":1, "bar":2}')
end
end
"##;
const GIT_RB: &str = r##"class Git < Formula
desc "Distributed revision control system"
homepage "https://git-scm.com"
url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.55.0.tar.xz"
sha256 "457fdb04dc8728e007d4688695e6912e6f680727920f2a40bf11eacc17505357"
license all_of: [
"GPL-2.0-only",
"GPL-2.0-or-later", # imap-send.c; trace.c; ...
"LGPL-2.1-or-later", # xdiff/
"BSD-3-Clause", # xdiff/xhistogram.c; reftable/
"MIT", # khash.h; sha1dc/
]
compatibility_version 1
head "https://github.com/git/git.git", branch: "master"
livecheck do
url "https://mirrors.edge.kernel.org/pub/software/scm/git/"
regex(/href=.*?git[._-]v?(\d+(?:\.\d+)+)\.t/i)
end
bottle do
sha256 arm64_tahoe: "8cae58826d317457128023d07c07f00ac00aaf3019356ff72a837e804f5d0306"
sha256 arm64_sequoia: "5df08160c5e705ffcce25edfe1bb189d50fd3366a0e451fc254a8d70904adc5f"
sha256 arm64_sonoma: "3d7a66500cc7bf1530d7a5e862f4b4c2e317ed53c95bf76c012ce8ff7ec92ae7"
sha256 sonoma: "f7477c65f53669d1f9fd90ad2d633e6c009d5f6b0e10e039c15ebc022ac18bde"
sha256 arm64_linux: "ea73cb7a909a7c80c2c5474b15b53659dd04b1222efd678e9a693bb06a7ed314"
sha256 x86_64_linux: "01365c33768a2cb170a34f8f201ea3f3a7bb8c0d2140640ff3fbc58584e9758e"
end
depends_on "gettext" => :build
depends_on "pkgconf" => :build
depends_on "pcre2"
uses_from_macos "curl"
uses_from_macos "expat"
# Don't try to add a libiconv dependency without reading this PR first:
# https://github.com/Homebrew/homebrew-core/pull/258461
on_macos do
depends_on "gettext"
end
on_linux do
depends_on "openssl@3" # for git-imap-send (GPL-2.0-or-later), uses CommonCrypto on macOS
depends_on "zlib-ng-compat"
end
resource "Authen::SASL" do
url "https://cpan.metacpan.org/authors/id/E/EH/EHUELS/Authen-SASL-2.2000.tar.gz"
sha256 "8cdf5a7f185448b614471675dae5b26f8c6e330b62264c3ff5d91172d6889b99"
end
resource "html" do
url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-htmldocs-2.55.0.tar.xz"
sha256 "d1142c4e28b469d297d6df6519653e92a76c952f55202fde17a72a3b03d49437"
livecheck do
formula :parent
end
end
resource "man" do
url "https://mirrors.edge.kernel.org/pub/software/scm/git/git-manpages-2.55.0.tar.xz"
sha256 "a32d432f80df46a14a05d1104c72d5a13fe27e9feba9aa0f017e54131db6b982"
livecheck do
formula :parent
end
end
resource "Net::SMTP::SSL" do
url "https://cpan.metacpan.org/authors/id/R/RJ/RJBS/Net-SMTP-SSL-1.04.tar.gz"
sha256 "7b29c45add19d3d5084b751f7ba89a8e40479a446ce21cfd9cc741e558332a00"
end
deny_network_access! [:build, :postinstall]
def install
odie "html resource needs to be updated" if build.stable? && version != resource("html").version
odie "man resource needs to be updated" if build.stable? && version != resource("man").version
# If these things are installed, tell Git build system not to use them
ENV["NO_FINK"] = "1"
ENV["NO_DARWIN_PORTS"] = "1"
ENV["PYTHON_PATH"] = which("python3")
ENV["PERL_PATH"] = which("perl")
ENV["USE_LIBPCRE2"] = "1"
ENV["INSTALL_SYMLINKS"] = "1"
ENV["LIBPCREDIR"] = formula_opt_prefix("pcre2")
ENV["V"] = "1" # build verbosely
perl_version = Utils.safe_popen_read("perl", "--version")[/v(\d+\.\d+)(?:\.\d+)?/, 1]
if OS.mac?
ENV["PERLLIB_EXTRA"] = %W[
#{MacOS.active_developer_dir}
/Library/Developer/CommandLineTools
/Applications/Xcode.app/Contents/Developer
].uniq.map do |p|
"#{p}/Library/Perl/#{perl_version}/darwin-thread-multi-2level"
end.join(":")
end
# The git-gui and gitk tools are installed by a separate formula (git-gui)
# to avoid a dependency on tcl-tk and to avoid using the broken system
# tcl-tk (see https://github.com/Homebrew/homebrew-core/issues/36390)
# This is done by setting the NO_TCLTK make variable.
args = %W[
prefix=#{prefix}
sysconfdir=#{etc}
CC=#{ENV.cc}
CFLAGS=#{ENV.cflags}
LDFLAGS=#{ENV.ldflags}
NO_TCLTK=1
NO_RUST=1
]
args += if OS.mac?
%w[NO_OPENSSL=1 APPLE_COMMON_CRYPTO=1]
else
openssl_prefix = formula_opt_prefix("openssl@3")
%W[NO_APPLE_COMMON_CRYPTO=1 OPENSSLDIR=#{openssl_prefix}]
end
# Make sure `git` looks in `opt_prefix` instead of the Cellar.
# Otherwise, Cellar references propagate to generated plists from `git maintenance`.
inreplace "Makefile", /(-DFALLBACK_RUNTIME_PREFIX=")[^"]+/, "\\1#{opt_prefix}"
system "make", "install", *args
git_core = libexec/"git-core"
rm git_core/"git-svn"
# Install the macOS keychain credential helper
if OS.mac?
cd "contrib/credential/osxkeychain" do
system "make", *args
git_core.install "git-credential-osxkeychain"
system "make", "clean"
end
end
# Generate diff-highlight perl script executable
cd "contrib/diff-highlight" do
system "make"
end
# Install the netrc credential helper
cd "contrib/credential/netrc" do
system "make", "test"
git_core.install "git-credential-netrc"
end
# Install git-subtree
cd "contrib/subtree" do
system "make", "CC=#{ENV.cc}",
"CFLAGS=#{ENV.cflags}",
"LDFLAGS=#{ENV.ldflags}"
git_core.install "git-subtree"
end
# Install git-jump
cd "contrib/git-jump" do
git_core.install "git-jump"
end
# install the completion script first because it is inside "contrib"
bash_completion.install "contrib/completion/git-completion.bash"
bash_completion.install "contrib/completion/git-prompt.sh"
zsh_completion.install "contrib/completion/git-completion.zsh" => "_git"
cp "#{bash_completion}/git-completion.bash", zsh_completion
cp "#{bash_completion}/git-prompt.sh", zsh_completion
(share/"git-core").install "contrib"
# We could build the manpages ourselves, but the build process depends
# on many other packages, and is somewhat crazy, this way is easier.
man.install resource("man")
(share/"doc/git-doc").install resource("html")
# Make html docs world-readable
chmod 0644, Dir["#{share}/doc/git-doc/**/*.{html,txt}"]
chmod 0755, Dir["#{share}/doc/git-doc/{RelNotes,howto,technical}"]
# git-send-email needs Net::SMTP::SSL or Net::SMTP >= 2.34 and Authen::SASL
resource("Net::SMTP::SSL").stage do
(share/"perl5").install "lib/Net"
end
resource("Authen::SASL").stage do
(share/"perl5").install "lib/Authen"
end
# This is only created when building against system Perl, but it isn't
# purged by Homebrew's post-install cleaner because that doesn't check
# "Library" directories. It is however pointless to keep around as it
# only contains the perllocal.pod installation file.
perl_dir = prefix/"Library/Perl"
rm_r perl_dir if perl_dir.exist?
# Set the macOS keychain credential helper by default
# (as Apple's CLT's git also does this).
if OS.mac?
(buildpath/"gitconfig").write <<~EOS
[credential]
\thelper = osxkeychain
EOS
etc.install "gitconfig"
end
end
def caveats
<<~EOS
The Tcl/Tk GUIs (e.g. gitk, git-gui) are now in the `git-gui` formula.
Subversion interoperability (git-svn) is now in the `git-svn` formula.
EOS
end
test do
system bin/"git", "init"
%w[haunted house].each { |f| touch testpath/f }
system bin/"git", "add", "haunted", "house"
system bin/"git", "config", "user.name", "'A U Thor'"
system bin/"git", "config", "user.email", "author@example.com"
system bin/"git", "commit", "-a", "-m", "Initial Commit"
assert_equal "haunted\nhouse", shell_output("#{bin}/git ls-files").strip
# Check that our `inreplace` for the `Makefile` does not break.
# If this assertion fails, please fix the `inreplace` instead of removing this test.
# The failure of this test means that `git` will generate broken launchctl plist files.
refute_match HOMEBREW_CELLAR.to_s, shell_output("#{bin}/git --exec-path")
return unless OS.mac?
# Check Net::SMTP or Net::SMTP::SSL works for git-send-email
%w[foo bar].each { |f| touch testpath/f }
system bin/"git", "add", "foo", "bar"
system bin/"git", "commit", "-a", "-m", "Second Commit"
assert_match "Authentication Required", pipe_output(
"#{bin}/git send-email --from=test@example.com --to=dev@null.com " \
"--smtp-server=smtp.gmail.com --smtp-server-port=587 " \
"--smtp-encryption=tls --confirm=never HEAD^ 2>&1",
)
end
end
"##;
}