1use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
25use crate::parsers::utils::{RecursionGuard, capped_iteration_limit, truncate_field};
26use packageurl::PackageUrl;
27use serde_json::{Map as JsonMap, Value as JsonValue};
28use std::path::Path;
29
30use crate::parser_warn as warn;
31use starlark_syntax::syntax::ast;
32use starlark_syntax::syntax::{AstModule, Dialect};
33
34use super::PackageParser;
35use super::metadata::ParserMetadata;
36
37type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
38const SCANCODE_SIMPLE_TOP_LEVEL_KEY: &str = "scancode_simple_top_level";
39
40struct StarlarkCall<'a> {
41 func: &'a ast::AstExpr,
42 args: &'a StarlarkCallArgs,
43}
44
45pub struct BazelBuildParser;
46
47impl PackageParser for BazelBuildParser {
48 const PACKAGE_TYPE: PackageType = PackageType::Bazel;
49
50 fn metadata() -> Vec<ParserMetadata> {
51 vec![ParserMetadata {
52 description: "Bazel BUILD file",
53 file_patterns: &["**/BUILD"],
54 package_type: "bazel",
55 primary_language: "",
56 documentation_url: Some("https://bazel.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 == "BUILD")
64 }
65
66 fn extract_packages(path: &Path) -> Vec<PackageData> {
67 match parse_bazel_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 Bazel BUILD file {:?}: {}", path, e);
72 vec![fallback_package_data(path)]
73 }
74 }
75 }
76}
77
78fn parse_bazel_build(path: &Path) -> Result<Vec<PackageData>, String> {
80 let content =
81 crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
82 let module = parse_starlark_module("<BUILD>", content)?;
83 let scancode_simple_top_level = is_scancode_simple_top_level_module(&module);
84
85 let mut packages = Vec::new();
86
87 let statements = top_level_statements(&module);
88 let limit = capped_iteration_limit(statements.len(), "bazel BUILD top-level statements");
89 for statement in statements.iter().take(limit) {
90 if let Some(mut package_data) = extract_package_from_statement(statement) {
91 set_scancode_simple_top_level(&mut package_data, scancode_simple_top_level);
92 packages.push(package_data);
93 }
94 }
95
96 Ok(packages)
97}
98
99fn extract_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
101 let call = extract_call(statement)?;
102 let rule_name = extract_call_name(&call)?;
103
104 if !check_rule_name_ending(rule_name) {
105 return None;
106 }
107
108 let name = extract_string_kwarg(&call, "name")?;
109 let licenses = extract_string_list_kwarg(&call, "licenses");
110 let purl = build_bazel_purl(&name, None).map(truncate_field);
111
112 Some(PackageData {
113 package_type: Some(BazelBuildParser::PACKAGE_TYPE),
114 name: Some(truncate_field(name)),
115 extracted_license_statement: licenses.map(|licenses| truncate_field(licenses.join(", "))),
116 datasource_id: Some(DatasourceId::BazelBuild),
117 purl,
118 ..Default::default()
119 })
120}
121
122fn check_rule_name_ending(rule_name: &str) -> bool {
124 rule_name.ends_with("binary") || rule_name.ends_with("library")
125}
126
127fn fallback_package_data(path: &Path) -> PackageData {
129 let name = path
130 .parent()
131 .and_then(|p| p.file_name())
132 .and_then(|n| n.to_str())
133 .map(|s| truncate_field(s.to_string()));
134
135 PackageData {
136 package_type: Some(BazelBuildParser::PACKAGE_TYPE),
137 purl: name
138 .as_deref()
139 .and_then(|name| build_bazel_purl(name, None))
140 .map(truncate_field),
141 name,
142 datasource_id: Some(DatasourceId::BazelBuild),
143 ..Default::default()
144 }
145}
146
147fn set_scancode_simple_top_level(package_data: &mut PackageData, enabled: bool) {
148 let extra_data = package_data.extra_data.get_or_insert_with(Default::default);
149 extra_data.insert(
150 SCANCODE_SIMPLE_TOP_LEVEL_KEY.to_string(),
151 JsonValue::Bool(enabled),
152 );
153}
154
155fn is_scancode_simple_top_level_module(module: &AstModule) -> bool {
156 top_level_statements(module)
157 .iter()
158 .all(is_scancode_simple_top_level_statement)
159}
160
161fn is_scancode_simple_top_level_statement(statement: &ast::AstStmt) -> bool {
162 match &statement.node {
163 ast::StmtP::Expression(expr) => {
164 matches!(&expr.node, ast::ExprP::Call(func, _) if matches!(&func.node, ast::ExprP::Identifier(_)))
165 }
166 _ => true,
167 }
168}
169
170pub struct BazelModuleParser;
171
172impl PackageParser for BazelModuleParser {
173 const PACKAGE_TYPE: PackageType = PackageType::Bazel;
174
175 fn metadata() -> Vec<ParserMetadata> {
176 vec![ParserMetadata {
177 description: "Bazel MODULE.bazel file",
178 file_patterns: &["**/MODULE.bazel"],
179 package_type: "bazel",
180 primary_language: "",
181 documentation_url: Some("https://bazel.build/external/module"),
182 }]
183 }
184
185 fn is_match(path: &Path) -> bool {
186 path.file_name()
187 .and_then(|name| name.to_str())
188 .is_some_and(|name| name == "MODULE.bazel")
189 }
190
191 fn extract_packages(path: &Path) -> Vec<PackageData> {
192 match parse_bazel_module(path) {
193 Ok(package) => vec![package],
194 Err(e) => {
195 warn!("Failed to parse Bazel MODULE.bazel {:?}: {}", path, e);
196 vec![default_bazel_module_package_data()]
197 }
198 }
199 }
200}
201
202fn parse_bazel_module(path: &Path) -> Result<PackageData, String> {
203 let content =
204 crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
205 let module = parse_starlark_module("<MODULE.bazel>", content)?;
206
207 let mut package = default_bazel_module_package_data();
208 let mut extra_data = JsonMap::new();
209 let mut dependencies = Vec::new();
210 let mut overrides = Vec::new();
211
212 let statements = top_level_statements(&module);
213 let limit = capped_iteration_limit(statements.len(), "MODULE.bazel top-level statements");
214 for statement in statements.iter().take(limit) {
215 let Some(call) = extract_call(statement) else {
216 continue;
217 };
218
219 let Some(function_name) = extract_call_name(&call) else {
220 continue;
221 };
222
223 match function_name {
224 "module" => {
225 package.name = extract_string_kwarg(&call, "name").map(truncate_field);
226 package.version = extract_string_kwarg(&call, "version").map(truncate_field);
227 package.purl = package
228 .name
229 .as_deref()
230 .and_then(|name| build_bazel_purl(name, package.version.as_deref()))
231 .map(truncate_field);
232
233 if let Some(repo_name) =
234 extract_string_kwarg(&call, "repo_name").map(truncate_field)
235 {
236 extra_data.insert("repo_name".to_string(), JsonValue::String(repo_name));
237 }
238 if let Some(compatibility_level) = extract_int_kwarg(&call, "compatibility_level") {
239 extra_data.insert(
240 "compatibility_level".to_string(),
241 JsonValue::Number(compatibility_level.into()),
242 );
243 }
244 if let Some(bazel_compatibility) = extract_kwarg_json(&call, "bazel_compatibility")
245 {
246 extra_data.insert("bazel_compatibility".to_string(), bazel_compatibility);
247 }
248 }
249 "bazel_dep" => {
250 if let Some(dep) = extract_bazel_dependency(&call) {
251 dependencies.push(dep);
252 }
253 }
254 "archive_override"
255 | "git_override"
256 | "local_path_override"
257 | "single_version_override"
258 | "multiple_version_override" => {
259 overrides.push(extract_override(function_name, &call));
260 }
261 _ => {}
262 }
263 }
264
265 if package.name.is_none() {
266 return Ok(default_bazel_module_package_data());
267 }
268
269 if !overrides.is_empty() {
270 extra_data.insert("overrides".to_string(), JsonValue::Array(overrides));
271 }
272
273 package.dependencies = dependencies;
274 package.extra_data = (!extra_data.is_empty()).then(|| extra_data.into_iter().collect());
275 Ok(package)
276}
277
278fn parse_starlark_module(filename: &str, content: String) -> Result<AstModule, String> {
279 let dialect = Dialect {
280 enable_top_level_stmt: true,
281 ..Dialect::Standard
282 };
283 AstModule::parse(filename, content, &dialect).map_err(|error| error.to_string())
284}
285
286fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
287 match &module.statement().node {
288 ast::StmtP::Statements(statements) => statements,
289 _ => std::slice::from_ref(module.statement()),
290 }
291}
292
293fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
294 match &statement.node {
295 ast::StmtP::Expression(expr) => extract_call_expr(expr),
296 ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
297 _ => None,
298 }
299}
300
301fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
302 match &expr.node {
303 ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
304 _ => None,
305 }
306}
307
308fn extract_call_name<'a>(call: &'a StarlarkCall<'_>) -> Option<&'a str> {
309 match &call.func.node {
310 ast::ExprP::Identifier(identifier) => Some(identifier.node.ident.as_str()),
311 _ => None,
312 }
313}
314
315fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
316 call.args
317 .args
318 .iter()
319 .find_map(|argument| match &argument.node {
320 ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
321 _ => None,
322 })
323}
324
325fn extract_string_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
326 extract_named_kwarg(call, key).and_then(expr_as_string)
327}
328
329fn extract_string_list_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
330 let expr = extract_named_kwarg(call, key)?;
331 let items = match &expr.node {
332 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
333 _ => return None,
334 };
335 let limit = capped_iteration_limit(items.len(), "bazel string-list kwarg items");
336 let values: Vec<_> = items
337 .iter()
338 .take(limit)
339 .filter_map(expr_as_string)
340 .collect();
341 (!values.is_empty()).then_some(values)
342}
343
344fn extract_bool_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<bool> {
345 extract_named_kwarg(call, key).and_then(expr_as_bool)
346}
347
348fn extract_int_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<i64> {
349 extract_named_kwarg(call, key).and_then(expr_as_i64)
350}
351
352fn extract_kwarg_json(call: &StarlarkCall<'_>, key: &str) -> Option<JsonValue> {
353 extract_named_kwarg(call, key)
354 .and_then(|expr| expr_to_json(expr, &mut RecursionGuard::depth_only()))
355}
356
357fn extract_bazel_dependency(call: &StarlarkCall<'_>) -> Option<Dependency> {
358 let name = extract_string_kwarg(call, "name").map(truncate_field)?;
359 let version = extract_string_kwarg(call, "version").map(truncate_field);
360 let is_dev = extract_bool_kwarg(call, "dev_dependency").unwrap_or(false);
361 let mut extra_data = JsonMap::new();
362
363 let fields = ["repo_name", "max_compatibility_level", "registry"];
364 let limit = capped_iteration_limit(fields.len(), "bazel dependency extra fields");
365 for field in fields.iter().take(limit) {
366 if let Some(value) = extract_kwarg_json(call, field) {
367 extra_data.insert(field.to_string(), value);
368 }
369 }
370
371 Some(Dependency {
372 purl: build_bazel_purl(&name, version.as_deref()).map(truncate_field),
373 extracted_requirement: version.clone(),
374 scope: Some(if is_dev { "dev" } else { "dependencies" }.to_string()),
375 is_runtime: Some(!is_dev),
376 is_optional: Some(is_dev),
377 is_pinned: Some(version.is_some()),
378 is_direct: Some(true),
379 resolved_package: None,
380 extra_data: (!extra_data.is_empty()).then(|| extra_data.into_iter().collect()),
381 })
382}
383
384fn extract_override(kind: &str, call: &StarlarkCall<'_>) -> JsonValue {
385 let mut override_map = JsonMap::new();
386 override_map.insert("kind".to_string(), JsonValue::String(kind.to_string()));
387 let limit = capped_iteration_limit(call.args.args.len(), "bazel override arguments");
388 for argument in call.args.args.iter().take(limit) {
389 if let ast::ArgumentP::Named(name, value) = &argument.node
390 && let Some(value) = expr_to_json(value, &mut RecursionGuard::depth_only())
391 {
392 override_map.insert(name.node.clone(), value);
393 }
394 }
395 JsonValue::Object(override_map)
396}
397
398fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
399 match &expr.node {
400 ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
401 _ => None,
402 }
403}
404
405fn expr_as_bool(expr: &ast::AstExpr) -> Option<bool> {
406 match &expr.node {
407 ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
408 "True" => Some(true),
409 "False" => Some(false),
410 _ => None,
411 },
412 _ => None,
413 }
414}
415
416fn expr_as_i64(expr: &ast::AstExpr) -> Option<i64> {
417 match &expr.node {
418 ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value.node.to_string().parse().ok(),
419 _ => None,
420 }
421}
422
423fn expr_to_json(expr: &ast::AstExpr, guard: &mut RecursionGuard<()>) -> Option<JsonValue> {
424 if guard.descend() {
425 return None;
426 }
427 let result = match &expr.node {
428 ast::ExprP::Literal(ast::AstLiteral::String(value)) => {
429 Some(JsonValue::String(value.node.clone()))
430 }
431 ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value
432 .node
433 .to_string()
434 .parse::<i64>()
435 .ok()
436 .map(|value| JsonValue::Number(value.into()))
437 .or_else(|| Some(JsonValue::String(value.node.to_string()))),
438 ast::ExprP::Literal(ast::AstLiteral::Float(value)) => {
439 serde_json::Number::from_f64(value.node).map(JsonValue::Number)
440 }
441 ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
442 "True" => Some(JsonValue::Bool(true)),
443 "False" => Some(JsonValue::Bool(false)),
444 "None" => Some(JsonValue::Null),
445 _ => None,
446 },
447 ast::ExprP::List(elts) | ast::ExprP::Tuple(elts) => {
448 let limit = capped_iteration_limit(elts.len(), "bazel list/tuple elements");
449 Some(JsonValue::Array(
450 elts.iter()
451 .take(limit)
452 .filter_map(|e| expr_to_json(e, guard))
453 .collect(),
454 ))
455 }
456 ast::ExprP::Dict(items) => {
457 let mut map = JsonMap::new();
458 let limit = capped_iteration_limit(items.len(), "bazel dict entries");
459 for (key, value) in items.iter().take(limit) {
460 let Some(key) = expr_as_string(key) else {
461 continue;
462 };
463 if let Some(value) = expr_to_json(value, guard) {
464 map.insert(key, value);
465 }
466 }
467 Some(JsonValue::Object(map))
468 }
469 _ => None,
470 };
471 guard.ascend();
472 result
473}
474
475fn build_bazel_purl(name: &str, version: Option<&str>) -> Option<String> {
476 let mut purl = PackageUrl::new("bazel", name).ok()?;
477 if let Some(version) = version.filter(|value| !value.trim().is_empty()) {
478 purl.with_version(version).ok()?;
479 }
480 Some(purl.to_string())
481}
482
483fn default_bazel_module_package_data() -> PackageData {
484 PackageData {
485 package_type: Some(BazelModuleParser::PACKAGE_TYPE),
486 datasource_id: Some(DatasourceId::BazelModule),
487 ..Default::default()
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::models::PackageType;
495 use std::path::PathBuf;
496
497 #[test]
498 fn test_is_match() {
499 assert!(BazelBuildParser::is_match(&PathBuf::from("BUILD")));
500 assert!(BazelBuildParser::is_match(&PathBuf::from("path/to/BUILD")));
501 assert!(!BazelBuildParser::is_match(&PathBuf::from("BUILD.bazel")));
502 assert!(!BazelBuildParser::is_match(&PathBuf::from("build")));
503 assert!(!BazelBuildParser::is_match(&PathBuf::from("BUCK")));
504 }
505
506 #[test]
507 fn test_check_rule_name_ending() {
508 assert!(check_rule_name_ending("cc_binary"));
509 assert!(check_rule_name_ending("cc_library"));
510 assert!(check_rule_name_ending("java_binary"));
511 assert!(check_rule_name_ending("py_library"));
512 assert!(!check_rule_name_ending("filegroup"));
513 assert!(!check_rule_name_ending("load"));
514 assert!(!check_rule_name_ending("cc_test"));
515 }
516
517 #[test]
518 fn test_fallback_package_data() {
519 let path = PathBuf::from("/path/to/myproject/BUILD");
520 let pkg = fallback_package_data(&path);
521 assert_eq!(pkg.package_type, Some(PackageType::Bazel));
522 assert_eq!(pkg.name, Some("myproject".to_string()));
523 assert_eq!(pkg.purl.as_deref(), Some("pkg:bazel/myproject"));
524 }
525
526 #[test]
527 fn test_scancode_simple_top_level_allows_direct_calls() {
528 let module = parse_starlark_module(
529 "<BUILD>",
530 "cc_library(name = \"demo\")\npy_binary(name = \"tool\")\n".to_string(),
531 )
532 .expect("parse BUILD");
533
534 assert!(is_scancode_simple_top_level_module(&module));
535 }
536
537 #[test]
538 fn test_scancode_simple_top_level_rejects_attribute_calls() {
539 let module = parse_starlark_module(
540 "<BUILD>",
541 "selects.config_setting_group(name = \"demo\")\ncc_library(name = \"demo\")\n"
542 .to_string(),
543 )
544 .expect("parse BUILD");
545
546 assert!(!is_scancode_simple_top_level_module(&module));
547 }
548
549 #[test]
550 fn test_scancode_simple_top_level_rejects_non_call_expressions() {
551 let module =
552 parse_starlark_module("<BUILD>", "[(cc_binary(name = \"demo\"),)]\n".to_string())
553 .expect("parse BUILD");
554
555 assert!(!is_scancode_simple_top_level_module(&module));
556 }
557}