1use super::*;
2
3pub struct Scope<'a> {
4 current_recipe: Option<String>,
5 document: &'a Document,
6 globals: HashSet<String>,
7 locals: HashSet<String>,
8 pub recipe_identifier_usage: HashMap<String, HashSet<String>>,
9 pub unresolved_identifiers: Vec<(String, lsp::Range)>,
10 pub variable_usage: HashMap<String, bool>,
11}
12
13impl<'a> Scope<'a> {
14 pub fn analyze(context: &RuleContext<'a>) -> Self {
15 let mut scope = Self::new(context);
16
17 let Some(tree) = context.tree() else {
18 return scope;
19 };
20
21 let root = tree.root_node();
22
23 for node in root.find_all("recipe") {
24 scope.walk_recipe(node);
25 }
26
27 for node in root.find_all("function_definition") {
28 scope.walk_function(node);
29 }
30
31 for identifier in root.find_all("expression > value > identifier") {
32 if identifier.has_any_parent(&["function_definition", "recipe"]) {
33 continue;
34 }
35
36 scope.record(identifier);
37 }
38
39 scope
40 }
41
42 fn new(context: &RuleContext<'a>) -> Self {
43 Self {
44 current_recipe: None,
45 document: context.document(),
46 globals: context
47 .variable_and_builtin_names()
48 .iter()
49 .cloned()
50 .chain(context.user_function_names().iter().cloned())
51 .collect(),
52 locals: HashSet::new(),
53 recipe_identifier_usage: context
54 .recipes()
55 .iter()
56 .map(|recipe| (recipe.name.value.clone(), HashSet::new()))
57 .collect(),
58 unresolved_identifiers: Vec::new(),
59 variable_usage: context
60 .variables()
61 .iter()
62 .map(|variable| (variable.name.value.clone(), false))
63 .collect(),
64 }
65 }
66
67 fn record(&mut self, identifier: Node<'_>) {
73 if identifier.is_missing() {
74 return;
75 }
76
77 let name = self.document.get_node_text(&identifier);
78
79 if let Some(recipe_name) = &self.current_recipe {
80 self
81 .recipe_identifier_usage
82 .entry(recipe_name.clone())
83 .or_default()
84 .insert(name.clone());
85 }
86
87 if self.locals.contains(&name) {
88 return;
89 }
90
91 if let Some(used) = self.variable_usage.get_mut(&name) {
92 *used = true;
93 return;
94 }
95
96 if self.globals.contains(&name) {
97 return;
98 }
99
100 self
101 .unresolved_identifiers
102 .push((name, identifier.get_range(self.document)));
103 }
104
105 fn walk_function(&mut self, function_node: Node<'_>) {
111 self.locals.clear();
112
113 if let Some(parameters_node) =
114 function_node.child_by_field_name("parameters")
115 {
116 for parameter_node in parameters_node.find_all("^identifier") {
117 self
118 .locals
119 .insert(self.document.get_node_text(¶meter_node));
120 }
121 }
122
123 if let Some(body_node) = function_node.child_by_field_name("body") {
124 for identifier in body_node.find_all("value > identifier") {
125 self.record(identifier);
126 }
127 }
128 }
129
130 fn walk_recipe(&mut self, recipe_node: Node<'_>) {
138 let Some(name_node) = recipe_node.find("recipe_header > identifier") else {
139 return;
140 };
141
142 self.current_recipe = Some(self.document.get_node_text(&name_node));
143 self.locals.clear();
144
145 if let Some(parameters_node) =
146 recipe_node.find("recipe_header > parameters")
147 {
148 for parameter_node in
149 parameters_node.find_all("^parameter, ^variadic_parameter")
150 {
151 let parameter_node = if parameter_node.kind() == "variadic_parameter" {
152 parameter_node.find("parameter")
153 } else {
154 Some(parameter_node)
155 };
156
157 let Some(parameter_node) = parameter_node else {
158 continue;
159 };
160
161 if let Some(default_node) =
162 parameter_node.child_by_field_name("default")
163 {
164 for identifier in default_node
165 .find_all("^identifier, expression > value > identifier")
166 {
167 self.record(identifier);
168 }
169 }
170
171 if let Some(name_node) = parameter_node.child_by_field_name("name") {
172 self.locals.insert(self.document.get_node_text(&name_node));
173 }
174 }
175 }
176
177 for identifier in recipe_node.find_all("expression > value > identifier") {
178 if identifier.has_any_parent(&["parameter", "variadic_parameter"]) {
179 continue;
180 }
181
182 self.record(identifier);
183 }
184
185 self.current_recipe = None;
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use {super::*, indoc::indoc, pretty_assertions::assert_eq};
192
193 struct Test {
194 document: Document,
195 recipe_usage: Vec<(&'static str, Vec<&'static str>)>,
196 unresolved: Vec<&'static str>,
197 unused: Vec<&'static str>,
198 used: Vec<&'static str>,
199 }
200
201 impl Test {
202 fn new(content: &str) -> Self {
203 Self {
204 document: Document::from(content),
205 recipe_usage: Vec::new(),
206 unresolved: Vec::new(),
207 unused: Vec::new(),
208 used: Vec::new(),
209 }
210 }
211
212 fn recipe_usage(
213 self,
214 recipe: &'static str,
215 names: &[&'static str],
216 ) -> Self {
217 Self {
218 recipe_usage: self
219 .recipe_usage
220 .into_iter()
221 .chain(once((recipe, names.to_vec())))
222 .collect(),
223 ..self
224 }
225 }
226
227 fn run(self) {
228 let scope = Scope::analyze(&RuleContext::new(&self.document));
229
230 let mut actual_unresolved = scope
231 .unresolved_identifiers
232 .iter()
233 .map(|(name, _)| name.as_str())
234 .collect::<Vec<_>>();
235
236 let mut expected_unresolved = self.unresolved.clone();
237
238 actual_unresolved.sort_unstable();
239
240 expected_unresolved.sort_unstable();
241
242 assert_eq!(
243 actual_unresolved, expected_unresolved,
244 "unresolved mismatch"
245 );
246
247 let mut actual_used = scope
248 .variable_usage
249 .iter()
250 .filter(|(_, used)| **used)
251 .map(|(name, _)| name.as_str())
252 .collect::<Vec<_>>();
253
254 actual_used.sort_unstable();
255
256 let mut expected_used = self.used.clone();
257
258 expected_used.sort_unstable();
259
260 assert_eq!(actual_used, expected_used, "used variables mismatch");
261
262 let mut actual_unused = scope
263 .variable_usage
264 .iter()
265 .filter(|(_, used)| !**used)
266 .map(|(name, _)| name.as_str())
267 .collect::<Vec<_>>();
268
269 actual_unused.sort_unstable();
270
271 let mut expected_unused = self.unused.clone();
272
273 expected_unused.sort_unstable();
274
275 assert_eq!(actual_unused, expected_unused, "unused variables mismatch");
276
277 for (recipe, expected_names) in &self.recipe_usage {
278 let mut actual_names = scope
279 .recipe_identifier_usage
280 .get(*recipe)
281 .map(|set| set.iter().map(String::as_str).collect::<Vec<_>>())
282 .unwrap_or_default();
283
284 actual_names.sort_unstable();
285
286 let mut expected = expected_names.clone();
287
288 expected.sort_unstable();
289
290 assert_eq!(actual_names, expected, "recipe `{recipe}` usage mismatch");
291 }
292 }
293
294 fn unresolved(self, names: &[&'static str]) -> Self {
295 Self {
296 unresolved: names.to_vec(),
297 ..self
298 }
299 }
300
301 fn unused(self, names: &[&'static str]) -> Self {
302 Self {
303 unused: names.to_vec(),
304 ..self
305 }
306 }
307
308 fn used(self, names: &[&'static str]) -> Self {
309 Self {
310 used: names.to_vec(),
311 ..self
312 }
313 }
314 }
315
316 #[test]
317 fn empty_justfile() {
318 Test::new("").run();
319 }
320
321 #[test]
322 fn variable_defined_and_unused() {
323 Test::new("foo := 'bar'\n").unused(&["foo"]).run();
324 }
325
326 #[test]
327 fn variable_used_in_recipe_body() {
328 Test::new(indoc! {
329 "
330 foo := 'bar'
331
332 baz:
333 echo {{foo}}
334 "
335 })
336 .used(&["foo"])
337 .run();
338 }
339
340 #[test]
341 fn variable_used_in_assignment() {
342 Test::new(indoc! {
343 "
344 foo := 'bar'
345 baz := foo
346 "
347 })
348 .used(&["foo"])
349 .unused(&["baz"])
350 .run();
351 }
352
353 #[test]
354 fn undefined_identifier_in_recipe() {
355 Test::new(indoc! {
356 "
357 foo:
358 echo {{bar}}
359 "
360 })
361 .unresolved(&["bar"])
362 .run();
363 }
364
365 #[test]
366 fn undefined_identifier_in_assignment() {
367 Test::new("foo := bar\n")
368 .unresolved(&["bar"])
369 .unused(&["foo"])
370 .run();
371 }
372
373 #[test]
374 fn recipe_parameter_resolves_in_body() {
375 Test::new(indoc! {
376 "
377 foo bar:
378 echo {{bar}}
379 "
380 })
381 .run();
382 }
383
384 #[test]
385 fn recipe_parameter_does_not_leak_to_other_recipes() {
386 Test::new(indoc! {
387 "
388 foo bar:
389 echo {{bar}}
390
391 baz:
392 echo {{bar}}
393 "
394 })
395 .unresolved(&["bar"])
396 .run();
397 }
398
399 #[test]
400 fn parameter_default_references_variable() {
401 Test::new(indoc! {
402 "
403 x := 'foo'
404
405 bar y=x:
406 echo {{y}}
407 "
408 })
409 .used(&["x"])
410 .run();
411 }
412
413 #[test]
414 fn parameter_default_references_earlier_parameter() {
415 Test::new(indoc! {
416 "
417 foo a b=a:
418 echo {{b}}
419 "
420 })
421 .run();
422 }
423
424 #[test]
425 fn parameter_default_cannot_reference_itself() {
426 Test::new(indoc! {
427 "
428 foo a=a:
429 echo {{a}}
430 "
431 })
432 .unresolved(&["a"])
433 .run();
434 }
435
436 #[test]
437 fn parameter_default_cannot_reference_later_parameter() {
438 Test::new(indoc! {
439 "
440 foo a=b b='x':
441 echo {{a}}
442 "
443 })
444 .unresolved(&["b"])
445 .run();
446 }
447
448 #[test]
449 fn variadic_parameter_resolves_in_body() {
450 Test::new(indoc! {
451 "
452 foo +bar:
453 echo {{bar}}
454 "
455 })
456 .run();
457 }
458
459 #[test]
460 fn variadic_star_parameter_resolves_in_body() {
461 Test::new(indoc! {
462 "
463 foo *bar:
464 echo {{bar}}
465 "
466 })
467 .run();
468 }
469
470 #[test]
471 fn variadic_parameter_with_default() {
472 Test::new(indoc! {
473 "
474 x := 'foo'
475
476 bar +args=x:
477 echo {{args}}
478 "
479 })
480 .used(&["x"])
481 .run();
482 }
483
484 #[test]
485 fn multiple_variables_usage_tracking() {
486 Test::new(indoc! {
487 "
488 a := 'foo'
489 b := 'bar'
490 c := 'baz'
491
492 recipe:
493 echo {{a}} {{c}}
494 "
495 })
496 .used(&["a", "c"])
497 .unused(&["b"])
498 .run();
499 }
500
501 #[test]
502 fn builtin_constants_resolve() {
503 Test::new(indoc! {
504 "
505 foo:
506 echo {{HEX}}
507 "
508 })
509 .run();
510 }
511
512 #[test]
513 fn recipe_identifier_usage_tracks_body() {
514 Test::new(indoc! {
515 "
516 x := 'foo'
517
518 bar:
519 echo {{x}}
520 "
521 })
522 .used(&["x"])
523 .recipe_usage("bar", &["x"])
524 .run();
525 }
526
527 #[test]
528 fn recipe_identifier_usage_tracks_parameters() {
529 Test::new(indoc! {
530 "
531 foo bar:
532 echo {{bar}}
533 "
534 })
535 .recipe_usage("foo", &["bar"])
536 .run();
537 }
538
539 #[test]
540 fn recipe_identifier_usage_parameter_default_self_reference() {
541 Test::new(indoc! {
542 "
543 x := 'bar'
544
545 foo a=a:
546 echo {{a}}
547 "
548 })
549 .unresolved(&["a"])
550 .recipe_usage("foo", &["a"])
551 .unused(&["x"])
552 .run();
553 }
554
555 #[test]
556 fn multiple_recipes_isolated_scopes() {
557 Test::new(indoc! {
558 "
559 foo a:
560 echo {{a}}
561
562 bar b:
563 echo {{b}}
564 "
565 })
566 .recipe_usage("foo", &["a"])
567 .recipe_usage("bar", &["b"])
568 .run();
569 }
570
571 #[test]
572 fn variable_used_across_multiple_recipes() {
573 Test::new(indoc! {
574 "
575 x := 'foo'
576
577 a:
578 echo {{x}}
579
580 b:
581 echo {{x}}
582 "
583 })
584 .used(&["x"])
585 .recipe_usage("a", &["x"])
586 .recipe_usage("b", &["x"])
587 .run();
588 }
589
590 #[test]
591 fn parameter_shadows_variable_in_recipe() {
592 Test::new(indoc! {
593 "
594 x := 'foo'
595
596 bar x:
597 echo {{x}}
598 "
599 })
600 .unused(&["x"])
601 .run();
602 }
603
604 #[test]
605 fn user_defined_function_resolves() {
606 Test::new(indoc! {
607 "
608 set unstable
609
610 greet(name) := f\"hello {name}\"
611
612 foo:
613 echo {{greet('world')}}
614 "
615 })
616 .run();
617 }
618
619 #[test]
620 fn function_parameter_resolves_in_body() {
621 Test::new(indoc! {
622 "
623 set unstable
624
625 add(a) := a + 'x'
626 "
627 })
628 .run();
629 }
630
631 #[test]
632 fn function_parameter_does_not_leak() {
633 Test::new(indoc! {
634 "
635 set unstable
636
637 add(a) := a + 'x'
638
639 foo:
640 echo {{a}}
641 "
642 })
643 .unresolved(&["a"])
644 .run();
645 }
646
647 #[test]
648 fn function_body_references_variable() {
649 Test::new(indoc! {
650 "
651 set unstable
652
653 base := 'foo'
654
655 join(ext) := base + '.' + ext
656 "
657 })
658 .used(&["base"])
659 .run();
660 }
661
662 #[test]
663 fn function_body_undefined_identifier() {
664 Test::new(indoc! {
665 "
666 set unstable
667
668 join(ext) := missing + '.' + ext
669 "
670 })
671 .unresolved(&["missing"])
672 .run();
673 }
674
675 #[test]
676 fn multiple_parameters_in_recipe() {
677 Test::new(indoc! {
678 "
679 foo a b c:
680 echo {{a}} {{b}} {{c}}
681 "
682 })
683 .recipe_usage("foo", &["a", "b", "c"])
684 .run();
685 }
686
687 #[test]
688 fn multiple_function_parameters() {
689 Test::new(indoc! {
690 "
691 set unstable
692
693 add(a, b) := a + b
694 "
695 })
696 .run();
697 }
698
699 #[test]
700 fn variable_used_in_parameter_default_and_body() {
701 Test::new(indoc! {
702 "
703 x := 'foo'
704
705 bar y=x:
706 echo {{x}} {{y}}
707 "
708 })
709 .used(&["x"])
710 .run();
711 }
712
713 #[test]
714 fn complex_parameter_ordering() {
715 Test::new(indoc! {
716 "
717 foo a b=a c=b:
718 echo {{c}}
719 "
720 })
721 .run();
722 }
723
724 #[test]
725 fn recipe_with_no_parameters_or_body() {
726 Test::new(indoc! {
727 "
728 foo:
729 "
730 })
731 .recipe_usage("foo", &[])
732 .run();
733 }
734
735 #[test]
736 fn multiple_unresolved_identifiers() {
737 Test::new(indoc! {
738 "
739 foo:
740 echo {{a}} {{b}} {{c}}
741 "
742 })
743 .unresolved(&["a", "b", "c"])
744 .run();
745 }
746
747 #[test]
748 fn variable_chain() {
749 Test::new(indoc! {
750 "
751 a := 'foo'
752 b := a
753 c := b
754 "
755 })
756 .used(&["a", "b"])
757 .unused(&["c"])
758 .run();
759 }
760}