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