1use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
26use crate::parsers::utils::{RecursionGuard, capped_iteration_limit, truncate_field};
27use packageurl::PackageUrl;
28use serde_json::{Map as JsonMap, Value as JsonValue};
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use starlark_syntax::syntax::AstModule;
33use starlark_syntax::syntax::ast;
34
35use super::PackageParser;
36use super::metadata::ParserMetadata;
37use super::starlark_parse::{self, RECOVERY_MISSING_SEPARATOR};
38
39type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
40
41struct StarlarkCall<'a> {
42 func: &'a ast::AstExpr,
43 args: &'a StarlarkCallArgs,
44}
45
46pub struct BazelBuildParser;
47
48impl PackageParser for BazelBuildParser {
49 const PACKAGE_TYPE: PackageType = PackageType::Bazel;
50
51 fn metadata() -> Vec<ParserMetadata> {
52 vec![ParserMetadata {
53 description: "Bazel BUILD file",
54 file_patterns: &["**/BUILD"],
55 package_type: "bazel",
56 primary_language: "",
57 documentation_url: Some("https://bazel.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 == "BUILD")
65 }
66
67 fn extract_packages(path: &Path) -> Vec<PackageData> {
68 match parse_bazel_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 Bazel BUILD file {:?}: {}", path, e);
73 vec![fallback_package_data(path)]
74 }
75 }
76 }
77}
78
79fn parse_bazel_build(path: &Path) -> Result<Vec<PackageData>, String> {
81 let content =
82 crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
83 let (module, repaired) = parse_starlark_module("<BUILD>", content)?;
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 if repaired {
92 starlark_parse::mark_parse_recovery(&mut package_data, RECOVERY_MISSING_SEPARATOR);
93 }
94 packages.push(package_data);
95 }
96 }
97
98 Ok(packages)
99}
100
101fn extract_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
103 let call = extract_call(statement)?;
104 let rule_name = extract_call_name(&call)?;
105
106 if !check_rule_name_ending(rule_name) {
107 return None;
108 }
109
110 let name = extract_string_kwarg(&call, "name")?;
111 let licenses = extract_string_list_kwarg(&call, "licenses");
112 let purl = build_bazel_purl(&name, None).map(truncate_field);
113
114 let extra_data = licenses.as_ref().map(|licenses| {
119 let references = licenses
120 .iter()
121 .map(|license| JsonValue::String(truncate_field(license.clone())))
122 .collect();
123 let mut map = JsonMap::new();
124 map.insert("license_files".to_string(), JsonValue::Array(references));
125 map.into_iter().collect()
126 });
127
128 Some(PackageData {
129 package_type: Some(BazelBuildParser::PACKAGE_TYPE),
130 name: Some(truncate_field(name)),
131 extracted_license_statement: licenses.map(|licenses| truncate_field(licenses.join(", "))),
132 datasource_id: Some(DatasourceId::BazelBuild),
133 purl,
134 extra_data,
135 ..Default::default()
136 })
137}
138
139fn check_rule_name_ending(rule_name: &str) -> bool {
141 rule_name.ends_with("binary") || rule_name.ends_with("library")
142}
143
144fn fallback_package_data(path: &Path) -> PackageData {
146 let name = path
147 .parent()
148 .and_then(|p| p.file_name())
149 .and_then(|n| n.to_str())
150 .map(|s| truncate_field(s.to_string()));
151
152 PackageData {
153 package_type: Some(BazelBuildParser::PACKAGE_TYPE),
154 purl: name
155 .as_deref()
156 .and_then(|name| build_bazel_purl(name, None))
157 .map(truncate_field),
158 name,
159 datasource_id: Some(DatasourceId::BazelBuild),
160 ..Default::default()
161 }
162}
163
164pub struct BazelModuleParser;
165
166impl PackageParser for BazelModuleParser {
167 const PACKAGE_TYPE: PackageType = PackageType::Bazel;
168
169 fn metadata() -> Vec<ParserMetadata> {
170 vec![ParserMetadata {
171 description: "Bazel MODULE.bazel file",
172 file_patterns: &["**/MODULE.bazel"],
173 package_type: "bazel",
174 primary_language: "",
175 documentation_url: Some("https://bazel.build/external/module"),
176 }]
177 }
178
179 fn is_match(path: &Path) -> bool {
180 path.file_name()
181 .and_then(|name| name.to_str())
182 .is_some_and(|name| name == "MODULE.bazel")
183 }
184
185 fn extract_packages(path: &Path) -> Vec<PackageData> {
186 match parse_bazel_module(path) {
187 Ok(package) => vec![package],
188 Err(e) => {
189 warn!("Failed to parse Bazel MODULE.bazel {:?}: {}", path, e);
190 vec![default_bazel_module_package_data()]
191 }
192 }
193 }
194}
195
196fn parse_bazel_module(path: &Path) -> Result<PackageData, String> {
197 let content =
198 crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
199 let (module, repaired) = parse_starlark_module("<MODULE.bazel>", content)?;
200
201 let mut package = default_bazel_module_package_data();
202 let mut extra_data = JsonMap::new();
203 let mut dependencies = Vec::new();
204 let mut overrides = Vec::new();
205
206 let statements = top_level_statements(&module);
207 let limit = capped_iteration_limit(statements.len(), "MODULE.bazel top-level statements");
208 for statement in statements.iter().take(limit) {
209 let Some(call) = extract_call(statement) else {
210 continue;
211 };
212
213 let Some(function_name) = extract_call_name(&call) else {
214 continue;
215 };
216
217 match function_name {
218 "module" => {
219 package.name = extract_string_kwarg(&call, "name").map(truncate_field);
220 package.version = extract_string_kwarg(&call, "version").map(truncate_field);
221 package.purl = package
222 .name
223 .as_deref()
224 .and_then(|name| build_bazel_purl(name, package.version.as_deref()))
225 .map(truncate_field);
226
227 if let Some(repo_name) =
228 extract_string_kwarg(&call, "repo_name").map(truncate_field)
229 {
230 extra_data.insert("repo_name".to_string(), JsonValue::String(repo_name));
231 }
232 if let Some(compatibility_level) = extract_int_kwarg(&call, "compatibility_level") {
233 extra_data.insert(
234 "compatibility_level".to_string(),
235 JsonValue::Number(compatibility_level.into()),
236 );
237 }
238 if let Some(bazel_compatibility) = extract_kwarg_json(&call, "bazel_compatibility")
239 {
240 extra_data.insert("bazel_compatibility".to_string(), bazel_compatibility);
241 }
242 }
243 "bazel_dep" => {
244 if let Some(dep) = extract_bazel_dependency(&call) {
245 dependencies.push(dep);
246 }
247 }
248 "archive_override"
249 | "git_override"
250 | "local_path_override"
251 | "single_version_override"
252 | "multiple_version_override" => {
253 overrides.push(extract_override(function_name, &call));
254 }
255 _ => {}
256 }
257 }
258
259 if package.name.is_none() {
260 let mut fallback = default_bazel_module_package_data();
261 if repaired {
262 starlark_parse::mark_parse_recovery(&mut fallback, RECOVERY_MISSING_SEPARATOR);
263 }
264 return Ok(fallback);
265 }
266
267 if !overrides.is_empty() {
268 extra_data.insert("overrides".to_string(), JsonValue::Array(overrides));
269 }
270
271 package.dependencies = dependencies;
272 package.extra_data = (!extra_data.is_empty()).then(|| extra_data.into_iter().collect());
273 if repaired {
274 starlark_parse::mark_parse_recovery(&mut package, RECOVERY_MISSING_SEPARATOR);
275 }
276 Ok(package)
277}
278
279fn parse_starlark_module(filename: &str, content: String) -> Result<(AstModule, bool), String> {
280 starlark_parse::parse_with_repair(filename, content)
281}
282
283fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
284 match &module.statement().node {
285 ast::StmtP::Statements(statements) => statements,
286 _ => std::slice::from_ref(module.statement()),
287 }
288}
289
290fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
291 match &statement.node {
292 ast::StmtP::Expression(expr) => extract_call_expr(expr),
293 ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
294 _ => None,
295 }
296}
297
298fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
299 match &expr.node {
300 ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
301 _ => None,
302 }
303}
304
305fn extract_call_name<'a>(call: &'a StarlarkCall<'_>) -> Option<&'a str> {
306 match &call.func.node {
307 ast::ExprP::Identifier(identifier) => Some(identifier.node.ident.as_str()),
308 _ => None,
309 }
310}
311
312fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
313 call.args
314 .args
315 .iter()
316 .find_map(|argument| match &argument.node {
317 ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
318 _ => None,
319 })
320}
321
322fn extract_string_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
323 extract_named_kwarg(call, key).and_then(expr_as_string)
324}
325
326fn extract_string_list_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
327 let expr = extract_named_kwarg(call, key)?;
328 let items = match &expr.node {
329 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
330 _ => return None,
331 };
332 let limit = capped_iteration_limit(items.len(), "bazel string-list kwarg items");
333 let values: Vec<_> = items
334 .iter()
335 .take(limit)
336 .filter_map(expr_as_string)
337 .collect();
338 (!values.is_empty()).then_some(values)
339}
340
341fn extract_bool_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<bool> {
342 extract_named_kwarg(call, key).and_then(expr_as_bool)
343}
344
345fn extract_int_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<i64> {
346 extract_named_kwarg(call, key).and_then(expr_as_i64)
347}
348
349fn extract_kwarg_json(call: &StarlarkCall<'_>, key: &str) -> Option<JsonValue> {
350 extract_named_kwarg(call, key)
351 .and_then(|expr| expr_to_json(expr, &mut RecursionGuard::depth_only()))
352}
353
354fn extract_bazel_dependency(call: &StarlarkCall<'_>) -> Option<Dependency> {
355 let name = extract_string_kwarg(call, "name").map(truncate_field)?;
356 let version = extract_string_kwarg(call, "version").map(truncate_field);
357 let is_dev = extract_bool_kwarg(call, "dev_dependency").unwrap_or(false);
358 let mut extra_data = JsonMap::new();
359
360 let fields = ["repo_name", "max_compatibility_level", "registry"];
361 let limit = capped_iteration_limit(fields.len(), "bazel dependency extra fields");
362 for field in fields.iter().take(limit) {
363 if let Some(value) = extract_kwarg_json(call, field) {
364 extra_data.insert(field.to_string(), value);
365 }
366 }
367
368 Some(Dependency {
369 purl: build_bazel_purl(&name, version.as_deref()).map(truncate_field),
370 extracted_requirement: version.clone(),
371 scope: Some(if is_dev { "dev" } else { "dependencies" }.to_string()),
372 is_runtime: Some(!is_dev),
373 is_optional: Some(is_dev),
374 is_pinned: Some(version.is_some()),
375 is_direct: Some(true),
376 resolved_package: None,
377 extra_data: (!extra_data.is_empty()).then(|| extra_data.into_iter().collect()),
378 })
379}
380
381fn extract_override(kind: &str, call: &StarlarkCall<'_>) -> JsonValue {
382 let mut override_map = JsonMap::new();
383 override_map.insert("kind".to_string(), JsonValue::String(kind.to_string()));
384 let limit = capped_iteration_limit(call.args.args.len(), "bazel override arguments");
385 for argument in call.args.args.iter().take(limit) {
386 if let ast::ArgumentP::Named(name, value) = &argument.node
387 && let Some(value) = expr_to_json(value, &mut RecursionGuard::depth_only())
388 {
389 override_map.insert(name.node.clone(), value);
390 }
391 }
392 JsonValue::Object(override_map)
393}
394
395fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
396 match &expr.node {
397 ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
398 _ => None,
399 }
400}
401
402fn expr_as_bool(expr: &ast::AstExpr) -> Option<bool> {
403 match &expr.node {
404 ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
405 "True" => Some(true),
406 "False" => Some(false),
407 _ => None,
408 },
409 _ => None,
410 }
411}
412
413fn expr_as_i64(expr: &ast::AstExpr) -> Option<i64> {
414 match &expr.node {
415 ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value.node.to_string().parse().ok(),
416 _ => None,
417 }
418}
419
420fn expr_to_json(expr: &ast::AstExpr, guard: &mut RecursionGuard<()>) -> Option<JsonValue> {
421 if guard.descend() {
422 return None;
423 }
424 let result = match &expr.node {
425 ast::ExprP::Literal(ast::AstLiteral::String(value)) => {
426 Some(JsonValue::String(value.node.clone()))
427 }
428 ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value
429 .node
430 .to_string()
431 .parse::<i64>()
432 .ok()
433 .map(|value| JsonValue::Number(value.into()))
434 .or_else(|| Some(JsonValue::String(value.node.to_string()))),
435 ast::ExprP::Literal(ast::AstLiteral::Float(value)) => {
436 serde_json::Number::from_f64(value.node).map(JsonValue::Number)
437 }
438 ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
439 "True" => Some(JsonValue::Bool(true)),
440 "False" => Some(JsonValue::Bool(false)),
441 "None" => Some(JsonValue::Null),
442 _ => None,
443 },
444 ast::ExprP::List(elts) | ast::ExprP::Tuple(elts) => {
445 let limit = capped_iteration_limit(elts.len(), "bazel list/tuple elements");
446 Some(JsonValue::Array(
447 elts.iter()
448 .take(limit)
449 .filter_map(|e| expr_to_json(e, guard))
450 .collect(),
451 ))
452 }
453 ast::ExprP::Dict(items) => {
454 let mut map = JsonMap::new();
455 let limit = capped_iteration_limit(items.len(), "bazel dict entries");
456 for (key, value) in items.iter().take(limit) {
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}