use std::collections::HashSet;
use crate::deps::parse::parse_dep_spec;
#[allow(clippy::case_sensitive_file_extension_comparisons)]
#[must_use]
pub fn parse_pkgbuild_deps(pkgbuild: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
let lines: Vec<&str> = pkgbuild.lines().collect();
let mut index = 0;
let mut fields = DependencyFields::default();
while index < lines.len() {
let line = lines[index].trim();
index += 1;
let Some((key, value)) = dependency_declaration(line) else {
continue;
};
let values = parse_declared_array(value, &lines, &mut index);
fields.extend(key, values);
}
fields.into_parts()
}
#[derive(Clone, Copy)]
enum DependencyField {
Runtime,
Make,
Check,
Optional,
}
#[derive(Default)]
struct DependencyFields {
depends: (Vec<String>, HashSet<String>),
makedepends: (Vec<String>, HashSet<String>),
checkdepends: (Vec<String>, HashSet<String>),
optdepends: (Vec<String>, HashSet<String>),
}
impl DependencyFields {
fn extend(&mut self, field: DependencyField, values: Vec<String>) {
let destination = match field {
DependencyField::Runtime => &mut self.depends,
DependencyField::Make => &mut self.makedepends,
DependencyField::Check => &mut self.checkdepends,
DependencyField::Optional => &mut self.optdepends,
};
for value in values {
let value = value.trim();
if !value.is_empty() && is_valid_dependency(value) && destination.1.insert(value.into())
{
destination.0.push(value.into());
}
}
}
fn into_parts(self) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
(
self.depends.0,
self.makedepends.0,
self.checkdepends.0,
self.optdepends.0,
)
}
}
fn dependency_declaration(line: &str) -> Option<(DependencyField, &str)> {
if line.is_empty() || line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
let field = match key.trim().strip_suffix('+').unwrap_or_else(|| key.trim()) {
"depends" => DependencyField::Runtime,
"makedepends" => DependencyField::Make,
"checkdepends" => DependencyField::Check,
"optdepends" => DependencyField::Optional,
_ => return None,
};
value
.trim()
.starts_with('(')
.then_some((field, value.trim()))
}
fn parse_declared_array(value: &str, lines: &[&str], index: &mut usize) -> Vec<String> {
if let Some(closing) = find_matching_closing_paren(value) {
return parse_array_content(&value[1..closing]);
}
let mut parts = Vec::new();
let first = value[1..].trim();
if !first.is_empty() && !first.starts_with('#') {
parts.push(first.to_string());
}
while *index < lines.len() {
let line = lines[*index].trim();
*index += 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == ")" {
break;
}
if let Some(closing) = line.find(')') {
let content = line[..closing].trim();
if !content.is_empty() {
parts.push(content.to_string());
}
break;
}
parts.push(line.to_string());
}
parse_array_content(&parts.join(" "))
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
#[must_use]
pub fn parse_pkgbuild_conflicts(pkgbuild: &str) -> Vec<String> {
let mut conflicts = Vec::new();
let mut seen = HashSet::new();
let lines: Vec<&str> = pkgbuild.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
i += 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let base_key = key.strip_suffix('+').map_or(key, |stripped| stripped);
if base_key != "conflicts" {
continue;
}
if value.starts_with('(') {
let conflict_deps = find_matching_closing_paren(value).map_or_else(
|| {
let mut array_lines = Vec::new();
while i < lines.len() {
let next_line = lines[i].trim();
i += 1;
if next_line.is_empty() || next_line.starts_with('#') {
continue;
}
if next_line == ")" {
break;
}
if let Some(paren_pos) = next_line.find(')') {
let content_before_paren = &next_line[..paren_pos].trim();
if !content_before_paren.is_empty() {
array_lines.push((*content_before_paren).to_string());
}
break;
}
array_lines.push(next_line.to_string());
}
let array_content = array_lines
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
parse_array_content(&array_content)
},
|closing_paren_pos| {
let array_content = &value[1..closing_paren_pos];
parse_array_content(array_content)
},
);
let filtered_conflicts: Vec<String> = conflict_deps
.into_iter()
.filter_map(|conflict| {
let conflict_trimmed = conflict.trim();
if conflict_trimmed.is_empty() {
return None;
}
if is_valid_dependency(conflict_trimmed) {
let spec = parse_dep_spec(conflict_trimmed);
if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
Some(spec.name)
} else {
None
}
} else {
None
}
})
.collect();
conflicts.extend(filtered_conflicts);
}
}
}
conflicts
}
fn find_matching_closing_paren(s: &str) -> Option<usize> {
let mut depth = 0;
let mut in_quotes = false;
let mut quote_char = '\0';
for (pos, ch) in s.char_indices() {
match ch {
'\'' | '"' => {
if !in_quotes {
in_quotes = true;
quote_char = ch;
} else if ch == quote_char {
in_quotes = false;
quote_char = '\0';
}
}
'(' if !in_quotes => {
depth += 1;
}
')' if !in_quotes => {
depth -= 1;
if depth == 0 {
return Some(pos);
}
}
_ => {}
}
}
None
}
fn parse_array_content(content: &str) -> Vec<String> {
let mut deps = Vec::new();
let mut in_quotes = false;
let mut quote_char = '\0';
let mut current = String::new();
for ch in content.chars() {
match ch {
'\'' | '"' => {
if !in_quotes {
in_quotes = true;
quote_char = ch;
} else if ch == quote_char {
if !current.is_empty() {
deps.push(current.clone());
current.clear();
}
in_quotes = false;
quote_char = '\0';
} else {
current.push(ch);
}
}
_ if in_quotes => {
current.push(ch);
}
ch if ch.is_whitespace() => {
if !current.is_empty() {
deps.push(current.clone());
current.clear();
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
deps.push(current);
}
deps
}
fn is_valid_dependency(dep: &str) -> bool {
let dep_lower = dep.to_lowercase();
if std::path::Path::new(&dep_lower)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|| dep_lower.contains(".so.")
|| dep_lower.contains(".so=")
{
return false;
}
if dep.ends_with(')') {
if dep.contains(">=") || dep.contains("<=") || dep.contains("==") {
return false;
}
return false;
}
let Some(first_char) = dep.chars().next() else {
return false;
};
if !first_char.is_alphanumeric() && first_char != '_' {
return false;
}
if dep.len() < 2 {
return false;
}
let has_valid_chars = dep
.chars()
.any(|c| c.is_alphanumeric() || c == '-' || c == '_');
if !has_valid_chars {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pkgbuild_deps_basic() {
let pkgbuild = r"
pkgname=test-package
pkgver=1.0.0
depends=('foo' 'bar>=1.2')
makedepends=('make' 'gcc')
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar>=1.2".to_string()));
assert_eq!(makedepends.len(), 2);
assert!(makedepends.contains(&"make".to_string()));
assert!(makedepends.contains(&"gcc".to_string()));
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_append() {
let pkgbuild = r#"
pkgname=test-package
pkgver=1.0.0
package() {
depends+=(foo bar)
cd $_pkgname
make DESTDIR="$pkgdir" PREFIX=/usr install
}
"#;
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar".to_string()));
assert_eq!(makedepends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_unquoted() {
let pkgbuild = r"
pkgname=test-package
depends=(foo bar libcairo.so libdbus-1.so)
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar".to_string()));
assert_eq!(makedepends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_multiline() {
let pkgbuild = r"
pkgname=test-package
depends=(
'foo'
'bar>=1.2'
'baz'
)
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 3);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar>=1.2".to_string()));
assert!(depends.contains(&"baz".to_string()));
assert_eq!(makedepends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_makedepends_append() {
let pkgbuild = r"
pkgname=test-package
build() {
makedepends+=(cmake ninja)
cmake -B build
}
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(makedepends.len(), 2);
assert!(makedepends.contains(&"cmake".to_string()));
assert!(makedepends.contains(&"ninja".to_string()));
assert_eq!(depends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_jujutsu_git_scenario() {
let pkgbuild = r"
pkgname=jujutsu-git
pkgver=0.1.0
pkgdesc=Git-compatible VCS that is both simple and powerful
url=https://github.com/martinvonz/jj
license=(Apache-2.0)
arch=(i686 x86_64 armv6h armv7h)
depends=(
glibc
libc.so
libm.so
)
makedepends=(
libgit2
libgit2.so
libssh2
libssh2.so)
openssh
git)
cargo
checkdepends=()
optdepends=()
source=($pkgname::git+$url)
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 1);
assert!(depends.contains(&"glibc".to_string()));
assert_eq!(makedepends.len(), 2);
assert!(makedepends.contains(&"libgit2".to_string()));
assert!(makedepends.contains(&"libssh2".to_string()));
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_ignore_other_fields() {
let pkgbuild = r"
pkgname=test-package
pkgver=1.0.0
pkgdesc=Test package description
url=https://example.com
license=(MIT)
arch=(x86_64)
source=($pkgname-$pkgver.tar.gz)
depends=(foo bar)
makedepends=(make)
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar".to_string()));
assert_eq!(makedepends.len(), 1);
assert!(makedepends.contains(&"make".to_string()));
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_filter_invalid_names() {
let pkgbuild = r"
depends=('valid-package' 'invalid)' '=invalid' 'a' 'valid>=1.0')
";
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"valid-package".to_string()));
assert!(depends.contains(&"valid>=1.0".to_string()));
assert_eq!(makedepends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_deduplicates() {
let pkgbuild = r"
depends=('foo' 'bar' 'foo' 'baz' 'bar')
";
let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar".to_string()));
assert!(depends.contains(&"baz".to_string()));
}
#[test]
fn test_parse_pkgbuild_deps_empty() {
let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps("");
assert_eq!(depends.len(), 0);
assert_eq!(makedepends.len(), 0);
assert_eq!(checkdepends.len(), 0);
assert_eq!(optdepends.len(), 0);
}
#[test]
fn test_parse_pkgbuild_deps_comments_and_blank_lines() {
let pkgbuild = r"
# This is a comment
pkgname=test-package
depends=(foo bar)
# Another comment
makedepends=(make)
";
let (depends, makedepends, _, _) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar".to_string()));
assert_eq!(makedepends.len(), 1);
assert!(makedepends.contains(&"make".to_string()));
}
#[test]
fn test_parse_pkgbuild_deps_mixed_quoted_unquoted() {
let pkgbuild = r"
depends=('quoted' unquoted 'another-quoted' unquoted2)
";
let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(depends.len(), 4);
assert!(depends.contains(&"quoted".to_string()));
assert!(depends.contains(&"unquoted".to_string()));
assert!(depends.contains(&"another-quoted".to_string()));
assert!(depends.contains(&"unquoted2".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_basic() {
let pkgbuild = r"
pkgname=jujutsu-git
pkgver=0.1.0
conflicts=('jujutsu')
";
let conflicts = parse_pkgbuild_conflicts(pkgbuild);
assert_eq!(conflicts.len(), 1);
assert!(conflicts.contains(&"jujutsu".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_multiline() {
let pkgbuild = r"
pkgname=pacsea-git
pkgver=0.1.0
conflicts=(
'pacsea'
'pacsea-bin'
)
";
let conflicts = parse_pkgbuild_conflicts(pkgbuild);
assert_eq!(conflicts.len(), 2);
assert!(conflicts.contains(&"pacsea".to_string()));
assert!(conflicts.contains(&"pacsea-bin".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_with_versions() {
let pkgbuild = r"
pkgname=test-package
conflicts=('old-pkg<2.0' 'new-pkg>=3.0')
";
let conflicts = parse_pkgbuild_conflicts(pkgbuild);
assert_eq!(conflicts.len(), 2);
assert!(conflicts.contains(&"old-pkg".to_string()));
assert!(conflicts.contains(&"new-pkg".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_filter_so() {
let pkgbuild = r"
pkgname=test-package
conflicts=('foo' 'libcairo.so' 'bar' 'libdbus-1.so=1-64')
";
let conflicts = parse_pkgbuild_conflicts(pkgbuild);
assert_eq!(conflicts.len(), 2);
assert!(conflicts.contains(&"foo".to_string()));
assert!(conflicts.contains(&"bar".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_deduplicates() {
let pkgbuild = r"
conflicts=('pkg1' 'pkg2' 'pkg1' 'pkg3')
";
let conflicts = parse_pkgbuild_conflicts(pkgbuild);
assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
assert!(conflicts.contains(&"pkg1".to_string()));
assert!(conflicts.contains(&"pkg2".to_string()));
assert!(conflicts.contains(&"pkg3".to_string()));
}
#[test]
fn test_parse_pkgbuild_conflicts_empty() {
let conflicts = parse_pkgbuild_conflicts("");
assert!(conflicts.is_empty());
}
#[test]
fn test_multiline_array_keeps_first_line_entry() {
let pkgbuild = "optdepends=('cups: printing support'\n 'foo: extra feature')\ndepends=('glibc'\n 'gcc-libs'\n)";
let (depends, _makedepends, _checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
assert_eq!(
optdepends,
vec![
"cups: printing support".to_string(),
"foo: extra feature".to_string()
]
);
assert_eq!(depends, vec!["glibc".to_string(), "gcc-libs".to_string()]);
}
}