use std::collections::HashMap;
use std::path::Path;
use crate::parser_warn as warn;
use packageurl::PackageUrl;
use rustpython_parser::{Parse, ast};
use crate::models::{DatasourceId, PackageData, PackageType, Party};
use super::PackageParser;
pub struct BuckBuildParser;
impl PackageParser for BuckBuildParser {
const PACKAGE_TYPE: PackageType = PackageType::Buck;
fn is_match(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "BUCK")
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
match parse_buck_build(path) {
Ok(packages) if !packages.is_empty() => packages,
Ok(_) => vec![fallback_package_data(path)],
Err(e) => {
warn!("Failed to parse Buck BUCK file {:?}: {}", path, e);
vec![fallback_package_data(path)]
}
}
}
}
pub struct BuckMetadataBzlParser;
impl PackageParser for BuckMetadataBzlParser {
const PACKAGE_TYPE: PackageType = PackageType::Buck;
fn is_match(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "METADATA.bzl")
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
vec![match parse_metadata_bzl(path) {
Ok(pkg) => pkg,
Err(e) => {
warn!("Failed to parse Buck METADATA.bzl {:?}: {}", path, e);
PackageData {
package_type: Some(Self::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::BuckMetadata),
..Default::default()
}
}
}]
}
}
fn parse_buck_build(path: &Path) -> Result<Vec<PackageData>, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let module = ast::Suite::parse(&content, "<BUCK>")
.map_err(|e| format!("Failed to parse Starlark: {}", e))?;
let mut packages = Vec::new();
for statement in &module {
if let Some(package_data) = extract_from_statement(statement) {
packages.push(package_data);
}
}
Ok(packages)
}
fn parse_metadata_bzl(path: &Path) -> Result<PackageData, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let module = ast::Suite::parse(&content, "<METADATA.bzl>")
.map_err(|e| format!("Failed to parse Starlark: {}", e))?;
for statement in &module {
if let ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) = statement {
for target in targets {
if let ast::Expr::Name(ast::ExprName { id, .. }) = target
&& id.as_str() == "METADATA"
{
if let ast::Expr::Dict(dict) = value.as_ref() {
return Ok(extract_metadata_dict(dict));
}
}
}
}
}
Ok(PackageData {
package_type: Some(BuckMetadataBzlParser::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::BuckMetadata),
..Default::default()
})
}
fn extract_metadata_dict(dict: &ast::ExprDict) -> PackageData {
let mut fields: HashMap<String, MetadataValue> = HashMap::new();
for (key, value) in dict.keys.iter().zip(dict.values.iter()) {
let key_name = match key {
Some(ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
if let ast::Constant::Str(s) = value {
s.clone()
} else {
continue;
}
}
_ => continue,
};
let metadata_value = match value {
ast::Expr::Constant(ast::ExprConstant {
value: ast::Constant::Str(s),
..
}) => MetadataValue::String(s.clone()),
ast::Expr::Constant(_) => continue,
ast::Expr::List(ast::ExprList { elts, .. }) => {
let mut list_values = Vec::new();
for elt in elts {
if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt
&& let ast::Constant::Str(s) = value
{
list_values.push(s.clone());
}
}
MetadataValue::List(list_values)
}
_ => continue,
};
fields.insert(key_name, metadata_value);
}
build_package_from_metadata(fields)
}
enum MetadataValue {
String(String),
List(Vec<String>),
}
fn split_buck_license_values(values: &[String]) -> (Vec<String>, Vec<String>) {
let mut statements = Vec::new();
let mut references = Vec::new();
for value in values {
if is_probable_local_license_reference(value) {
references.push(value.clone());
} else {
statements.push(value.clone());
}
}
(statements, references)
}
fn is_probable_local_license_reference(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let lower = trimmed.to_ascii_lowercase();
lower.contains('/')
|| lower.contains('\\')
|| lower.starts_with("license")
|| lower.starts_with("licence")
|| lower.starts_with("copying")
|| lower.starts_with("notice")
|| lower.starts_with("copyright")
|| lower.ends_with(".txt")
|| lower.ends_with(".md")
|| lower.ends_with(".rst")
|| lower.ends_with(".html")
}
fn insert_license_reference_extra_data(
extra_data: &mut HashMap<String, serde_json::Value>,
references: &[String],
) {
match references {
[] => {}
[reference] => {
extra_data.insert(
"license_file".to_string(),
serde_json::Value::String(reference.clone()),
);
}
_ => {
extra_data.insert(
"license_files".to_string(),
serde_json::Value::Array(
references
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
}
}
}
fn build_package_from_metadata(fields: HashMap<String, MetadataValue>) -> PackageData {
let mut pkg = PackageData {
package_type: Some(BuckMetadataBzlParser::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::BuckMetadata),
..Default::default()
};
let mut license_references = Vec::new();
if let Some(MetadataValue::String(s)) = fields.get("name") {
pkg.name = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("version") {
pkg.version = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("upstream_type") {
pkg.package_type = s.parse::<PackageType>().ok();
} else if let Some(MetadataValue::String(s)) = fields.get("package_type") {
pkg.package_type = s.parse::<PackageType>().ok();
}
if let Some(MetadataValue::List(licenses)) = fields.get("licenses") {
let (license_statements, references) = split_buck_license_values(licenses);
license_references = references;
let extracted_license_statement = if !license_statements.is_empty() {
Some(license_statements.join(", "))
} else if !license_references.is_empty() {
Some(license_references.join(", "))
} else {
None
};
pkg.extracted_license_statement = extracted_license_statement;
} else if let Some(MetadataValue::String(s)) = fields.get("license_expression") {
pkg.extracted_license_statement = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("upstream_address") {
pkg.homepage_url = Some(s.clone());
} else if let Some(MetadataValue::String(s)) = fields.get("homepage_url") {
pkg.homepage_url = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("download_url") {
pkg.download_url = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("vcs_url") {
pkg.vcs_url = Some(s.clone());
}
if let Some(MetadataValue::String(s)) = fields.get("download_archive_sha1") {
pkg.sha1 = Some(s.clone());
}
if let Some(MetadataValue::List(maintainers)) = fields.get("maintainers") {
pkg.parties = maintainers
.iter()
.map(|name| Party {
r#type: Some("organization".to_string()),
name: Some(name.clone()),
role: Some("maintainer".to_string()),
email: None,
url: None,
organization: None,
organization_url: None,
timezone: None,
})
.collect();
}
let mut extra_data = HashMap::new();
if let Some(MetadataValue::String(s)) = fields.get("vcs_commit_hash") {
extra_data.insert(
"vcs_commit_hash".to_string(),
serde_json::Value::String(s.clone()),
);
}
if let Some(MetadataValue::String(s)) = fields.get("upstream_hash") {
extra_data.insert(
"upstream_hash".to_string(),
serde_json::Value::String(s.clone()),
);
}
insert_license_reference_extra_data(&mut extra_data, &license_references);
if !extra_data.is_empty() {
pkg.extra_data = Some(extra_data);
}
if let Some(MetadataValue::String(purl_str)) = fields.get("package_url")
&& let Ok(purl) = purl_str.parse::<PackageUrl>()
{
pkg.package_type = purl.ty().parse::<PackageType>().ok();
if let Some(ns) = purl.namespace() {
pkg.namespace = Some(ns.to_string());
}
pkg.name = Some(purl.name().to_string());
if let Some(ver) = purl.version() {
pkg.version = Some(ver.to_string());
}
if !purl.qualifiers().is_empty() {
let quals: HashMap<String, String> = purl
.qualifiers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
pkg.qualifiers = Some(quals);
}
if let Some(sp) = purl.subpath() {
pkg.subpath = Some(sp.to_string());
}
}
pkg
}
fn extract_from_statement(statement: &ast::Stmt) -> Option<PackageData> {
match statement {
ast::Stmt::Expr(ast::StmtExpr { value, .. }) => {
if let ast::Expr::Call(call) = value.as_ref() {
return extract_from_call(call);
}
}
ast::Stmt::Assign(ast::StmtAssign { value, .. }) => {
if let ast::Expr::Call(call) = value.as_ref() {
return extract_from_call(call);
}
}
_ => {}
}
None
}
fn extract_from_call(call: &ast::ExprCall) -> Option<PackageData> {
let rule_name = match call.func.as_ref() {
ast::Expr::Name(ast::ExprName { id, .. }) => id.as_str(),
_ => return None,
};
if !check_rule_name_ending(rule_name) {
return None;
}
let mut name: Option<String> = None;
let mut licenses: Option<Vec<String>> = None;
for keyword in &call.keywords {
let arg_name = keyword.arg.as_ref()?.as_str();
match arg_name {
"name" => {
if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = &keyword.value
&& let ast::Constant::Str(s) = value
{
name = Some(s.clone());
}
}
"licenses" => {
if let ast::Expr::List(ast::ExprList { elts, .. }) = &keyword.value {
let mut license_list = Vec::new();
for elt in elts {
if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt
&& let ast::Constant::Str(s) = value
{
license_list.push(s.clone());
}
}
if !license_list.is_empty() {
licenses = Some(license_list);
}
}
}
_ => {}
}
}
let package_name = name?;
let (license_statements, license_references) = licenses
.as_deref()
.map(split_buck_license_values)
.unwrap_or_default();
let extracted_license_statement = if !license_statements.is_empty() {
Some(license_statements.join(", "))
} else if !license_references.is_empty() {
Some(license_references.join(", "))
} else {
None
};
let mut extra_data = HashMap::new();
insert_license_reference_extra_data(&mut extra_data, &license_references);
Some(PackageData {
package_type: Some(BuckBuildParser::PACKAGE_TYPE),
name: Some(package_name),
extracted_license_statement,
extra_data: (!extra_data.is_empty()).then_some(extra_data),
datasource_id: Some(DatasourceId::BuckFile),
..Default::default()
})
}
fn check_rule_name_ending(rule_name: &str) -> bool {
rule_name.ends_with("binary") || rule_name.ends_with("library")
}
fn fallback_package_data(path: &Path) -> PackageData {
let name = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|s| s.to_string());
PackageData {
package_type: Some(BuckBuildParser::PACKAGE_TYPE),
name,
datasource_id: Some(DatasourceId::BuckFile),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_buck_build_is_match() {
assert!(BuckBuildParser::is_match(&PathBuf::from("BUCK")));
assert!(BuckBuildParser::is_match(&PathBuf::from("path/to/BUCK")));
assert!(!BuckBuildParser::is_match(&PathBuf::from("BUILD")));
assert!(!BuckBuildParser::is_match(&PathBuf::from("buck")));
}
#[test]
fn test_metadata_bzl_is_match() {
assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
"METADATA.bzl"
)));
assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
"path/to/METADATA.bzl"
)));
assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from(
"metadata.bzl"
)));
assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from("METADATA")));
}
#[test]
fn test_check_rule_name_ending() {
assert!(check_rule_name_ending("android_binary"));
assert!(check_rule_name_ending("android_library"));
assert!(check_rule_name_ending("java_binary"));
assert!(!check_rule_name_ending("filegroup"));
}
}
crate::register_parser!(
"Buck build file and METADATA.bzl",
&["**/BUCK", "**/METADATA.bzl"],
"buck",
"",
Some("https://buck.build/"),
);