Skip to main content

just_lsp/
scope.rs

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  /// Resolve an identifier against the scope stack.
68  ///
69  /// Recipe identifier usage is recorded unconditionally before resolution,
70  /// so parameter self-references like `foo foo` still count as usage for the
71  /// `unused-parameters` rule.
72  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  /// Enter a function definition scope and record its body.
106  ///
107  /// Parameters are all defined before processing the body, since `just`
108  /// function parameters have no default values and cannot reference each
109  /// other.
110  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(&parameter_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  /// Enter a recipe scope and record its parameters and body.
131  ///
132  /// Parameters are defined one at a time: each default value is recorded
133  /// before defining that parameter, so `b=a` resolves `a` against earlier
134  /// parameters and globals but not `b` itself. Body identifiers inside
135  /// parameter defaults are skipped in the final expression walk to avoid
136  /// double-recording with the wrong scope.
137  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}