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