use super::*;

#[derive(Debug)]
pub struct Analyzer<'a> {
  config: Option<&'a Config>,
  document: &'a Document,
}

impl<'a> From<&'a Document> for Analyzer<'a> {
  fn from(document: &'a Document) -> Self {
    Self {
      config: None,
      document,
    }
  }
}

impl<'a> Analyzer<'a> {
  /// Run all registered rules against the document.
  ///
  /// Rules that return `None` from `severity()` are filtered out, so
  /// config can suppress individual rules entirely. Diagnostics are
  /// sorted by position then message for deterministic output.
  #[must_use]
  pub fn analyze(&self) -> Vec<Diagnostic> {
    let context = RuleContext::new(self.document);

    let default = Config::default();

    let config = self.config.unwrap_or(&default);

    let mut diagnostics = inventory::iter::<&dyn Rule>
      .into_iter()
      .flat_map(|rule| {
        rule
          .run(&context)
          .into_iter()
          .filter_map(move |diagnostic| {
            let rule_config = config.rule_config(rule.id());

            Some(Diagnostic {
              id: rule.id().to_string(),
              display: rule.message().to_string(),
              severity: rule_config.severity(diagnostic.severity)?,
              ..diagnostic
            })
          })
      })
      .collect::<Vec<_>>();

    diagnostics.sort_by(|a, b| {
      a.range
        .start
        .line
        .cmp(&b.range.start.line)
        .then_with(|| a.range.start.character.cmp(&b.range.start.character))
        .then_with(|| a.message.cmp(&b.message))
    });

    diagnostics
  }

  /// Set the config for rule severity overrides.
  ///
  /// When no config is set, `Config::default()` is used, which leaves
  /// all rule severities at their built-in defaults.
  #[must_use]
  pub fn config(self, config: &'a Config) -> Self {
    Self {
      config: Some(config),
      ..self
    }
  }
}

#[cfg(test)]
mod tests {
  use {super::*, indoc::indoc, pretty_assertions::assert_eq};

  #[derive(Debug)]
  struct Test {
    config: Config,
    document: Document,
    messages: Vec<(&'static str, lsp::Range, Option<lsp::DiagnosticSeverity>)>,
  }

  impl Test {
    fn config(self, config: Config) -> Self {
      Self { config, ..self }
    }

    fn error(self, message: &'static str, range: lsp::Range) -> Self {
      Self {
        messages: self
          .messages
          .into_iter()
          .chain([(message, range, Some(lsp::DiagnosticSeverity::ERROR))])
          .collect(),
        ..self
      }
    }

    fn new(content: &str) -> Self {
      let uri = if cfg!(windows) {
        "file:///C:/test.just"
      } else {
        "file:///test.just"
      };

      Self {
        config: Config::default(),
        document: Document::try_from(lsp::DidOpenTextDocumentParams {
          text_document: lsp::TextDocumentItem {
            uri: lsp::Url::parse(uri).unwrap(),
            language_id: "just".to_string(),
            version: 1,
            text: content.to_string(),
          },
        })
        .unwrap(),
        messages: Vec::new(),
      }
    }

    fn run(self) {
      let Test {
        config,
        document,
        messages,
      } = self;

      let analyzer = Analyzer::from(&document).config(&config);

      let diagnostics = analyzer
        .analyze()
        .into_iter()
        .map(lsp::Diagnostic::from)
        .collect::<Vec<lsp::Diagnostic>>();

      assert_eq!(
        diagnostics.len(),
        messages.len(),
        "Expected diagnostics {:?} but got {:?}",
        messages,
        diagnostics,
      );

      for (diagnostic, (expected_message, expected_range, expected_severity)) in
        diagnostics.into_iter().zip(messages)
      {
        assert_eq!(diagnostic.severity, expected_severity, "{diagnostic:?}");
        assert_eq!(diagnostic.message, expected_message);
        assert_eq!(diagnostic.range, expected_range);
      }
    }

    fn warning(self, message: &'static str, range: lsp::Range) -> Self {
      Self {
        messages: self
          .messages
          .into_iter()
          .chain([(message, range, Some(lsp::DiagnosticSeverity::WARNING))])
          .collect(),
        ..self
      }
    }
  }

  #[test]
  fn accepts_logical_operators() {
    Test::new(indoc! {
      "
      foo := '' || 'bar'
      bar := 'foo' && 'bar'

      baz:
        echo {{ foo }}
        echo {{ bar }}
      "
    })
    .run();
  }

  #[test]
  fn alias_recipe_conflict_alias_then_recipe() {
    Test::new(indoc! {
      "
      alias t := other

      other:
        echo \"other\"

      t:
        echo \"recipe\"
      "
    })
    .error(
      "Alias `t` is redefined as a recipe",
      lsp::Range::at(5, 0, 5, 1),
    )
    .run();
  }

  #[test]
  fn alias_recipe_conflict_recipe_then_alias() {
    Test::new(indoc! {
      "
      other:
        echo \"other\"

      t:
        echo \"recipe\"

      alias t := other
      "
    })
    .error(
      "Recipe `t` is redefined as an alias",
      lsp::Range::at(6, 6, 6, 7),
    )
    .run();
  }

  #[test]
  fn aliases_basic() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      alias bar := foo
      "
    })
    .run();
  }

  #[test]
  fn aliases_duplicate() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      alias bar := foo
      alias bar := foo
      alias bar := foo
      "
    })
    .error("Duplicate alias `bar`", lsp::Range::at(4, 0, 4, 16))
    .error("Duplicate alias `bar`", lsp::Range::at(5, 0, 5, 16))
    .run();
  }

  #[test]
  fn aliases_missing_recipe() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      alias bar := baz
      "
    })
    .error("Recipe `baz` not found", lsp::Range::at(3, 13, 3, 16))
    .run();
  }

  #[test]
  fn aliases_missing_target() {
    Test::new(indoc! {
      "
      alias foo :=
      "
    })
    .error("Missing identifier in alias", lsp::Range::at(0, 12, 0, 12))
    .error("Recipe `` not found", lsp::Range::at(0, 12, 0, 12))
    .run();
  }

  #[test]
  fn all_four_os_groups_no_conflict() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux\"

      [macos]
      build:
        echo \"Building on macOS\"

      [windows]
      build:
        echo \"Building on Windows\"

      [dragonfly]
      build:
        echo \"Building on DragonFly BSD\"

      [freebsd]
      build:
        echo \"Building on FreeBSD\"

      [netbsd]
      build:
        echo \"Building on NetBSD\"

      [openbsd]
      build:
        echo \"Building on OpenBSD\"
      "
    })
    .run();
  }

  #[test]
  fn analyze_complete() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: missing
        echo \"bar\"

      alias baz := nonexistent
      "
    })
    .error("Recipe `missing` not found", lsp::Range::at(3, 5, 3, 12))
    .error(
      "Recipe `nonexistent` not found",
      lsp::Range::at(6, 13, 6, 24),
    )
    .run();
  }

  #[test]
  fn arg_attribute_empty_parens() {
    Test::new(indoc! {
      "
      [arg()]
      bar foo:
        echo {{foo}}
      "
    })
    .error(
      "Attribute `arg` got 0 arguments but takes at least 1 argument",
      lsp::Range::at(0, 0, 1, 0),
    )
    .error("Missing identifier in value", lsp::Range::at(0, 5, 0, 5))
    .run();
  }

  #[test]
  fn arg_attribute_missing_parameter_name() {
    Test::new(indoc! {
      "
      [arg]
      bar foo:
        echo {{foo}}
      "
    })
    .error(
      "Attribute `arg` got 0 arguments but takes at least 1 argument",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn arg_attribute_unknown_kwarg() {
    Test::new(indoc! {
      "
      [arg('name', bogus='x')]
      foo name:
        echo {{name}}
      "
    })
    .error(
      "Unknown `[arg]` keyword `bogus`, expected one of help, long, short, value, pattern",
    lsp::Range::at(0, 13, 0, 22))
    .run();
  }

  #[test]
  fn arg_attribute_unknown_parameter() {
    Test::new(indoc! {
      "
      [arg('missing', help='nope')]
      foo name:
        echo {{name}}
      "
    })
    .error(
      "`[arg]` references unknown parameter `missing`",
      lsp::Range::at(0, 5, 0, 14),
    )
    .run();
  }

  #[test]
  fn arg_attribute_valid() {
    Test::new(indoc! {
      "
      [arg('foo', help=\"Help text\")]
      bar foo:
        echo {{foo}}
      "
    })
    .run();
  }

  #[test]
  fn arg_attribute_value_requires_long_or_short() {
    Test::new(indoc! {
      "
      [arg('name', value='hi')]
      foo name:
        echo {{name}}
      "
    })
    .error(
      "`[arg]` `value=` requires `long=` or `short=`",
      lsp::Range::at(0, 13, 0, 23),
    )
    .run();
  }

  #[test]
  fn arg_attribute_value_with_long_ok() {
    Test::new(indoc! {
      "
      [arg('name', long='name', value='hi')]
      foo name:
        echo {{name}}
      "
    })
    .run();
  }

  #[test]
  fn arg_attribute_with_long_option() {
    Test::new(indoc! {
      "
      [arg('foo', long=\"foo-opt\")]
      bar foo:
        echo {{foo}}
      "
    })
    .run();
  }

  #[test]
  fn arg_attribute_with_multiple_options() {
    Test::new(indoc! {
      "
      [arg('foo', long=\"foo-opt\", short=\"f\", value=\"default\")]
      bar foo:
        echo {{foo}}
      "
    })
    .run();
  }

  #[test]
  fn arg_attribute_with_pattern() {
    Test::new(indoc! {
      "
      [arg('version', pattern=\"[0-9]+\\\\.[0-9]+\\\\.[0-9]+\")]
      release version:
        echo {{version}}
      "
    })
    .run();
  }

  #[test]
  fn arg_attribute_with_short_option() {
    Test::new(indoc! {
      "
      [arg('foo', short=\"f\")]
      bar foo:
        echo {{foo}}
      "
    })
    .run();
  }

  #[test]
  fn assert_accepts_condition_operators() {
    Test::new(indoc! {
      "
      foo name:
        {{ assert(name == \"bar\", \"msg\") }}
        {{ assert(name != \"bar\", \"msg\") }}
        {{ assert(name =~ \"^[a-z]+$\", \"msg\") }}
      "
    })
    .run();
  }

  #[test]
  fn attribute_expression_requires_support() {
    Test::new(indoc! {
      "
      foo := 'foo'

      [group(foo)]
      [group(f'bar')]
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `group` arguments must be string literals",
      lsp::Range::at(2, 7, 2, 10),
    )
    .error(
      "Attribute `group` arguments must be string literals",
      lsp::Range::at(3, 7, 3, 13),
    )
    .run();
  }

  #[test]
  fn attribute_string_literal_expression() {
    Test::new(indoc! {
      "
      [group('foo')]
      [metadata(x'bar')]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_correct() {
    Test::new(indoc! {
      "
      [no-cd]
      [linux]
      [macos]
      foo:
        echo \"foo\"

      [doc('Recipe documentation')]
      bar:
        echo \"bar\"

      [default]
      baz:
        echo \"baz\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_duplicate_default_between_recipes() {
    Test::new(indoc! {
      "
      [default]
      check:
        echo \"check\"

      [default]
      ci:
        echo \"ci\"
      "
    })
    .error(
      "Recipe `ci` has duplicate `[default]` attribute, which may only appear once per module", lsp::Range::at(4, 0, 5, 0))
    .run();
  }

  #[test]
  fn attributes_duplicate_default_on_same_recipe() {
    Test::new(indoc! {
      "
      [default]
      [default]
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe `build` has duplicate `[default]` attribute, which may only appear once per module", lsp::Range::at(1, 0, 2, 0))
    .run();
  }

  #[test]
  fn attributes_duplicate_group_attribute() {
    Test::new(indoc! {
      "
      [group('dev')]
      [group('dev')]
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe attribute `group` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn attributes_duplicate_recipe_attribute() {
    Test::new(indoc! {
      "
      [script]
      [script]
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe attribute `script` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn attributes_duplicate_working_directory_attribute() {
    Test::new(indoc! {
      "
      [working-directory: 'foo']
      [working-directory: 'bar']
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe attribute `working-directory` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn attributes_extra_arguments() {
    Test::new(indoc! {
      "
      [linux('invalid')]
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `linux` got 1 argument but takes 0 arguments",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();

    Test::new(indoc! {
      "
      [default('invalid')]
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `default` got 1 argument but takes 0 arguments",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn attributes_inline_parameters_focused() {
    Test::new(indoc! {
      "
      [group: 'foo', no-cd]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_invalid_inline() {
    Test::new(indoc! {
      "
      [group: 'foo', foo]
      foo:
        echo \"foo\"
      "
    })
    .error("Unknown attribute `foo`", lsp::Range::at(0, 15, 0, 18))
    .run();
  }

  #[test]
  fn attributes_metadata_multiple_arguments() {
    Test::new(indoc! {
      "
      [metadata('foo', 'bar')]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_missing_arguments() {
    Test::new(indoc! {
      "
      [extension]
      foo:
        #!/usr/bin/env bash
        echo \"foo\"
      "
    })
    .error(
      "Attribute `extension` got 0 arguments but takes 1 argument",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn attributes_more_arguments_than_required() {
    Test::new(indoc! {
      "
      [group('foo', 'bar')]
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `group` got 2 arguments but takes 1 argument",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn attributes_multiple_group_attributes_allowed() {
    Test::new(indoc! {
      "
      [group('lint')]
      [group('rust')]
      build:
        echo \"build\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_multiple_metadata_allowed() {
    Test::new(indoc! {
      "
      [metadata('foo', 'bar')]
      [metadata('baz', 'qux')]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_no_cd_allowed_with_global_working_directory() {
    Test::new(indoc! {
      "
      set working-directory := '/tmp'

      [no-cd]
      build:
        echo \"build\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_no_parameters_needed() {
    Test::new(indoc! {
      "
      [script]
      foo:
        echo \"foo\"
      "
    })
    .run();

    Test::new(indoc! {
      "
      [confirm]
      foo:
        echo \"foo\"
      "
    })
    .run();

    Test::new(indoc! {
      "
      [default]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn attributes_on_assignments() {
    Test::new(indoc! {
      "
      [private]
      secret := \"secret value\"

      [private]
      _db_url := \"postgres://user:pass@host:port/db\"

      public_var := \"public value\"

      test:
        echo {{ secret }}
        echo {{ _db_url }}
        echo {{ public_var }}
      "
    })
    .run();
  }

  #[test]
  fn attributes_on_exported_assignments() {
    Test::new(indoc! {
      "
      [private]
      export PATH := '/usr/local/bin'
      "
    })
    .run();
  }

  #[test]
  fn attributes_unknown() {
    Test::new(indoc! {
      "
      [unknown_attribute]
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Unknown attribute `unknown_attribute`",
      lsp::Range::at(0, 1, 0, 18),
    )
    .run();
  }

  #[test]
  fn attributes_working_directory_conflicts_with_no_cd() {
    Test::new(indoc! {
      "
      [no-cd]
      [working-directory: '/tmp']
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe `build` can't combine `[working-directory]` with `[no-cd]`",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn attributes_exit_message_conflicts_with_no_exit_message() {
    Test::new(indoc! {
      "
      [exit-message]
      [no-exit-message]
      build:
        echo \"build\"
      "
    })
    .error(
      "Recipe `build` can't combine `[exit-message]` with `[no-exit-message]`",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn attributes_wrong_target() {
    Test::new(indoc! {
      "
      [group: 'foo']
      alias f := foo

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `group` cannot be applied to alias target",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn bsd_os_specific_no_conflict() {
    Test::new(indoc! {
      "
      [dragonfly]
      build:
        echo \"Building on DragonFly BSD\"

      [freebsd]
      build:
        echo \"Building on FreeBSD\"

      [netbsd]
      build:
        echo \"Building on NetBSD\"

      [openbsd]
      build:
        echo \"Building on OpenBSD\"
      "
    })
    .run();
  }

  #[test]
  fn circular_dependencies_long_chain() {
    Test::new(indoc! {
      "
      foo: bar
        echo \"foo\"

      bar: baz
        echo \"bar\"

      baz: foo
        echo \"baz\"
      "
    })
    .error(
      "Recipe `foo` has circular dependency `foo -> bar -> baz -> foo`",
      lsp::Range::at(0, 0, 3, 0),
    )
    .error(
      "Recipe `bar` has circular dependency `bar -> baz -> foo -> bar`",
      lsp::Range::at(3, 0, 6, 0),
    )
    .error(
      "Recipe `baz` has circular dependency `baz -> foo -> bar -> baz`",
      lsp::Range::at(6, 0, 8, 0),
    )
    .run();
  }

  #[test]
  fn circular_dependencies_multiple_cycles() {
    Test::new(indoc! {
      "
      a: b
        echo \"a\"

      b: a
        echo \"b\"

      x: y
        echo \"x\"

      y: z
        echo \"y\"

      z: x
        echo \"z\"
      "
    })
    .error(
      "Recipe `a` has circular dependency `a -> b -> a`",
      lsp::Range::at(0, 0, 3, 0),
    )
    .error(
      "Recipe `b` has circular dependency `b -> a -> b`",
      lsp::Range::at(3, 0, 6, 0),
    )
    .error(
      "Recipe `x` has circular dependency `x -> y -> z -> x`",
      lsp::Range::at(6, 0, 9, 0),
    )
    .error(
      "Recipe `y` has circular dependency `y -> z -> x -> y`",
      lsp::Range::at(9, 0, 12, 0),
    )
    .error(
      "Recipe `z` has circular dependency `z -> x -> y -> z`",
      lsp::Range::at(12, 0, 14, 0),
    )
    .run();
  }

  #[test]
  fn circular_dependencies_only_flags_cycle_members() {
    Test::new(indoc! {
      "
      foo: bar
        echo \"foo\"

      bar: baz
        echo \"bar\"

      baz: bar
        echo \"baz\"
      "
    })
    .error(
      "Recipe `bar` has circular dependency `bar -> baz -> bar`",
      lsp::Range::at(3, 0, 6, 0),
    )
    .error(
      "Recipe `baz` has circular dependency `baz -> bar -> baz`",
      lsp::Range::at(6, 0, 8, 0),
    )
    .run();
  }

  #[test]
  fn circular_dependencies_self() {
    Test::new(indoc! {
      "
      foo: foo
        echo \"foo\"
      "
    })
    .error("Recipe `foo` depends on itself", lsp::Range::at(0, 0, 2, 0))
    .run();
  }

  #[test]
  fn circular_dependencies_simple() {
    Test::new(indoc! {
      "
      foo: bar
        echo \"foo\"

      bar: foo
        echo \"bar\"
      "
    })
    .error(
      "Recipe `foo` has circular dependency `foo -> bar -> foo`",
      lsp::Range::at(0, 0, 3, 0),
    )
    .error(
      "Recipe `bar` has circular dependency `bar -> foo -> bar`",
      lsp::Range::at(3, 0, 5, 0),
    )
    .run();
  }

  #[test]
  fn circular_dependencies_with_multiple_dependencies() {
    Test::new(indoc! {
      "
      foo: bar baz
        echo \"foo\"

      bar:
        echo \"bar\"

      baz: qux
        echo \"baz\"

      qux: foo
        echo \"qux\"
      "
    })
    .error(
      "Recipe `foo` has circular dependency `foo -> baz -> qux -> foo`",
      lsp::Range::at(0, 0, 3, 0),
    )
    .error(
      "Recipe `baz` has circular dependency `baz -> qux -> foo -> baz`",
      lsp::Range::at(6, 0, 9, 0),
    )
    .error(
      "Recipe `qux` has circular dependency `qux -> foo -> baz -> qux`",
      lsp::Range::at(9, 0, 11, 0),
    )
    .run();
  }

  #[test]
  fn comma_separated_os_attributes_no_conflict() {
    Test::new(indoc! {
      "
      [private, unix]
      hello:
        @echo 'hello'

      [private, windows]
      hello:
        @echo hello
      "
    })
    .run();
  }

  #[test]
  fn comma_separated_os_attributes_with_conflict() {
    Test::new(indoc! {
      "
      [private, linux]
      hello:
        @echo 'hello on linux'

      [private, linux]
      hello:
        @echo 'hello on linux again'
      "
    })
    .error("Duplicate recipe name `hello`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn comma_separated_unix_windows_no_conflict() {
    Test::new(indoc! {
      "
      [unix]
      [private]
      build:
        @echo 'building on unix'

      [private, windows]
      build:
        @echo 'building on windows'
      "
    })
    .run();
  }

  #[test]
  fn confirm_attribute_accepts_expression() {
    Test::new(indoc! {
      r#"
      [confirm("Deploy to " + env + "?")]
      deploy env:
        echo {{env}}
      "#
    })
    .run();
  }

  #[test]
  fn cross_parameter_default_references_preceding_parameter() {
    Test::new(indoc! {
      "
      a := 'foo'

      foo a b=a:
        echo {{ a }} {{ b }}
      "
    })
    .warning("Variable `a` appears unused", lsp::Range::at(0, 0, 0, 1))
    .run();
  }

  #[test]
  fn default_parameter_expression_functions() {
    Test::new(indoc! {
      "
      build version=uppercase(\"1.0.0\"):
        echo {{ version }}
      "
    })
    .run();
  }

  #[test]
  fn default_parameter_expression_with_env_call() {
    Test::new(indoc! {
      "
      build target=(env('TARGET', 'debug')):
        echo {{ target }}
      "
    })
    .run();
  }

  #[test]
  fn dir_aliases_recognized() {
    Test::new(indoc! {
      "
      foo:
        echo {{ home_dir() }}
        echo {{ cache_dir() }}
        echo {{ config_dir() }}
        echo {{ config_local_dir() }}
        echo {{ data_dir() }}
        echo {{ data_local_dir() }}
        echo {{ executable_dir() }}
        echo {{ invocation_dir() }}
        echo {{ invocation_dir_native() }}
        echo {{ justfile_dir() }}
        echo {{ source_dir() }}
        echo {{ parent_dir('~/.config') }}
      "
    })
    .run();
  }

  #[test]
  fn duplicate_dragonfly_attribute() {
    Test::new(indoc! {
      "
      [dragonfly]
      [dragonfly]
      build:
        echo \"foo\"
      "
    })
    .error(
      "Recipe attribute `dragonfly` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn duplicate_freebsd_attribute() {
    Test::new(indoc! {
      "
      [freebsd]
      [freebsd]
      build:
        echo \"foo\"
      "
    })
    .error(
      "Recipe attribute `freebsd` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn duplicate_netbsd_attribute() {
    Test::new(indoc! {
      "
      [netbsd]
      [netbsd]
      build:
        echo \"foo\"
      "
    })
    .error(
      "Recipe attribute `netbsd` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn duplicate_recipe_names() {
    Test::new(indoc! {
      "
      foo:
        echo foo

      foo:
        echo foo

      foo:
        echo foo
      "
    })
    .error("Duplicate recipe name `foo`", lsp::Range::at(3, 0, 6, 0))
    .error("Duplicate recipe name `foo`", lsp::Range::at(6, 0, 8, 0))
    .run();
  }

  #[test]
  fn duplicate_recipe_names_allowed_via_setting() {
    Test::new(indoc! {
      "
      set allow-duplicate-recipes := true

      foo:
        echo foo

      [linux]
      foo:
        echo foo on linux
      "
    })
    .run();
  }

  #[test]
  fn duplicate_recipes_with_same_os_attribute() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux version 1\"

      [linux]
      build:
        echo \"Building on Linux version 2\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn duplicate_variable_assignments() {
    Test::new(indoc! {
      "
      foo := \"one\"
      foo := \"two\"

      recipe:
        echo {{ foo }}
      "
    })
    .error("Duplicate variable `foo`", lsp::Range::at(1, 0, 2, 0))
    .run();
  }

  #[test]
  fn duplicate_variable_assignments_allowed_via_setting() {
    Test::new(indoc! {
      "
      set allow-duplicate-variables := true

      foo := \"one\"
      foo := \"two\"

      recipe:
        echo {{ foo }}
      "
    })
    .run();
  }

  #[test]
  fn env_attribute_duplicate_var_name() {
    Test::new(indoc! {
      "
      [env('FOO', 'bar')]
      [env('FOO', 'baz')]
      foo:
        echo \"$FOO\"
      "
    })
    .error(
      "Recipe attribute `env` is duplicated",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn env_attribute_missing_value() {
    Test::new(indoc! {
      "
      [env('FOO')]
      foo:
        echo \"$FOO\"
      "
    })
    .error(
      "Attribute `env` got 1 argument but takes 2 arguments",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn env_attribute_multiple_vars_allowed() {
    Test::new(indoc! {
      "
      [env('FOO', 'bar')]
      [env('BAZ', 'qux')]
      foo:
        echo \"$FOO $BAZ\"
      "
    })
    .run();
  }

  #[test]
  fn env_attribute_too_many_arguments() {
    Test::new(indoc! {
      "
      [env('FOO', 'bar', 'extra')]
      foo:
        echo \"$FOO\"
      "
    })
    .error(
      "Attribute `env` got 3 arguments but takes 2 arguments",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn env_attribute_valid() {
    Test::new(indoc! {
      "
      [env('FOO', 'bar')]
      foo:
        echo \"$FOO\"
      "
    })
    .run();
  }

  #[test]
  fn env_attribute_wrong_target() {
    Test::new(indoc! {
      "
      [env('FOO', 'bar')]
      alias baz := foo

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Attribute `env` cannot be applied to alias target",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn escaped_braces_are_treated_as_literal_text() {
    Test::new(indoc! {
      "
      test:
        echo \"{{{{hello}}\"
      "
    })
    .run();
  }

  #[test]
  fn exported_variables_not_warned() {
    Test::new(indoc! {
      "
      foo := \"unused value\"
      export bar := \"exported but unused\"
      baz := \"used value\"

      recipe:
        echo {{ baz }}
      "
    })
    .warning("Variable `foo` appears unused", lsp::Range::at(0, 0, 0, 3))
    .run();
  }

  #[test]
  fn expression_attribute_arguments() {
    Test::new(indoc! {
      "
      bar := 'bar'

      [confirm('foo' / bar)]
      [env('FOO', bar)]
      [working-directory('foo' / bar)]
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn extension_with_script_is_allowed() {
    Test::new(indoc! {
      "
      [script]
      [extension: '.sh']
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn extension_with_shebang_is_allowed() {
    Test::new(indoc! {
      "
      [extension: '.sh']
      foo:
        #!/usr/bin/env bash
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn extension_without_script_or_shebang() {
    Test::new(indoc! {
      "
      [extension: '.sh']
      foo:
        echo \"foo\"
      "
    })
    .error(
      "Recipe `foo` uses `[extension]` without `[script]` or a shebang",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn format_strings_mark_variables_as_used() {
    Test::new(indoc! {
      r#"
      name := "world"
      greeting := f'Hello, {{name}}!'
      foo:
        echo {{greeting}}
      "#
    })
    .run();
  }

  #[test]
  fn format_strings_with_function_calls() {
    Test::new(indoc! {
      r"
      info := f'arch: {{arch()}}'
      foo:
        echo {{info}}
      "
    })
    .run();
  }

  #[test]
  fn format_strings_with_undefined_variables() {
    Test::new(indoc! {
      r"
      greeting := f'Hello, {{undefined_var}}!'
      foo:
        echo {{greeting}}
      "
    })
    .error(
      "Variable `undefined_var` not found",
      lsp::Range::at(0, 23, 0, 36),
    )
    .run();
  }

  #[test]
  fn format_strings_with_valid_variables() {
    Test::new(indoc! {
      r#"
      name := "world"
      greeting := f'Hello, {{name}}!'
      foo:
        echo {{greeting}}
      "#
    })
    .run();
  }

  #[test]
  fn function_calls_correct() {
    Test::new(indoc! {
      "
      foo:
        echo {{ arch() }}
        echo {{ join(\"a\", \"b\", \"c\") }}
      "
    })
    .run();
  }

  #[test]
  fn function_calls_nested() {
    Test::new(indoc! {
      "
      foo:
        echo {{ replace(parent_directory('~/.config/nvim/init.lua'), '.', 'dot-') }}
      "
    })
    .run();
  }

  #[test]
  fn function_calls_too_few_args() {
    Test::new(indoc! {
      "
      foo:
        echo {{ replace() }}
      "
    })
    .error(
      "Function `replace` requires at least 3 arguments, but 0 provided",
      lsp::Range::at(1, 10, 1, 19),
    )
    .run();
  }

  #[test]
  fn function_calls_too_many_args() {
    Test::new(indoc! {
      "
      foo:
        echo {{ uppercase(\"hello\", \"extra\") }}
      "
    })
    .error(
      "Function `uppercase` accepts 1 argument, but 2 provided",
      lsp::Range::at(1, 10, 1, 37),
    )
    .run();
  }

  #[test]
  fn function_calls_unknown() {
    Test::new(indoc! {
      "
      foo:
        echo {{ unknown_function() }}
      "
    })
    .error(
      "Unknown function `unknown_function`",
      lsp::Range::at(1, 10, 1, 26),
    )
    .run();
  }

  #[test]
  fn import_format_string_skipped() {
    Test::new(indoc! {
      r#"
      import f"{'nonexistent.just'}"
      "#
    })
    .run();
  }

  #[test]
  fn import_invalid_path() {
    let expected = if cfg!(windows) {
      "Import path does not exist: `C:\\nonexistent.just`"
    } else {
      "Import path does not exist: `/nonexistent.just`"
    };

    Test::new(indoc! {
      "
      import 'nonexistent.just'
      "
    })
    .error(expected, lsp::Range::at(0, 7, 0, 25))
    .run();
  }

  #[test]
  fn import_optional_invalid_path() {
    Test::new(indoc! {
      "
      import? 'nonexistent.just'
      "
    })
    .run();
  }

  #[test]
  fn import_shell_expanded_string_skipped() {
    Test::new(indoc! {
      "
      import x'nonexistent.just'
      "
    })
    .run();
  }

  #[test]
  fn linux_openbsd_no_conflict() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux\"

      [openbsd]
      build:
        echo \"Building on OpenBSD\"
      "
    })
    .run();
  }

  #[test]
  fn linux_unix_conflict() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux\"

      [unix]
      build:
        echo \"Building on Unix systems\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn mixed_os_specific_and_regular_recipe() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux\"

      build:
        echo \"Building on any OS\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 6, 0))
    .run();
  }

  #[test]
  fn module_attributes_group() {
    Test::new(indoc! {
      "
      [group: 'tools']
      mod foo
      "
    })
    .run();
  }

  #[test]
  fn openbsd_macos_no_conflict() {
    Test::new(indoc! {
      "
      [openbsd]
      build:
        echo \"Building on OpenBSD\"

      [macos]
      build:
        echo \"Building on macOS\"
      "
    })
    .run();
  }

  #[test]
  fn os_specific_duplicate_recipes() {
    Test::new(indoc! {
      "
      [linux]
      build:
        echo \"Building on Linux\"

      [windows]
      build:
        echo \"Building on Windows\"

      [macos]
      build:
        echo \"Building on macOS\"
      "
    })
    .run();
  }

  #[test]
  fn parallel_with_single_dependency_warns() {
    Test::new(indoc! {
      "
      [parallel]
      foo: bar
        echo \"foo\"

      bar:
        echo \"bar\"
      "
    })
    .warning(
      "Recipe `foo` has only one dependency, so `[parallel]` has no effect",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn parallel_without_dependencies_warns() {
    Test::new(indoc! {
      "
      [parallel]
      foo:
        echo \"foo\"
      "
    })
    .warning(
      "Recipe `foo` has no dependencies, so `[parallel]` has no effect",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn parameter_default_references_preceding_parameter() {
    Test::new(indoc! {
      "
      @binstall crate bin=crate:
        which {{bin}} 2>&1 >/dev/null || cargo binstall -y {{crate}}
      "
    })
    .run();
  }

  #[test]
  fn parenthesized_expression_default_uses_global_variable() {
    Test::new(indoc! {
      "
      foo := 'foo'
      bar := 'bar'

      recipe x=(foo + bar):
        echo {{ x }}
      "
    })
    .run();
  }

  #[test]
  fn parser_errors_invalid() {
    Test::new(indoc! {
      "
      foo
        echo \"foo\"
      "
    })
    .error(
      "Syntax error near `foo echo \"foo\"`",
      lsp::Range::at(0, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn parser_errors_valid() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn parser_errors_valid_with_recipe_line_containing_only_open_brace() {
    Test::new(indoc! {
      r#"
      foo bar="baz" qux="quux":
        #!/usr/bin/env bash
        cat <<'JSON'
        {
          "foo": "bar",
          "{{ bar }}": "{{ qux }}"
        }
        JSON
      "#
    })
    .run();
  }

  #[test]
  fn parser_errors_valid_with_shell_expanded_strings() {
    Test::new(indoc! {
      r#"
      import x'~/.config/just/common.just'

      greeting := x"~/$USER/${GREETING:-hello}"

      foo:
        echo {{greeting}}
      "#
    })
    .run();
  }

  #[test]
  fn positional_arguments_attribute_marks_parameters_as_used() {
    Test::new(indoc! {
      "
      [positional-arguments]
      graph log:
        ./bin/graph $1
      "
    })
    .run();
  }

  #[test]
  fn positional_arguments_attribute_scope_is_limited() {
    Test::new(indoc! {
      "
      [positional-arguments]
      graph log:
        ./bin/graph $1

      other data:
        ./bin/graph $1
      "
    })
    .warning(
      "Parameter `data` appears unused",
      lsp::Range::at(4, 6, 4, 10),
    )
    .run();
  }

  #[test]
  fn positional_arguments_disabled_still_warns() {
    Test::new(indoc! {
      "
      graph log:
        ./bin/graph $1
      "
    })
    .warning("Parameter `log` appears unused", lsp::Range::at(0, 6, 0, 9))
    .run();
  }

  #[test]
  fn positional_arguments_dollar_at_marks_all_as_used() {
    Test::new(indoc! {
      r#"
      [positional-arguments]
      run *args:
        #!/usr/bin/env bash
        exec "$@"
      "#
    })
    .run();
  }

  #[test]
  fn positional_arguments_only_mark_used_indices() {
    Test::new(indoc! {
      "
      set positional-arguments := true

      graph first second:
        ./bin/graph $2
      "
    })
    .warning(
      "Parameter `first` appears unused",
      lsp::Range::at(2, 6, 2, 11),
    )
    .run();
  }

  #[test]
  fn positional_arguments_setting_handles_multiple_parameters() {
    Test::new(indoc! {
      "
      set positional-arguments := true

      graph first second third:
        ./bin/graph $1 ${2} $3
      "
    })
    .run();
  }

  #[test]
  fn positional_arguments_setting_handles_multiple_parameters_unused() {
    Test::new(indoc! {
      "
      set positional-arguments := true

      graph first second third fourth:
        ./bin/graph $1 ${2} $3
      "
    })
    .warning(
      "Parameter `fourth` appears unused",
      lsp::Range::at(2, 25, 2, 31),
    )
    .run();
  }

  #[test]
  fn positional_arguments_setting_marks_parameters_as_used() {
    Test::new(indoc! {
      "
      set positional-arguments := true

      graph log:
        ./bin/graph $1
      "
    })
    .run();
  }

  #[test]
  fn positional_arguments_shebang_marks_all_as_used() {
    Test::new(indoc! {
      r"
      [positional-arguments]
      run *args:
        #!/usr/bin/env -S deno run
        console.log(Deno.args)
      "
    })
    .run();
  }

  #[test]
  fn recipe_consistent_indentation() {
    Test::new("foo:\n  echo \"foo\"\n  echo \"bar\"\n").run();
  }

  #[test]
  fn recipe_dependencies_correct() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: foo
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_dependencies_duplicate_warns() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: foo foo
        echo \"bar\"
      "
    })
    .warning(
      "Recipe `bar` lists dependency `foo` more than once; just only runs it once, so it's redundant",
    lsp::Range::at(3, 9, 3, 12))
    .run();
  }

  #[test]
  fn recipe_dependencies_duplicate_with_arguments_warns() {
    Test::new(indoc! {
      "
      foo arg1:
        echo \"{{arg1}}\"

      bar: (foo `a`) (foo `a`)
        echo \"bar\"
      "
    })
    .warning(
      "Recipe `bar` lists dependency `foo` with the same arguments more than once; just only runs it once, so it's redundant",
    lsp::Range::at(3, 15, 3, 24))
    .run();
  }

  #[test]
  fn recipe_dependencies_missing() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: baz
        echo \"bar\"
      "
    })
    .error("Recipe `baz` not found", lsp::Range::at(3, 5, 3, 8))
    .run();
  }

  #[test]
  fn recipe_dependencies_multiple_missing() {
    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: missing1 missing2
        echo \"bar\"
      "
    })
    .error("Recipe `missing1` not found", lsp::Range::at(3, 5, 3, 13))
    .error("Recipe `missing2` not found", lsp::Range::at(3, 14, 3, 22))
    .run();
  }

  #[test]
  fn recipe_dependencies_with_different_arguments_no_warning() {
    Test::new(indoc! {
      "
      foo arg1:
        echo \"{{arg1}}\"

      bar: (foo `a`) (foo `b`)
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_dependencies_with_expressions() {
    Test::new(indoc! {
      "
      recipe-a param:
        echo {{param}}

      recipe-b param: (recipe-a (\"##\" + param + \"##\"))
        echo \"recipe-b called with {{param}}\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_dependencies_with_multiple_expression_arguments() {
    Test::new(indoc! {
      "
      recipe-a a b:
        echo {{a}} {{b}}

      recipe-b param: (recipe-a (\"1\") (\"2\"))
        echo \"recipe-b called with {{param}}\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_inconsistent_indentation_between_lines() {
    Test::new("foo:\n        echo \"foo\"\n  echo \"bar\"\n")
    .error(
      "Recipe line has inconsistent leading whitespace. Recipe started with `␠␠␠␠␠␠␠␠` but found line with `␠␠`", lsp::Range::at(3, 0, 3, 2))
    .run();
  }

  #[test]
  fn recipe_invocation_argument_count_correct() {
    Test::new(indoc! {
      "
      foo arg1 arg2=\"default\":
        echo \"{{arg1}} {{arg2}}\"

      bar: (foo `value1`)
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_invocation_missing_args() {
    Test::new(indoc! {
      "
      foo arg1 arg2:
        echo \"{{arg1}} {{arg2}}\"

      bar: (foo)
        echo \"bar\"
      "
    })
    .error(
      "Dependency `foo` requires 2 arguments, but 0 provided",
      lsp::Range::at(3, 5, 3, 10),
    )
    .run();
  }

  #[test]
  fn recipe_invocation_one_or_more_variadic_requires_argument() {
    Test::new(indoc! {
      "
      foo +args:
        echo \"{{args}}\"

      bar: (foo)
        echo \"bar\"
      "
    })
    .error(
      "Dependency `foo` requires 1 argument, but 0 provided",
      lsp::Range::at(3, 5, 3, 10),
    )
    .run();
  }

  #[test]
  fn recipe_invocation_too_few_args() {
    Test::new(indoc! {
      "
      foo arg1 arg2:
        echo \"{{arg1}} {{arg2}}\"

      bar: (foo `value1`)
        echo \"bar\"
      "
    })
    .error(
      "Dependency `foo` requires 2 arguments, but 1 provided",
      lsp::Range::at(3, 5, 3, 19),
    )
    .run();
  }

  #[test]
  fn recipe_invocation_too_many_args() {
    Test::new(indoc! {
      "
      foo arg1:
        echo \"{{arg1}}\"

      bar: (foo `value1` `value2` `value3`)
        echo \"bar\"
      "
    })
    .error(
      "Dependency `foo` accepts 1 argument, but 3 provided",
      lsp::Range::at(3, 5, 3, 37),
    )
    .run();
  }

  #[test]
  fn recipe_invocation_unknown_variable() {
    Test::new(indoc! {
      "
      foo arg1:
        echo {{ arg1 }}

      bar: (foo wow)
        echo \"bar\"
      "
    })
    .error("Variable `wow` not found", lsp::Range::at(3, 10, 3, 13))
    .run();
  }

  #[test]
  fn recipe_invocation_valid_variable() {
    Test::new(indoc! {
      "
      wow := `foo`

      foo arg1:
        echo \"{{arg1}}\"

      bar: (foo wow)
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_invocation_variadic_params() {
    Test::new(indoc! {
      "
      foo arg1 +args:
        echo \"{{arg1}} {{args}}\"

      bar: (foo 'value1' 'value2' 'value3')
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_invocation_zero_or_more_variadic_accepts_no_arguments() {
    Test::new(indoc! {
      "
      foo *args:
        echo \"{{args}}\"

      bar: (foo)
        echo \"bar\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_line_continuations_allow_extra_indentation() {
    Test::new(indoc! {
      "
      update-mdbook-theme:
        curl \\
          https://example.com/resource \\
          > docs/theme/index.hbs
      "
    })
    .run();
  }

  #[test]
  fn recipe_mixed_indentation_between_lines() {
    Test::new(indoc! {
      "
      foo:
      \techo \"foo\"
        echo \"bar\"
      "
    })
    .error(
      "Recipe `foo` mixes tabs and spaces for indentation",
      lsp::Range::at(3, 0, 3, 2),
    )
    .run();
  }

  #[test]
  fn recipe_mixed_indentation_single_line_mix() {
    Test::new(indoc! {
      "
      foo:
   \t  echo \"foo\"
      "
    })
    .error(
      "Recipe `foo` mixes tabs and spaces for indentation",
      lsp::Range::at(2, 0, 2, 3),
    )
    .run();
  }

  #[test]
  fn recipe_named_import() {
    Test::new(indoc! {
      r"
      run: import

      import:
        body
      "
    })
    .run();
  }

  #[test]
  fn recipe_parameters_defaults_all() {
    Test::new(indoc! {
      "
      recipe_with_defaults arg1=\"first\" arg2=\"second\":
        echo \"{{arg1}} {{arg2}}\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_parameters_duplicate() {
    Test::new(indoc! {
      "
      recipe_with_duplicate_param arg1 arg1:
        echo \"{{arg1}}\"
      "
    })
    .error("Duplicate parameter `arg1`", lsp::Range::at(0, 33, 0, 37))
    .run();
  }

  #[test]
  fn recipe_parameters_order() {
    Test::new(indoc! {
      "
      recipe_with_param_order arg1=\"default\" arg2:
        echo \"{{arg1}} {{arg2}}\"
      "
    })
    .error(
      "Required parameter `arg2` follows a parameter with a default value",
      lsp::Range::at(0, 39, 0, 43),
    )
    .run();
  }

  #[test]
  fn recipe_parameters_valid() {
    Test::new(indoc! {
      "
      valid_recipe arg1 arg2=\"default\":
        echo \"{{arg1}} {{arg2}}\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_parameters_variadic() {
    Test::new(indoc! {
      "
      recipe_with_variadic arg1=\"default\" +args:
        echo \"{{arg1}} {{args}}\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_with_all_os_attributes() {
    Test::new(indoc! {
      "
      [linux]
      [windows]
      [unix]
      [macos]
      [dragonfly]
      [freebsd]
      [netbsd]
      [openbsd]
      build:
        echo \"Building everywhere\"

      test:
        echo \"Testing\"
      "
    })
    .run();
  }

  #[test]
  fn recipe_with_conflicting_multiple_os_attributes() {
    Test::new(indoc! {
      "
      [linux]
      [openbsd]
      build:
        echo \"Building on Linux and OpenBSD\"

      [linux]
      build:
        echo \"Building on Linux again\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(5, 0, 8, 0))
    .run();
  }

  #[test]
  fn recipe_with_multiple_os_attributes() {
    Test::new(indoc! {
      "
      [windows]
      [linux]
      build:
        echo \"Building on Linux or Windows\"

      [linux]
      build:
        echo \"Building on macOS\"

      [macos]
      build:
        echo \"Building on macOS\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(5, 0, 9, 0))
    .run();
  }

  #[test]
  fn rule_config_off_suppresses_diagnostic() {
    let config = serde_json::from_value::<Config>(serde_json::json!({
      "rules": {
        "unused-variables": "off"
      }
    }))
    .unwrap();

    Test::new(indoc! {
      "
      foo := \"unused value\"

      recipe:
        echo foo
      "
    })
    .config(config)
    .run();
  }

  #[test]
  fn rule_config_overrides_severity_to_error() {
    let config = serde_json::from_value::<Config>(serde_json::json!({
      "rules": {
        "unused-variables": "error"
      }
    }))
    .unwrap();

    Test::new(indoc! {
      "
      foo := \"unused value\"

      recipe:
        echo foo
      "
    })
    .config(config)
    .error("Variable `foo` appears unused", lsp::Range::at(0, 0, 0, 3))
    .run();
  }

  #[test]
  fn rule_config_overrides_severity_to_warning() {
    let config = serde_json::from_value::<Config>(serde_json::json!({
      "rules": {
        "missing-dependencies": "warning"
      }
    }))
    .unwrap();

    Test::new(indoc! {
      "
      foo:
        echo \"foo\"

      bar: baz
        echo \"bar\"
      "
    })
    .config(config)
    .warning("Recipe `baz` not found", lsp::Range::at(3, 5, 3, 8))
    .run();
  }

  #[test]
  fn script_attribute_with_shebang_conflict() {
    Test::new(indoc! {
      "
      [script]
      publish:
        #!/usr/bin/env bash
        echo \"publish\"
      "
    })
    .error(
      "Recipe `publish` has both shebang line and `[script]` attribute",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn script_attribute_without_shebang_is_allowed() {
    Test::new(indoc! {
      "
      [script]
      publish:
        echo \"publish\"
      "
    })
    .run();
  }

  #[test]
  fn set_export_suppresses_unused_variable_warnings() {
    Test::new(indoc! {
      "
      set export

      foo := 'bar'
      baz := 'qux'

      recipe:
        echo $foo
      "
    })
    .run();
  }

  #[test]
  fn settings_boolean_shorthand() {
    Test::new(indoc! {
      "
      set export

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_boolean_type_correct() {
    Test::new(indoc! {
      "
      set export := true
      set dotenv-load := false

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_boolean_type_error() {
    Test::new(indoc! {
      "
      set export := 'foo'

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Setting `export` expects a boolean value",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn settings_boolean_type_error_with_expression() {
    Test::new(indoc! {
      "
      env := 'true'
      set export := env

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Setting `export` expects a boolean value",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn settings_duplicate() {
    Test::new(indoc! {
      "
      set export := true
      set shell := [\"bash\", \"-c\"]
      set export := false

      foo:
        echo \"foo\"
      "
    })
    .error("Duplicate setting `export`", lsp::Range::at(2, 0, 3, 0))
    .run();
  }

  #[test]
  fn settings_guards_recognized() {
    Test::new(indoc! {
      "
      set guards

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_lazy_recognized() {
    Test::new(indoc! {
      "
      set lazy

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_multiple_errors() {
    Test::new(indoc! {
      "
      set unknown-setting := true
      set export := false
      set shell := ['bash']
      set export := false

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Unknown setting `unknown-setting`",
      lsp::Range::at(0, 0, 1, 0),
    )
    .error("Duplicate setting `export`", lsp::Range::at(3, 0, 4, 0))
    .run();
  }

  #[test]
  fn settings_shell_array_accepts_shell_expanded_strings() {
    Test::new(indoc! {
      r#"
      set shell := [x"${SHELL_BIN:-bash}", x"-c"]

      foo:
        echo "foo"
      "#
    })
    .run();
  }

  #[test]
  fn settings_string_type_correct() {
    Test::new(indoc! {
      "
      set dotenv-path := \".env.development\"

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_string_type_correct_with_expression() {
    Test::new(indoc! {
      "
      env := 'development'
      set dotenv-path := '.env.' + env

      foo:
        echo \"foo\"
      "
    })
    .run();
  }

  #[test]
  fn settings_string_type_correct_with_shell_expanded_string() {
    Test::new(indoc! {
      r#"
      set dotenv-path := x"~/.env.${JUST_ENV:-development}"

      foo:
        echo "foo"
      "#
    })
    .run();
  }

  #[test]
  fn settings_string_type_error() {
    Test::new(indoc! {
      "
      set dotenv-path := true

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Setting `dotenv-path` expects a string value",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn settings_unknown() {
    Test::new(indoc! {
      "
      set unknown-setting := true

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Unknown setting `unknown-setting`",
      lsp::Range::at(0, 0, 1, 0),
    )
    .run();
  }

  #[test]
  fn settings_unknown_with_expression() {
    Test::new(indoc! {
      "
      value := 'bar'
      set unknown-setting := value

      foo:
        echo \"foo\"
      "
    })
    .error(
      "Unknown setting `unknown-setting`",
      lsp::Range::at(1, 0, 2, 0),
    )
    .run();
  }

  #[test]
  fn shadowed_parameter_default_uses_global_variable() {
    Test::new(indoc! {
      "
      a := 'default a'

      b a=a:
        echo {{ a }}
      "
    })
    .run();
  }

  #[test]
  fn shebang_recipe_is_exempt_from_inconsistent_indentation() {
    Test::new(indoc! {
      "
      build-docs:
        #!/usr/bin/env bash
        mdbook build docs -d build
        for language in ar de; do
          echo $language
        done
      "
    })
    .run();
  }

  #[test]
  fn should_recognize_recipe_parameters_in_dependency_arguments() {
    Test::new(indoc! {
      "
      other-recipe var=\"else\":
        echo {{ var }}

      test var=\"something\": (other-recipe var)
      "
    })
    .run();
  }

  #[test]
  fn unexported_variables_warned() {
    Test::new(indoc! {
      "
      foo := \"unused value\"
      unexport BAR := \"unexported but unused\"
      baz := \"used value\"

      recipe:
        echo {{ baz }}
      "
    })
    .warning("Variable `foo` appears unused", lsp::Range::at(0, 0, 0, 3))
    .warning("Variable `BAR` appears unused", lsp::Range::at(1, 9, 1, 12))
    .run();
  }

  #[test]
  fn unix_dragonfly_conflict() {
    Test::new(indoc! {
      "
      [unix]
      build:
        echo \"Building on Unix systems\"

      [dragonfly]
      build:
        echo \"Building on DragonFly BSD\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn unix_freebsd_conflict() {
    Test::new(indoc! {
      "
      [unix]
      build:
        echo \"Building on Unix systems\"

      [freebsd]
      build:
        echo \"Building on FreeBSD\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn unix_macos_conflicts() {
    Test::new(indoc! {
      "
      [unix]
      build:
        echo \"Building on Unix systems\"

      [macos]
      build:
        echo \"Building on macOS specifically\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn unix_netbsd_conflict() {
    Test::new(indoc! {
      "
      [unix]
      build:
        echo \"Building on Unix systems\"

      [netbsd]
      build:
        echo \"Building on NetBSD\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 7, 0))
    .run();
  }

  #[test]
  fn unknown_default_recipe_parameter_reference() {
    Test::new(indoc! {
      "
      recipe arg=foo:
        echo {{ arg }}
      "
    })
    .error("Variable `foo` not found", lsp::Range::at(0, 11, 0, 14))
    .run();
  }

  #[test]
  fn unreferenced_variable_in_expression() {
    Test::new(indoc! {
      "
      foo:
        echo {{ var }}
      "
    })
    .error("Variable `var` not found", lsp::Range::at(1, 10, 1, 13))
    .run();
  }

  #[test]
  fn used_variables_no_warnings() {
    Test::new(indoc! {
      "
      foo := \"used in recipe\"
      bar := \"used as dependency arg\"

      another arg:
        echo {{ arg }}

      recipe: (another bar)
        echo {{ foo }}
      "
    })
    .run();
  }

  #[test]
  fn user_defined_function_body_references_variable() {
    Test::new(indoc! {
      "
      base := \"hello\"

      foo(x) := base + x
      "
    })
    .run();
  }

  #[test]
  fn user_defined_function_body_unknown_identifier() {
    Test::new(indoc! {
      "
      foo(x) := x + unknown
      "
    })
    .error("Variable `unknown` not found", lsp::Range::at(0, 14, 0, 21))
    .run();
  }

  #[test]
  fn user_defined_function_duplicate_parameters() {
    Test::new(indoc! {
      "
      foo(bar, bar) := bar
      "
    })
    .error("Duplicate parameter `bar`", lsp::Range::at(0, 9, 0, 12))
    .run();
  }

  #[test]
  fn user_defined_function_duplicates() {
    Test::new(indoc! {
      "
      foo() := \"bar\"
      foo() := \"baz\"
      foo() := \"bat\"
      "
    })
    .error("Duplicate function `foo`", lsp::Range::at(1, 0, 2, 0))
    .error("Duplicate function `foo`", lsp::Range::at(2, 0, 3, 0))
    .run();
  }

  #[test]
  fn user_defined_function_no_params() {
    Test::new(indoc! {
      "
      foo() := \"bar\"

      baz:
        echo {{ foo() }}
      "
    })
    .run();
  }

  #[test]
  fn user_defined_function_not_flagged_as_unknown() {
    Test::new(indoc! {
      "
      foo(x) := x + \"!\"

      bar:
        echo {{ foo(\"baz\") }}
      "
    })
    .run();
  }

  #[test]
  fn user_defined_function_parameters_not_unresolved() {
    Test::new(indoc! {
      "
      foo(x) := x + \"!\"
      "
    })
    .run();
  }

  #[test]
  fn user_defined_function_too_few_args() {
    Test::new(indoc! {
      "
      foo(a, b) := a + b

      bar:
        echo {{ foo(\"a\") }}
      "
    })
    .error(
      "Function `foo` accepts 2 arguments, but 1 provided",
      lsp::Range::at(3, 10, 3, 18),
    )
    .run();
  }

  #[test]
  fn user_defined_function_wrong_arity() {
    Test::new(indoc! {
      "
      foo(x) := x + \"!\"

      bar:
        echo {{ foo(\"a\", \"b\") }}
      "
    })
    .error(
      "Function `foo` accepts 1 argument, but 2 provided",
      lsp::Range::at(3, 10, 3, 23),
    )
    .run();
  }

  #[test]
  fn variables_and_parameters_same_name() {
    Test::new(indoc! {
      "
      param := \"variable value\"
      other := \"other value\"

      recipe param:
        # This should reference the parameter, not the variable
        echo {{ param }}
        echo {{ other }}
      "
    })
    .warning(
      "Variable `param` appears unused",
      lsp::Range::at(0, 0, 0, 5),
    )
    .run();
  }

  #[test]
  fn variables_used_after_hash_in_command() {
    Test::new(indoc! {
      "
      flake := \"testflake\"
      output := \"testoutput\"

      test:
        darwin-rebuild switch --flake {{ flake }}#{{ output }}
      "
    })
    .run();
  }

  #[test]
  fn variables_used_in_dependency_args() {
    Test::new(indoc! {
      "
      used_arg := \"value\"
      unused_var := \"not used\"

      recipe: (another used_arg)
        echo \"something\"

      another arg:
        echo {{ arg }}
      "
    })
    .warning(
      "Variable `unused_var` appears unused",
      lsp::Range::at(1, 0, 1, 10),
    )
    .run();
  }

  #[test]
  fn variables_used_in_multiple_recipes() {
    Test::new(indoc! {
      "
      shared := \"shared value\"
      only_in_first := \"first value\"
      only_in_second := \"second value\"
      never_used := \"unused\"

      first:
        echo {{ shared }}
        echo {{ only_in_first }}

      second:
        echo {{ shared }}
        echo {{ only_in_second }}
      "
    })
    .warning(
      "Variable `never_used` appears unused",
      lsp::Range::at(3, 0, 3, 10),
    )
    .run();
  }

  #[test]
  fn variables_used_in_recipe_default_parameters() {
    Test::new(indoc! {
      "
      param_value := \"value\"

      recipe arg=param_value:
        echo {{ arg }}
      "
    })
    .run();
  }

  #[test]
  fn variables_used_in_recipe_dependencies() {
    Test::new(indoc! {
      "
      param_value := \"value\"
      unused := \"unused\"

      recipe arg=\"default\": (another param_value)
        echo {{ arg }}

      another arg:
        echo {{ arg }}
      "
    })
    .warning(
      "Variable `unused` appears unused",
      lsp::Range::at(1, 0, 1, 6),
    )
    .run();
  }

  #[test]
  fn warn_for_unused_non_exported_recipe_parameters() {
    Test::new(indoc! {
      "
      foo bar:
        echo foo
      "
    })
    .warning("Parameter `bar` appears unused", lsp::Range::at(0, 4, 0, 7))
    .run();

    Test::new(indoc! {
      "
      foo $bar:
        echo foo
      "
    })
    .run();

    Test::new(indoc! {
      "
      set export := false

      foo bar:
        echo foo
      "
    })
    .warning("Parameter `bar` appears unused", lsp::Range::at(2, 4, 2, 7))
    .run();

    Test::new(indoc! {
      "
      set export

      foo bar:
        echo foo
      "
    })
    .run();
  }

  #[test]
  fn warn_for_unused_variables() {
    Test::new(indoc! {
      "
      foo := \"unused value\"
      bar := \"used value\"

      recipe:
        echo {{ bar }}
      "
    })
    .warning("Variable `foo` appears unused", lsp::Range::at(0, 0, 0, 3))
    .run();
  }

  #[test]
  fn windows_recipe_conflicts_with_default() {
    Test::new(indoc! {
      "
      [windows]
      build:
        echo \"Building on Windows\"

      build:
        echo \"Building on every OS\"
      "
    })
    .error("Duplicate recipe name `build`", lsp::Range::at(4, 0, 6, 0))
    .run();
  }
}
