1use std::collections::HashMap;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
28use packageurl::PackageUrl;
29use starlark_syntax::syntax::ast;
30use starlark_syntax::syntax::{AstModule, Dialect};
31
32use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType, Sha1Digest};
33
34use super::PackageParser;
35use super::metadata::ParserMetadata;
36
37type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
38
39struct StarlarkCall<'a> {
40 func: &'a ast::AstExpr,
41 args: &'a StarlarkCallArgs,
42}
43
44pub struct BuckBuildParser;
46
47impl PackageParser for BuckBuildParser {
48 const PACKAGE_TYPE: PackageType = PackageType::Buck;
49
50 fn metadata() -> Vec<ParserMetadata> {
51 vec![ParserMetadata {
52 description: "Buck build file and METADATA.bzl",
53 file_patterns: &["**/BUCK", "**/METADATA.bzl"],
54 package_type: "buck",
55 primary_language: "",
56 documentation_url: Some("https://buck.build/"),
57 }]
58 }
59
60 fn is_match(path: &Path) -> bool {
61 path.file_name()
62 .and_then(|name| name.to_str())
63 .is_some_and(|name| name == "BUCK")
64 }
65
66 fn extract_packages(path: &Path) -> Vec<PackageData> {
67 match parse_buck_build(path) {
68 Ok(packages) if !packages.is_empty() => packages,
69 Ok(_) => vec![fallback_package_data(path)],
70 Err(e) => {
71 warn!("Failed to parse Buck BUCK file {:?}: {}", path, e);
72 vec![fallback_package_data(path)]
73 }
74 }
75 }
76}
77
78pub struct BuckMetadataBzlParser;
80
81impl PackageParser for BuckMetadataBzlParser {
82 const PACKAGE_TYPE: PackageType = PackageType::Buck;
83
84 fn is_match(path: &Path) -> bool {
85 path.file_name()
86 .and_then(|name| name.to_str())
87 .is_some_and(|name| name == "METADATA.bzl")
88 }
89
90 fn extract_packages(path: &Path) -> Vec<PackageData> {
91 vec![match parse_metadata_bzl(path) {
92 Ok(pkg) => pkg,
93 Err(e) => {
94 warn!("Failed to parse Buck METADATA.bzl {:?}: {}", path, e);
95 PackageData {
96 datasource_id: Some(DatasourceId::BuckMetadata),
97 ..Default::default()
98 }
99 }
100 }]
101 }
102}
103
104fn parse_buck_build(path: &Path) -> Result<Vec<PackageData>, String> {
106 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
107 let module = parse_starlark_module("<BUCK>", content)?;
108
109 let mut packages = Vec::new();
110
111 for statement in top_level_statements(&module)
112 .iter()
113 .take(MAX_ITERATION_COUNT)
114 {
115 if let Some(package_data) = extract_build_package_from_statement(statement) {
116 packages.push(package_data);
117 }
118 }
119
120 Ok(packages)
121}
122
123fn parse_metadata_bzl(path: &Path) -> Result<PackageData, String> {
125 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
126 let module = parse_starlark_module("<METADATA.bzl>", content)?;
127
128 for statement in top_level_statements(&module)
129 .iter()
130 .take(MAX_ITERATION_COUNT)
131 {
132 if let Some(dict) = extract_metadata_assignment_dict(statement) {
133 return Ok(extract_metadata_dict(dict));
134 }
135 }
136
137 Ok(PackageData {
139 datasource_id: Some(DatasourceId::BuckMetadata),
140 ..Default::default()
141 })
142}
143
144fn parse_starlark_module(filename: &str, content: String) -> Result<AstModule, String> {
145 let content = preprocess_starlark_content(&content);
146 let dialect = Dialect {
147 enable_top_level_stmt: true,
148 ..Dialect::Standard
149 };
150 AstModule::parse(filename, content, &dialect).map_err(|error| error.to_string())
151}
152
153fn preprocess_starlark_content(content: &str) -> String {
154 let mut normalized = String::with_capacity(content.len());
155 let mut pending_oss_disable_indent: Option<String> = None;
156
157 for raw_line in content.lines() {
158 let trimmed_start = raw_line.trim_start();
159 let indent_len = raw_line.len() - trimmed_start.len();
160 let indent = &raw_line[..indent_len];
161
162 if trimmed_start.starts_with('#') && trimmed_start.contains("@oss-disable") {
163 pending_oss_disable_indent = Some(indent.to_string());
164 continue;
165 }
166
167 if let Some(marker_index) = raw_line.find("# @oss-enable") {
168 let code = raw_line[..marker_index].trim_end();
169 if !code.is_empty() {
170 if let Some(disabled_indent) = pending_oss_disable_indent.take() {
171 normalized.push_str(&disabled_indent);
172 normalized.push_str(code.trim_start());
173 } else {
174 normalized.push_str(code);
175 }
176 normalized.push('\n');
177 }
178 continue;
179 }
180
181 pending_oss_disable_indent = None;
182 normalized.push_str(raw_line);
183 normalized.push('\n');
184 }
185
186 if !content.ends_with('\n') && normalized.ends_with('\n') {
187 normalized.pop();
188 }
189
190 normalized
191}
192
193fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
194 match &module.statement().node {
195 ast::StmtP::Statements(statements) => statements,
196 _ => std::slice::from_ref(module.statement()),
197 }
198}
199
200fn extract_metadata_assignment_dict(
201 statement: &ast::AstStmt,
202) -> Option<&[(ast::AstExpr, ast::AstExpr)]> {
203 let ast::StmtP::Assign(assign) = &statement.node else {
204 return None;
205 };
206 let ast::AssignTargetP::Identifier(target) = &assign.lhs.node else {
207 return None;
208 };
209 if target.node.ident != "METADATA" {
210 return None;
211 }
212 match &assign.rhs.node {
213 ast::ExprP::Dict(items) => Some(items.as_slice()),
214 _ => None,
215 }
216}
217
218fn extract_metadata_dict(dict: &[(ast::AstExpr, ast::AstExpr)]) -> PackageData {
220 let mut fields: HashMap<String, MetadataValue> = HashMap::new();
221
222 for (key, value) in dict.iter().take(MAX_ITERATION_COUNT) {
223 let Some(key_name) = expr_as_string(key) else {
224 continue;
225 };
226 let Some(metadata_value) = metadata_value_from_expr(value) else {
227 continue;
228 };
229
230 fields.insert(key_name, metadata_value);
231 }
232
233 build_package_from_metadata(fields)
234}
235
236fn get_metadata_string(fields: &HashMap<String, MetadataValue>, keys: &[&str]) -> Option<String> {
237 keys.iter().find_map(|key| match fields.get(*key) {
238 Some(MetadataValue::String(value)) => Some(value.clone()),
239 _ => None,
240 })
241}
242
243fn get_metadata_list(
244 fields: &HashMap<String, MetadataValue>,
245 keys: &[&str],
246) -> Option<Vec<String>> {
247 keys.iter().find_map(|key| match fields.get(*key) {
248 Some(MetadataValue::List(values)) => Some(values.clone()),
249 _ => None,
250 })
251}
252
253enum MetadataValue {
255 String(String),
256 List(Vec<String>),
257}
258
259fn split_buck_license_values(values: &[String]) -> (Vec<String>, Vec<String>) {
260 let mut statements = Vec::new();
261 let mut references = Vec::new();
262
263 for value in values {
264 if is_probable_local_license_reference(value) {
265 references.push(value.clone());
266 } else {
267 statements.push(value.clone());
268 }
269 }
270
271 (statements, references)
272}
273
274fn is_probable_local_license_reference(value: &str) -> bool {
275 let trimmed = value.trim();
276 if trimmed.is_empty() {
277 return false;
278 }
279
280 let lower = trimmed.to_ascii_lowercase();
281 lower.contains('/')
282 || lower.contains('\\')
283 || lower.starts_with("license")
284 || lower.starts_with("licence")
285 || lower.starts_with("copying")
286 || lower.starts_with("notice")
287 || lower.starts_with("copyright")
288 || lower.ends_with(".txt")
289 || lower.ends_with(".md")
290 || lower.ends_with(".rst")
291 || lower.ends_with(".html")
292}
293
294fn insert_license_reference_extra_data(
295 extra_data: &mut HashMap<String, serde_json::Value>,
296 references: &[String],
297) {
298 match references {
299 [] => {}
300 [reference] => {
301 extra_data.insert(
302 "license_file".to_string(),
303 serde_json::Value::String(reference.clone()),
304 );
305 }
306 _ => {
307 extra_data.insert(
308 "license_files".to_string(),
309 serde_json::Value::Array(
310 references
311 .iter()
312 .cloned()
313 .map(serde_json::Value::String)
314 .collect(),
315 ),
316 );
317 }
318 }
319}
320
321fn build_package_from_metadata(fields: HashMap<String, MetadataValue>) -> PackageData {
323 let mut pkg = PackageData {
324 datasource_id: Some(DatasourceId::BuckMetadata),
325 ..Default::default()
326 };
327 let mut license_references = Vec::new();
328
329 if let Some(name) = get_metadata_string(&fields, &["name"]) {
331 pkg.name = Some(truncate_field(name));
332 }
333
334 if let Some(version) = get_metadata_string(&fields, &["version"]) {
336 pkg.version = Some(truncate_field(version));
337 }
338
339 if let Some(namespace) = get_metadata_string(&fields, &["namespace"]) {
341 pkg.namespace = Some(truncate_field(namespace));
342 }
343
344 if let Some(ecosystem) = get_metadata_string(&fields, &["ecosystem", "type", "package_type"])
347 && let Ok(package_type) = ecosystem.parse::<PackageType>()
348 {
349 pkg.package_type = Some(package_type);
350 }
351
352 if let Some(licenses) = get_metadata_list(&fields, &["licenses"]) {
354 let (license_statements, references) = split_buck_license_values(&licenses);
355 license_references = references;
356 let extracted_license_statement = if !license_statements.is_empty() {
357 Some(license_statements.join(", "))
358 } else if !license_references.is_empty() {
359 Some(license_references.join(", "))
360 } else {
361 None
362 };
363 pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
364 } else if let Some(license_expression) = get_metadata_string(&fields, &["license_expression"]) {
365 pkg.extracted_license_statement = Some(truncate_field(license_expression));
366 }
367
368 if let Some(copyright) = get_metadata_list(&fields, &["copyrights"]) {
369 if !copyright.is_empty() {
370 pkg.copyright = Some(truncate_field(copyright.join("\n")));
371 }
372 } else if let Some(copyright) = get_metadata_string(&fields, &["copyright"]) {
373 pkg.copyright = Some(truncate_field(copyright));
374 }
375
376 if let Some(homepage_url) = get_metadata_string(
378 &fields,
379 &["upstream_address", "upstream_url", "homepage_url"],
380 ) {
381 pkg.homepage_url = Some(truncate_field(homepage_url));
382 }
383
384 if let Some(download_url) = get_metadata_string(&fields, &["download_url"]) {
386 pkg.download_url = Some(truncate_field(download_url));
387 }
388
389 if let Some(vcs_url) = get_metadata_string(&fields, &["vcs_url"]) {
391 pkg.vcs_url = Some(truncate_field(vcs_url));
392 }
393
394 if let Some(sha1) = get_metadata_string(&fields, &["download_archive_sha1"]) {
396 pkg.sha1 = Sha1Digest::from_hex(&sha1).ok();
397 }
398
399 if let Some(maintainers) = get_metadata_list(&fields, &["maintainers"]) {
401 pkg.parties.extend(maintainers.iter().map(|name| Party {
402 r#type: Some(PartyType::Organization),
403 name: Some(name.clone()),
404 role: Some("maintainer".to_string()),
405 email: None,
406 url: None,
407 organization: None,
408 organization_url: None,
409 timezone: None,
410 }));
411 }
412
413 if let Some(vendor) = get_metadata_string(&fields, &["vendor", "publisher"]) {
414 pkg.parties.push(Party {
415 r#type: None,
416 name: Some(vendor),
417 role: Some("publisher".to_string()),
418 email: None,
419 url: None,
420 organization: None,
421 organization_url: None,
422 timezone: None,
423 });
424 }
425
426 let mut extra_data = HashMap::new();
428 if let Some(vcs_commit_hash) = get_metadata_string(&fields, &["vcs_commit_hash"]) {
429 extra_data.insert(
430 "vcs_commit_hash".to_string(),
431 serde_json::Value::String(vcs_commit_hash),
432 );
433 }
434 if let Some(upstream_hash) =
435 get_metadata_string(&fields, &["upstream_hash", "upstream_commit_hash"])
436 {
437 extra_data.insert(
438 "upstream_hash".to_string(),
439 serde_json::Value::String(upstream_hash),
440 );
441 }
442 if let Some(upstream_branch) = get_metadata_string(&fields, &["upstream_branch"]) {
443 extra_data.insert(
444 "upstream_branch".to_string(),
445 serde_json::Value::String(upstream_branch),
446 );
447 }
448 insert_license_reference_extra_data(&mut extra_data, &license_references);
449 if !extra_data.is_empty() {
450 pkg.extra_data = Some(extra_data);
451 }
452
453 if let Some(purl_str) = get_metadata_string(&fields, &["package_url"])
455 && let Ok(purl) = purl_str.parse::<PackageUrl>()
456 {
457 pkg.purl = Some(truncate_field(purl.to_string()));
458
459 if let Ok(package_type) = purl.ty().parse::<PackageType>() {
460 pkg.package_type = Some(package_type);
461 }
462 if let Some(ns) = purl.namespace() {
463 pkg.namespace = Some(truncate_field(ns.to_string()));
464 }
465 pkg.name = Some(truncate_field(purl.name().to_string()));
466 if let Some(ver) = purl.version() {
467 pkg.version = Some(truncate_field(ver.to_string()));
468 }
469 if !purl.qualifiers().is_empty() {
471 let quals: HashMap<String, String> = purl
472 .qualifiers()
473 .iter()
474 .map(|(k, v)| (k.to_string(), v.to_string()))
475 .collect();
476 pkg.qualifiers = Some(quals);
477 }
478 if let Some(sp) = purl.subpath() {
480 pkg.subpath = Some(sp.to_string());
481 }
482 }
483
484 pkg
485}
486
487fn metadata_value_from_expr(expr: &ast::AstExpr) -> Option<MetadataValue> {
488 if let Some(string) = expr_as_string(expr) {
489 return Some(MetadataValue::String(string));
490 }
491
492 let items = match &expr.node {
493 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
494 _ => return None,
495 };
496 let values: Vec<_> = items
497 .iter()
498 .take(MAX_ITERATION_COUNT)
499 .filter_map(expr_as_string)
500 .collect();
501 (!values.is_empty()).then_some(MetadataValue::List(values))
502}
503
504fn extract_build_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
506 let call = extract_call(statement)?;
507 let rule_name = match &call.func.node {
508 ast::ExprP::Identifier(identifier) => identifier.node.ident.as_str(),
509 _ => return None,
510 };
511
512 if !check_rule_name_ending(rule_name) {
513 return None;
514 }
515
516 let name = extract_named_kwarg_string(&call, "name");
517 let licenses = extract_named_kwarg_string_list(&call, "licenses");
518
519 let package_name = name?;
520 let (license_statements, license_references) = licenses
521 .as_deref()
522 .map(split_buck_license_values)
523 .unwrap_or_default();
524 let extracted_license_statement = if !license_statements.is_empty() {
525 Some(truncate_field(license_statements.join(", ")))
526 } else if !license_references.is_empty() {
527 Some(truncate_field(license_references.join(", ")))
528 } else {
529 None
530 };
531 let mut extra_data = HashMap::new();
532 insert_license_reference_extra_data(&mut extra_data, &license_references);
533
534 Some(PackageData {
535 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
536 name: Some(truncate_field(package_name)),
537 extracted_license_statement,
538 extra_data: (!extra_data.is_empty()).then_some(extra_data),
539 datasource_id: Some(DatasourceId::BuckFile),
540 ..Default::default()
541 })
542}
543
544fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
545 match &statement.node {
546 ast::StmtP::Expression(expr) => extract_call_expr(expr),
547 ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
548 _ => None,
549 }
550}
551
552fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
553 match &expr.node {
554 ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
555 _ => None,
556 }
557}
558
559fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
560 call.args
561 .args
562 .iter()
563 .find_map(|argument| match &argument.node {
564 ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
565 _ => None,
566 })
567}
568
569fn extract_named_kwarg_string(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
570 extract_named_kwarg(call, key).and_then(expr_as_string)
571}
572
573fn extract_named_kwarg_string_list(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
574 let expr = extract_named_kwarg(call, key)?;
575 let items = match &expr.node {
576 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
577 _ => return None,
578 };
579 let values: Vec<_> = items
580 .iter()
581 .take(MAX_ITERATION_COUNT)
582 .filter_map(expr_as_string)
583 .collect();
584 (!values.is_empty()).then_some(values)
585}
586
587fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
588 match &expr.node {
589 ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
590 _ => None,
591 }
592}
593
594fn check_rule_name_ending(rule_name: &str) -> bool {
596 rule_name.ends_with("binary") || rule_name.ends_with("library")
597}
598
599fn fallback_package_data(path: &Path) -> PackageData {
601 let name = path
602 .parent()
603 .and_then(|p| p.file_name())
604 .and_then(|n| n.to_str())
605 .map(|s| s.to_string());
606
607 PackageData {
608 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
609 name,
610 datasource_id: Some(DatasourceId::BuckFile),
611 ..Default::default()
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use std::path::PathBuf;
619
620 #[test]
621 fn test_buck_build_is_match() {
622 assert!(BuckBuildParser::is_match(&PathBuf::from("BUCK")));
623 assert!(BuckBuildParser::is_match(&PathBuf::from("path/to/BUCK")));
624 assert!(!BuckBuildParser::is_match(&PathBuf::from("BUILD")));
625 assert!(!BuckBuildParser::is_match(&PathBuf::from("buck")));
626 }
627
628 #[test]
629 fn test_metadata_bzl_is_match() {
630 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
631 "METADATA.bzl"
632 )));
633 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
634 "path/to/METADATA.bzl"
635 )));
636 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from(
637 "metadata.bzl"
638 )));
639 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from("METADATA")));
640 }
641
642 #[test]
643 fn test_check_rule_name_ending() {
644 assert!(check_rule_name_ending("android_binary"));
645 assert!(check_rule_name_ending("android_library"));
646 assert!(check_rule_name_ending("java_binary"));
647 assert!(!check_rule_name_ending("filegroup"));
648 }
649
650 #[test]
651 fn test_preprocess_starlark_content_handles_oss_guarded_alternatives() {
652 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
653prelude = native
654
655# @oss-disable: rust_unittest(
656 rust_test( # @oss-enable
657 name = "test",
658 )
659
660platform_utils = None # @oss-enable
661"#;
662
663 let normalized = preprocess_starlark_content(content);
664
665 assert!(!normalized.contains("@oss-disable"));
666 assert!(!normalized.contains("@oss-enable"));
667 assert!(normalized.contains("rust_test("));
668 assert!(normalized.contains("platform_utils = None"));
669 assert!(!normalized.contains(" rust_test("));
670 }
671
672 #[test]
673 fn test_parse_buck_build_with_oss_guarded_rule() {
674 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_library.bzl", "rust_library")
675# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
676
677oncall("build_infra")
678
679rust_library(
680 name = "library",
681 srcs = ["src/lib.rs"],
682)
683
684# @oss-disable: rust_unittest(
685 rust_test( # @oss-enable
686 name = "test",
687 srcs = ["tests/test.rs"],
688)
689"#;
690
691 let temp_dir = tempfile::tempdir().unwrap();
692 let buck_path = temp_dir.path().join("BUCK");
693 std::fs::write(&buck_path, content).unwrap();
694
695 let packages = parse_buck_build(&buck_path).expect("BUCK file should parse");
696
697 assert_eq!(packages.len(), 1);
698 assert_eq!(packages[0].package_type, Some(PackageType::Buck));
699 assert_eq!(packages[0].name.as_deref(), Some("library"));
700 }
701}