Files
depot/src/package/starbuild.rs
T
SFG545 192cdc1af2 feat: add support for package groups in dependency management
- Introduced a new `groups` field in the package specification to categorize packages.
- Updated various functions to handle package groups, including parsing, registration, and querying.
- Enhanced database schema to store package group information.
- Modified the interactive package creation process to allow users to specify package groups.
- Implemented tests to verify the correct handling of package groups in both source and binary repositories.
- Updated the output formatting to include package group information where applicable.
2026-03-25 00:23:16 -05:00

1371 lines
44 KiB
Rust

//! Legacy STARBUILD conversion support.
use super::{
Alternatives, Build, BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo,
PackageSpec, Source,
};
use anyhow::{Context, Result, bail};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct ConvertedStarbuild {
pub output_path: PathBuf,
pub toml: String,
pub build_script: Option<String>,
pub build_script_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
struct ParsedStarbuild {
package_names: Vec<String>,
package_descriptions: Vec<String>,
package_version: String,
description: String,
licenses: Vec<String>,
dependencies: Vec<String>,
build_dependencies: Vec<String>,
optional_dependencies: Vec<String>,
provides: Vec<String>,
conflicts: Vec<String>,
keep: Vec<String>,
options: Vec<String>,
sources: Vec<String>,
custom_assignments: Vec<String>,
custom_vars: HashMap<String, String>,
custom_functions: Vec<String>,
prepare: Vec<String>,
compile: Vec<String>,
verify: Vec<String>,
generic_assemble: Vec<String>,
assemble_functions: BTreeMap<String, Vec<String>>,
output_dependencies: BTreeMap<String, Vec<String>>,
output_optional: BTreeMap<String, Vec<String>>,
output_provides: BTreeMap<String, Vec<String>>,
output_conflicts: BTreeMap<String, Vec<String>>,
output_keep: BTreeMap<String, Vec<String>>,
output_licenses: BTreeMap<String, Vec<String>>,
symlinks: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PhaseKind {
Prepare,
Compile,
Verify,
AssembleGeneric,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FunctionCapture {
Phase(PhaseKind),
AssembleSpecific(String),
Custom,
}
pub(crate) fn convert_starbuild_file(
input_path: &Path,
output_override: Option<&Path>,
) -> Result<ConvertedStarbuild> {
let input_path = input_path
.canonicalize()
.with_context(|| format!("Failed to resolve STARBUILD path: {}", input_path.display()))?;
let input_text = fs::read_to_string(&input_path)
.with_context(|| format!("Failed to read STARBUILD: {}", input_path.display()))?;
let parsed = ParsedStarbuild::parse(&input_text)
.with_context(|| format!("Failed to parse STARBUILD: {}", input_path.display()))?;
let output_path = output_override.map(PathBuf::from).unwrap_or_else(|| {
input_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{}.toml", parsed.main_package_name()))
});
let spec_dir = output_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let spec = parsed.to_package_spec(spec_dir)?;
let toml = super::interactive::spec_to_minimal_toml(&spec)?;
let build_script = (!matches!(spec.build.build_type, BuildType::Meta))
.then(|| parsed.generate_build_script())
.transpose()?;
let build_script_path = build_script.as_ref().map(|_| {
output_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("build.sh")
});
Ok(ConvertedStarbuild {
output_path,
toml,
build_script,
build_script_path,
})
}
impl ParsedStarbuild {
fn parse(input: &str) -> Result<Self> {
let mut parsed = Self::default();
let lines: Vec<&str> = input.lines().collect();
let mut idx = 0usize;
let mut capture: Option<FunctionCapture> = None;
let mut current_lines: Vec<String> = Vec::new();
while idx < lines.len() {
let line = lines[idx];
let trimmed = line.trim();
if let Some(kind) = capture.clone() {
match kind {
FunctionCapture::Custom => {
current_lines.push(line.to_string());
if trimmed == "}" {
parsed.custom_functions.push(current_lines.join("\n"));
current_lines.clear();
capture = None;
}
}
FunctionCapture::Phase(phase) => {
if trimmed == "}" {
parsed.push_phase_lines(phase, std::mem::take(&mut current_lines));
capture = None;
} else {
current_lines.push(line.to_string());
}
}
FunctionCapture::AssembleSpecific(pkg_name) => {
if trimmed == "}" {
parsed
.assemble_functions
.insert(pkg_name, std::mem::take(&mut current_lines));
capture = None;
} else {
current_lines.push(line.to_string());
}
}
}
idx += 1;
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
idx += 1;
continue;
}
if let Some(function_name) = parse_function_name(trimmed) {
capture = Some(match function_name.as_str() {
"prepare" => FunctionCapture::Phase(PhaseKind::Prepare),
"compile" => FunctionCapture::Phase(PhaseKind::Compile),
"verify" => FunctionCapture::Phase(PhaseKind::Verify),
"assemble" => FunctionCapture::Phase(PhaseKind::AssembleGeneric),
_ => {
if let Some(pkg_name) = function_name.strip_prefix("assemble_") {
FunctionCapture::AssembleSpecific(pkg_name.to_string())
} else {
current_lines.push(line.to_string());
FunctionCapture::Custom
}
}
});
idx += 1;
continue;
}
if let Some((key, value)) = parse_simple_assignment(trimmed) {
if key == "package_name" {
parsed.package_names = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "package_version" {
parsed.package_version = strip_matching_quotes(&value).to_string();
idx += 1;
continue;
}
if key == "description" {
parsed.description = strip_matching_quotes(&value).to_string();
idx += 1;
continue;
}
if key == "package_descriptions" {
parsed.package_descriptions = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "license" {
parsed.licenses = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "dependencies" {
parsed.dependencies = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "build_dependencies" {
parsed.build_dependencies = parse_array_or_string(&value);
idx += 1;
continue;
}
if matches!(
key.as_str(),
"optional_dependencies" | "optional_depedencies" | "optional_dependecies"
) {
parsed.optional_dependencies = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "gives" {
parsed.provides = parse_array_or_string(&value);
idx += 1;
continue;
}
if matches!(key.as_str(), "clashes" | "clasheses" | "conflicts") {
parsed.conflicts = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "keep" {
parsed.keep = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "options" {
parsed.options = parse_array_or_string(&value);
idx += 1;
continue;
}
if key == "sources" || key == "source" {
parsed.sources = parse_array_or_string(&value);
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("dependencies_") {
parsed
.output_dependencies
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("optional_") {
parsed
.output_optional
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("gives_") {
parsed
.output_provides
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("clashes_") {
parsed
.output_conflicts
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("conflicts_") {
parsed
.output_conflicts
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("keep_") {
parsed
.output_keep
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
if let Some(pkg_name) = key.strip_prefix("license_") {
parsed
.output_licenses
.insert(pkg_name.to_string(), parse_array_or_string(&value));
idx += 1;
continue;
}
parsed.custom_assignments.push(trimmed.to_string());
parsed
.custom_vars
.insert(key, strip_matching_quotes(&value).to_string());
idx += 1;
continue;
}
if let Some(symlink) = trimmed.strip_prefix("symlink:") {
let pair = strip_matching_quotes(symlink.trim());
if let Some((link, target)) = pair.split_once(':') {
parsed
.symlinks
.push((link.trim().to_string(), target.trim().to_string()));
}
}
idx += 1;
}
if parsed.package_names.is_empty() {
bail!("STARBUILD does not define package_name");
}
if parsed.package_version.trim().is_empty() {
bail!("STARBUILD does not define package_version");
}
parsed.finalize_metadata();
Ok(parsed)
}
fn main_package_name(&self) -> &str {
&self.package_names[0]
}
fn finalize_metadata(&mut self) {
let main_pkg = self.main_package_name().to_string();
self.description = expand_legacy_vars(
&self.description,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.package_names,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.package_descriptions,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.licenses,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.dependencies,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.build_dependencies,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.optional_dependencies,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.provides,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.conflicts,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.keep,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.options,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_vec(
&mut self.sources,
&main_pkg,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_dependencies,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_optional,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_provides,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_conflicts,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_keep,
&self.package_version,
&self.custom_vars,
);
finalize_map(
&mut self.output_licenses,
&self.package_version,
&self.custom_vars,
);
for (link, target) in &mut self.symlinks {
*link = expand_legacy_vars(link, &main_pkg, &self.package_version, &self.custom_vars);
*target =
expand_legacy_vars(target, &main_pkg, &self.package_version, &self.custom_vars);
}
}
fn to_package_spec(&self, spec_dir: PathBuf) -> Result<PackageSpec> {
let homepage = infer_homepage(&self.sources).unwrap_or_default();
let primary_description = self
.package_descriptions
.first()
.cloned()
.unwrap_or_else(|| {
if self.description.is_empty() {
self.main_package_name().to_string()
} else {
self.description.clone()
}
});
let default_license = if self.licenses.is_empty() {
vec!["Unknown".to_string()]
} else {
self.licenses.clone()
};
let mut primary = PackageInfo {
name: self.main_package_name().to_string(),
real_name: None,
version: self.package_version.clone(),
revision: 1,
description: primary_description,
homepage: homepage.clone(),
abi_breaking: false,
license: self
.output_licenses
.get(self.main_package_name())
.cloned()
.unwrap_or_else(|| default_license.clone()),
};
if primary.license.is_empty() {
primary.license = default_license.clone();
}
let multiple_outputs = self.package_names.len() > 1;
let mut extra_packages = Vec::new();
for (idx, pkg_name) in self.package_names.iter().enumerate().skip(1) {
let mut pkg = PackageInfo {
name: pkg_name.clone(),
real_name: None,
version: self.package_version.clone(),
revision: 1,
description: self
.package_descriptions
.get(idx)
.cloned()
.unwrap_or_else(|| primary.description.clone()),
homepage: homepage.clone(),
abi_breaking: false,
license: self
.output_licenses
.get(pkg_name)
.cloned()
.unwrap_or_else(|| default_license.clone()),
};
if pkg.license.is_empty() {
pkg.license = default_license.clone();
}
extra_packages.push(pkg);
}
let mut dependencies = Dependencies {
build: dedupe_preserve_order(self.build_dependencies.clone()),
runtime: dedupe_preserve_order(self.dependencies.clone()),
test: Vec::new(),
optional: dedupe_preserve_order(self.optional_dependencies.clone()),
groups: Vec::new(),
lib32: None,
};
let mut alternatives = Alternatives {
provides: dedupe_preserve_order(self.provides.clone()),
conflicts: dedupe_preserve_order(self.conflicts.clone()),
replaces: Vec::new(),
lib32: None,
};
let mut package_dependencies = BTreeMap::new();
let mut package_alternatives = BTreeMap::new();
if multiple_outputs {
for pkg_name in self.package_names.iter().skip(1) {
package_dependencies.insert(
pkg_name.clone(),
Dependencies {
build: Vec::new(),
runtime: dedupe_preserve_order(
self.output_dependencies
.get(pkg_name)
.cloned()
.unwrap_or_default(),
),
test: Vec::new(),
optional: dedupe_preserve_order(
self.output_optional
.get(pkg_name)
.cloned()
.unwrap_or_default(),
),
groups: Vec::new(),
lib32: None,
},
);
package_alternatives.insert(
pkg_name.clone(),
Alternatives {
provides: dedupe_preserve_order(
self.output_provides
.get(pkg_name)
.cloned()
.unwrap_or_default(),
),
conflicts: dedupe_preserve_order(
self.output_conflicts
.get(pkg_name)
.cloned()
.unwrap_or_default(),
),
replaces: Vec::new(),
lib32: None,
},
);
}
}
let mut keep = self.keep.clone();
for values in self.output_keep.values() {
keep.extend(values.clone());
}
keep = dedupe_preserve_order(keep);
let extractable_sources = self.extractable_sources()?;
let manual_sources = self.manual_sources();
let has_build_steps = self.has_build_steps();
let is_meta =
extractable_sources.is_empty() && manual_sources.is_empty() && !has_build_steps;
if multiple_outputs {
dependencies.runtime = dedupe_preserve_order(self.dependencies.clone());
dependencies.optional = dedupe_preserve_order(self.optional_dependencies.clone());
alternatives.provides = dedupe_preserve_order(self.provides.clone());
alternatives.conflicts = dedupe_preserve_order(self.conflicts.clone());
}
Ok(PackageSpec {
package: primary,
packages: extra_packages,
alternatives,
manual_sources,
source: extractable_sources,
build: Build {
build_type: if is_meta {
BuildType::Meta
} else {
BuildType::Custom
},
flags: BuildFlags {
keep,
use_lto: !self.option_enabled("!lto"),
no_strip: self.option_enabled("no-strip")
|| self.option_enabled("nostrip")
|| self.option_enabled("no-strip-binaries"),
no_delete_static: self.option_enabled("no-remove-a"),
no_compress_man: self.option_enabled("no-compress-man"),
split_docs: self.option_enabled("docs"),
..BuildFlags::default()
},
},
dependencies,
package_alternatives,
package_dependencies,
spec_dir,
})
}
fn generate_build_script(&self) -> Result<String> {
let primary_name = self.main_package_name();
let install_handlers = self.install_handler_map()?;
let mut script = String::new();
script.push_str("#!/bin/sh\nset -eu\n\n");
script.push_str("makei() {\n");
script.push_str(" echo \"==> Running: make DESTDIR=\\\"$pkgdir\\\" install $*\"\n");
script.push_str(" make DESTDIR=\"$pkgdir\" install \"$@\"\n");
script.push_str("}\n\n");
script.push_str("mesoni() {\n");
script.push_str(" echo \"==> Running: meson install --destdir \\\"$pkgdir\\\" $*\"\n");
script.push_str(" meson install --destdir \"$pkgdir\" \"$@\"\n");
script.push_str("}\n\n");
script.push_str("cmakei() {\n");
script.push_str(" echo \"==> Running: cmake --install $*\"\n");
script.push_str(" DESTDIR=\"$pkgdir\" cmake --install \"$@\"\n");
script.push_str("}\n\n");
script.push_str("starmove() {\n");
script.push_str(" [ \"$#\" -ge 1 ] || {\n");
script.push_str(" echo \"starmove: requires at least one path pattern\" >&2\n");
script.push_str(" return 1\n");
script.push_str(" }\n");
script.push_str(" [ \"${DEPOT_OUTPUT_NAME:-}\" != \"\" ] || {\n");
script.push_str(" echo \"starmove: DEPOT_OUTPUT_NAME is not set\" >&2\n");
script.push_str(" return 1\n");
script.push_str(" }\n");
script.push_str(" haul \"$DEPOT_OUTPUT_NAME\" \"$@\"\n");
script.push_str("}\n\n");
script.push_str("depot_starbuild_sync_srcdir() {\n");
script.push_str(" workdir=${DEPOT_STARBUILD_WORKDIR:?}\n");
script.push_str(" compat_root=\"$workdir/.depot-starbuild\"\n");
script.push_str(" mkdir -p \"$compat_root/packages\"\n");
script
.push_str(" for entry in \"$workdir\"/* \"$workdir\"/.[!.]* \"$workdir\"/..?*; do\n");
script.push_str(" [ -e \"$entry\" ] || [ -L \"$entry\" ] || continue\n");
script.push_str(" base=$(basename \"$entry\")\n");
script.push_str(" [ \"$base\" = \".depot-starbuild\" ] && continue\n");
script.push_str(" [ \"$base\" = \"packages\" ] && continue\n");
script.push_str(" ln -snf \"$entry\" \"$compat_root/$base\"\n");
script.push_str(" done\n");
for pkg_name in &self.package_names {
let dest_expr = if pkg_name == primary_name {
"\"${DEPOT_PRIMARY_DESTDIR:-$DESTDIR}\"".to_string()
} else {
format!("\"$(subdestdir '{}')\"", sh_single_quote(pkg_name))
};
script.push_str(&format!(
" mkdir -p \"$compat_root/packages/{}/\"\n",
pkg_name
));
script.push_str(&format!(
" ln -snf {} \"$compat_root/packages/{}/files\"\n",
dest_expr, pkg_name
));
}
script.push_str(" srcdir=\"$compat_root\"\n");
script.push_str(" export srcdir\n");
script.push_str("}\n\n");
script.push_str("depot_starbuild_setup_env() {\n");
script.push_str(" package_name=$1\n");
script.push_str(&format!(
" package_version='{}'\n",
sh_single_quote(&self.package_version)
));
script.push_str(" pkgdir=${DESTDIR:?}\n");
script.push_str(" export package_name package_version pkgdir\n");
script.push_str(" LANG=C\n");
script.push_str(" LC_ALL=C\n");
script.push_str(" export LANG LC_ALL\n");
script.push_str(" depot_starbuild_sync_srcdir\n");
for assignment in &self.custom_assignments {
script.push_str(" ");
script.push_str(assignment.trim());
script.push('\n');
}
script.push_str("}\n\n");
for custom_function in &self.custom_functions {
script.push_str(custom_function);
script.push_str("\n\n");
}
if self.has_build_steps() {
script.push_str("depot_build() {\n");
script.push_str(&format!(
" depot_starbuild_setup_env '{}'\n",
sh_single_quote(primary_name)
));
append_shell_body(
&mut script,
&self.prepare,
&self.compile,
&self.verify,
None,
);
script.push_str("}\n\n");
}
let primary_install = install_handlers
.get(primary_name)
.cloned()
.unwrap_or_default();
script.push_str("depot_install() {\n");
script.push_str(&format!(
" depot_starbuild_setup_env '{}'\n",
sh_single_quote(primary_name)
));
append_shell_body(&mut script, &[], &[], &[], Some(&primary_install));
for (link, target) in &self.symlinks {
script.push_str(&format!(
" mkdir -p \"$(dirname \"$pkgdir/{0}\")\"\n ln -snf '{1}' \"$pkgdir/{0}\"\n",
link,
sh_single_quote(target)
));
}
script.push_str("}\n\n");
for pkg_name in self.package_names.iter().skip(1) {
let fn_suffix = shell_fn_suffix(pkg_name);
let body = install_handlers.get(pkg_name).cloned().unwrap_or_default();
script.push_str(&format!("depot_install_{fn_suffix}() {{\n"));
script.push_str(&format!(
" depot_starbuild_setup_env '{}'\n",
sh_single_quote(pkg_name)
));
append_shell_body(&mut script, &[], &[], &[], Some(&body));
script.push_str("}\n\n");
}
Ok(script)
}
fn has_build_steps(&self) -> bool {
!self.prepare.is_empty() || !self.compile.is_empty() || !self.verify.is_empty()
}
fn option_enabled(&self, option: &str) -> bool {
self.options.iter().any(|value| value == option)
}
fn extractable_sources(&self) -> Result<Vec<Source>> {
let mut out = Vec::new();
for raw in &self.sources {
if let Some(source) = convert_extractable_source(raw, self.main_package_name())? {
out.push(source);
}
}
Ok(out)
}
fn manual_sources(&self) -> Vec<ManualSource> {
let mut out = Vec::new();
for raw in &self.sources {
if let Some(manual) = convert_manual_source(raw) {
out.push(manual);
}
}
out
}
fn install_handler_map(&self) -> Result<BTreeMap<String, Vec<String>>> {
let mut handlers = BTreeMap::new();
for pkg_name in &self.package_names {
if let Some(body) = self.assemble_functions.get(pkg_name) {
handlers.insert(pkg_name.clone(), body.clone());
continue;
}
if !self.generic_assemble.is_empty() {
handlers.insert(pkg_name.clone(), self.generic_assemble.clone());
continue;
}
if pkg_name == self.main_package_name() {
handlers.insert(pkg_name.clone(), Vec::new());
continue;
}
bail!(
"STARBUILD defines output '{}' but has no matching assemble() or assemble_{}()",
pkg_name,
pkg_name
);
}
Ok(handlers)
}
fn push_phase_lines(&mut self, phase: PhaseKind, lines: Vec<String>) {
match phase {
PhaseKind::Prepare => self.prepare = lines,
PhaseKind::Compile => self.compile = lines,
PhaseKind::Verify => self.verify = lines,
PhaseKind::AssembleGeneric => self.generic_assemble = lines,
}
}
}
fn append_shell_body(
out: &mut String,
prepare: &[String],
compile: &[String],
verify: &[String],
install: Option<&[String]>,
) {
let mut wrote = false;
for line in prepare {
out.push_str(" ");
out.push_str(line);
out.push('\n');
wrote = true;
}
for line in compile {
out.push_str(" ");
out.push_str(line);
out.push('\n');
wrote = true;
}
for line in verify {
out.push_str(" ");
out.push_str(line);
out.push('\n');
wrote = true;
}
if let Some(install_lines) = install {
for line in install_lines {
out.push_str(" ");
out.push_str(line);
out.push('\n');
wrote = true;
}
}
if !wrote {
out.push_str(" :\n");
}
}
fn finalize_vec(
values: &mut Vec<String>,
pkg_name: &str,
package_version: &str,
custom_vars: &HashMap<String, String>,
) {
for value in values {
*value = expand_legacy_vars(value, pkg_name, package_version, custom_vars);
}
}
fn finalize_map(
values: &mut BTreeMap<String, Vec<String>>,
package_version: &str,
custom_vars: &HashMap<String, String>,
) {
for (pkg_name, items) in values {
finalize_vec(items, pkg_name, package_version, custom_vars);
}
}
fn convert_extractable_source(raw: &str, pkg_name: &str) -> Result<Option<Source>> {
if is_local_source(raw) || raw.starts_with("ne+") {
return Ok(None);
}
if !is_extractable_remote_source(raw) {
return Ok(None);
}
let normalized = normalize_source_url(raw);
Ok(Some(Source {
url: normalized.clone(),
sha256: "skip".to_string(),
extract_dir: derive_extract_dir(&normalized, pkg_name),
patches: Vec::new(),
post_extract: Vec::new(),
cherry_pick: Vec::new(),
}))
}
fn convert_manual_source(raw: &str) -> Option<ManualSource> {
if is_local_source(raw) {
return Some(ManualSource {
file: Some(raw.to_string()),
files: Vec::new(),
url: None,
urls: Vec::new(),
sha256: None,
dest: None,
});
}
if raw.starts_with("ne+") || !is_extractable_remote_source(raw) {
return Some(ManualSource {
file: None,
files: Vec::new(),
url: Some(normalize_source_url(raw)),
urls: Vec::new(),
sha256: None,
dest: None,
});
}
None
}
fn parse_function_name(line: &str) -> Option<String> {
let open = line.find('(')?;
let close = line[open..].find(')')? + open;
let brace = line[close..].find('{')? + close;
let name = line[..open].trim();
if brace <= close || name.is_empty() {
return None;
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
return None;
}
Some(name.to_string())
}
fn parse_simple_assignment(line: &str) -> Option<(String, String)> {
let eq = line.find('=')?;
let key = line[..eq].trim();
if key.is_empty() {
return None;
}
if !key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
return None;
}
Some((key.to_string(), line[eq + 1..].trim().to_string()))
}
fn parse_array_or_string(value: &str) -> Vec<String> {
let trimmed = value.trim();
if let Some(inner) = trimmed.strip_prefix('(').and_then(|v| v.strip_suffix(')')) {
return tokenize_shell_words(inner);
}
if let Some(inner) = trimmed.strip_prefix('{').and_then(|v| v.strip_suffix('}')) {
return tokenize_shell_words(inner);
}
let unquoted = strip_matching_quotes(trimmed);
if unquoted.is_empty() {
Vec::new()
} else {
vec![unquoted.to_string()]
}
}
fn tokenize_shell_words(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for ch in input.chars() {
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
} else {
current.push(ch);
}
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
c if c.is_whitespace() => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
out.push(current);
}
out
}
fn strip_matching_quotes(value: &str) -> &str {
if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
&value[1..value.len() - 1]
} else {
value
}
}
fn expand_legacy_vars(
input: &str,
pkg_name: &str,
package_version: &str,
custom_vars: &HashMap<String, String>,
) -> String {
let mut current = input.to_string();
for _ in 0..16 {
let next = expand_legacy_vars_once(&current, pkg_name, package_version, custom_vars);
if next == current {
break;
}
current = next;
}
current
}
fn expand_legacy_vars_once(
input: &str,
pkg_name: &str,
package_version: &str,
custom_vars: &HashMap<String, String>,
) -> String {
let chars: Vec<char> = input.chars().collect();
let mut idx = 0usize;
let mut out = String::new();
while idx < chars.len() {
if chars[idx] != '$' {
out.push(chars[idx]);
idx += 1;
continue;
}
if idx + 1 < chars.len() && chars[idx + 1] == '{' {
let mut end = idx + 2;
while end < chars.len() && chars[end] != '}' {
end += 1;
}
if end >= chars.len() {
out.push(chars[idx]);
idx += 1;
continue;
}
let key: String = chars[idx + 2..end].iter().collect();
out.push_str(&legacy_var_value(
&key,
pkg_name,
package_version,
custom_vars,
));
idx = end + 1;
continue;
}
let mut end = idx + 1;
while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
end += 1;
}
if end == idx + 1 {
out.push(chars[idx]);
idx += 1;
continue;
}
let key: String = chars[idx + 1..end].iter().collect();
out.push_str(&legacy_var_value(
&key,
pkg_name,
package_version,
custom_vars,
));
idx = end;
}
out
}
fn legacy_var_value(
key: &str,
pkg_name: &str,
package_version: &str,
custom_vars: &HashMap<String, String>,
) -> String {
match key {
"package_name" => pkg_name.to_string(),
"package_version" => package_version.to_string(),
_ => custom_vars
.get(key)
.cloned()
.unwrap_or_else(|| format!("${key}")),
}
}
fn infer_homepage(sources: &[String]) -> Option<String> {
for source in sources {
if is_local_source(source) {
continue;
}
let normalized = normalize_source_url(source);
let base = normalized
.strip_prefix("hg+")
.unwrap_or(&normalized)
.split('#')
.next()
.unwrap_or(&normalized);
if let Some(with_scheme) = base
.strip_prefix("http://")
.map(|rest| format!("http://{}", rest))
.or_else(|| {
base.strip_prefix("https://")
.map(|rest| format!("https://{}", rest))
})
.or_else(|| {
base.strip_prefix("ftp://")
.map(|rest| format!("ftp://{}", rest))
})
{
return Some(with_scheme);
}
}
None
}
fn normalize_source_url(raw: &str) -> String {
raw.strip_prefix("git+")
.or_else(|| raw.strip_prefix("ne+"))
.map(str::to_string)
.unwrap_or_else(|| raw.to_string())
}
fn derive_extract_dir(url: &str, fallback_pkg_name: &str) -> String {
let base = url.split('#').next().unwrap_or(url);
let filename = base.rsplit('/').next().unwrap_or(base);
let without_query = filename.split('?').next().unwrap_or(filename);
if url.starts_with("hg+") {
return without_query
.trim_end_matches(".hg")
.trim_end_matches(".git")
.to_string();
}
if base.ends_with(".git") || (url.contains('#') && !looks_like_archive(base)) {
return without_query.trim_end_matches(".git").to_string();
}
strip_archive_suffixes(without_query)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| fallback_pkg_name.to_string())
}
fn strip_archive_suffixes(filename: &str) -> Option<String> {
const SUFFIXES: &[&str] = &[
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tar.lz", ".tar.lz4", ".tgz", ".tbz2",
".txz", ".zip", ".tar", ".gz", ".xz", ".bz2", ".zst", ".deb", ".rpm",
];
for suffix in SUFFIXES {
if let Some(stripped) = filename.strip_suffix(suffix) {
return Some(stripped.to_string());
}
}
None
}
fn looks_like_archive(url: &str) -> bool {
strip_archive_suffixes(url.rsplit('/').next().unwrap_or(url)).is_some()
}
fn is_local_source(raw: &str) -> bool {
!raw.contains("://")
&& !raw.starts_with("git+")
&& !raw.starts_with("hg+")
&& !raw.starts_with("ne+")
}
fn is_extractable_remote_source(raw: &str) -> bool {
if raw.starts_with("ne+") {
return false;
}
if raw.starts_with("git+") || raw.starts_with("hg+") {
return true;
}
let normalized = normalize_source_url(raw);
let base = normalized.split('#').next().unwrap_or(&normalized);
base.ends_with(".git") || normalized.contains('#') || looks_like_archive(base)
}
fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for value in values {
if seen.insert(value.clone()) {
out.push(value);
}
}
out
}
fn shell_fn_suffix(pkg_name: &str) -> String {
let mut out = String::with_capacity(pkg_name.len().max(1));
for ch in pkg_name.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
out.push('_');
}
if out.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) {
out.insert(0, '_');
}
out
}
fn sh_single_quote(value: &str) -> String {
value.replace('\'', "'\"'\"'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_single_package_starbuild_generates_custom_spec_and_build_script() -> Result<()> {
let temp = tempfile::tempdir()?;
let starbuild = temp.path().join("STARBUILD");
fs::write(
&starbuild,
r#"
package_name="meson"
package_version="1.2.3"
description="build system"
license=( "Apache-2.0" )
dependencies=( "python" "ninja" )
build_dependencies=( "git" )
sources=( "helper.sh" "https://github.com/mesonbuild/meson.git#v$package_version" "ne+https://example.com/extra.tar.xz" )
BUILD_ZLIB=True
compile() {
cd meson
python -m build
}
assemble() {
cd meson
python -m installer --destdir="$pkgdir" dist/*.whl
}
"#,
)?;
let converted = convert_starbuild_file(&starbuild, None)?;
let spec_path = temp.path().join("meson.toml");
assert_eq!(converted.output_path, spec_path);
assert!(converted.build_script.is_some());
assert_eq!(
converted.build_script_path,
Some(temp.path().join("build.sh"))
);
assert!(converted.toml.contains("type = \"custom\""));
assert!(
converted
.toml
.contains("url = \"https://github.com/mesonbuild/meson.git#v1.2.3\"")
);
assert!(converted.toml.contains("file = \"helper.sh\""));
assert!(
converted
.toml
.contains("url = \"https://example.com/extra.tar.xz\"")
);
fs::write(&spec_path, &converted.toml)?;
fs::write(
temp.path().join("build.sh"),
converted.build_script.unwrap(),
)?;
let spec = PackageSpec::from_file(&spec_path)?;
assert_eq!(spec.package.name, "meson");
assert_eq!(spec.dependencies.runtime, vec!["python", "ninja"]);
assert_eq!(spec.dependencies.build, vec!["git"]);
assert_eq!(spec.manual_sources.len(), 2);
assert_eq!(spec.source.len(), 1);
Ok(())
}
#[test]
fn convert_multioutput_starbuild_maps_output_metadata() -> Result<()> {
let temp = tempfile::tempdir()?;
let starbuild = temp.path().join("STARBUILD");
fs::write(
&starbuild,
r#"
package_name=( "mesa" "vulkan-intel" )
package_version="25.3.1"
package_descriptions=( "Mesa" "Intel Vulkan" )
license=( "MIT" )
dependencies=( "expat" )
build_dependencies=( "meson" )
sources=( "https://mesa.freedesktop.org/archive/mesa-$package_version.tar.xz" )
dependencies_vulkan-intel=( "vulkan-icd-loader" "mesa" )
gives_vulkan-intel=( "vulkan-driver" )
clashes_vulkan-intel=( "old-vulkan-intel" )
keep_vulkan-intel=( "etc/vulkan/intel.conf" )
compile() {
cd mesa-$package_version
meson setup build
}
assemble_mesa() {
cd mesa-$package_version
mesoni -C build
}
assemble_vulkan-intel() {
starmove usr/lib/libvulkan_intel.so
}
"#,
)?;
let converted = convert_starbuild_file(&starbuild, None)?;
assert!(converted.toml.contains("name = \"vulkan-intel\""));
assert!(
converted
.toml
.contains("keep = [\"etc/vulkan/intel.conf\"]")
);
let build_script = converted.build_script.as_deref().unwrap_or("");
assert!(build_script.contains("depot_install_vulkan_intel()"));
assert!(build_script.contains("haul \"$DEPOT_OUTPUT_NAME\" \"$@\""));
assert!(build_script.contains("packages/vulkan-intel/files"));
let spec_path = temp.path().join("mesa.toml");
fs::write(&spec_path, &converted.toml)?;
fs::write(temp.path().join("build.sh"), build_script)?;
let spec = PackageSpec::from_file(&spec_path)?;
assert_eq!(spec.packages.len(), 1);
assert_eq!(spec.packages[0].name, "vulkan-intel");
assert_eq!(
spec.package_dependencies["vulkan-intel"].runtime,
vec!["vulkan-icd-loader", "mesa"]
);
assert_eq!(
spec.package_alternatives["vulkan-intel"].provides,
vec!["vulkan-driver"]
);
assert_eq!(
spec.package_alternatives["vulkan-intel"].conflicts,
vec!["old-vulkan-intel"]
);
Ok(())
}
}