use super::*;

#[derive(Debug)]
pub struct Document {
  pub content: Rope,
  pub tree: Option<Tree>,
  pub uri: lsp::Url,
  pub version: i32,
}

impl Document {
  #[must_use]
  pub fn aliases(&self) -> Vec<Alias> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("alias")
        .iter()
        .filter_map(|alias_node| {
          let left_node = alias_node.child_by_field_name("left")?;
          let right_node = alias_node.child_by_field_name("right")?;

          Some(Alias {
            attributes: self.attributes_for_node(alias_node),
            name: TextNode {
              value: self.get_node_text(&left_node),
              range: left_node.get_range(self),
            },
            value: TextNode {
              value: self.get_node_text(&right_node),
              range: right_node.get_range(self),
            },
            range: alias_node.get_range(self),
          })
        })
        .collect()
    })
  }

  /// Applies incremental edits from the client and reparses the syntax tree.
  ///
  /// # Errors
  ///
  /// Returns an [`Error`] if tree-sitter fails to parse the updated document.
  pub fn apply_change(
    &mut self,
    params: lsp::DidChangeTextDocumentParams,
  ) -> Result {
    let lsp::DidChangeTextDocumentParams {
      content_changes,
      text_document: lsp::VersionedTextDocumentIdentifier { version, .. },
      ..
    } = params;

    self.version = version;

    for change in content_changes {
      let edit = self.content.build_edit(&change);

      self.content.apply_edit(&edit);

      if let Some(tree) = &mut self.tree {
        tree.edit(&edit.input_edit);
      }
    }

    self.parse()?;

    Ok(())
  }

  #[must_use]
  pub fn attributes(&self) -> Vec<Attribute> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("attribute")
        .into_iter()
        .flat_map(|node| self.attributes_for_node(&node))
        .collect()
    })
  }

  pub(super) fn attributes_for_node(&self, node: &Node) -> Vec<Attribute> {
    let (attributes, target) = if node.kind() == "attribute" {
      (
        vec![*node],
        node
          .parent()
          .and_then(|parent| AttributeTarget::try_from_kind(parent.kind())),
      )
    } else {
      (
        node.find_all("attribute"),
        AttributeTarget::try_from_kind(node.kind()),
      )
    };

    attributes
      .into_iter()
      .flat_map(|attribute| {
        attribute
          .find_all("^identifier")
          .into_iter()
          .map(move |identifier| {
            let arguments = identifier
              .siblings()
              .take_while(|sibling| sibling.kind() != "identifier")
              .filter(|sibling| {
                sibling.start_byte() != sibling.end_byte()
                  && matches!(
                    sibling.kind(),
                    "string" | "expression" | "attribute_named_param"
                  )
              })
              .map(|argument| TextNode {
                value: self.get_node_text(&argument),
                range: argument.get_range(self),
              })
              .collect::<Vec<_>>();

            Attribute {
              name: TextNode {
                value: self.get_node_text(&identifier),
                range: identifier.get_range(self),
              },
              arguments,
              target,
              range: attribute.get_range(self),
            }
          })
          .collect::<Vec<_>>()
      })
      .collect()
  }

  #[must_use]
  pub fn find_function(&self, name: &str) -> Option<Function> {
    self
      .functions()
      .into_iter()
      .find(|function| function.name.value == name)
  }

  #[must_use]
  pub fn find_recipe(&self, name: &str) -> Option<Recipe> {
    self
      .recipes()
      .into_iter()
      .find(|recipe| recipe.name.value == name)
  }

  #[must_use]
  pub fn find_variable(&self, name: &str) -> Option<Variable> {
    self
      .variables()
      .into_iter()
      .find(|var| var.name.value == name)
  }

  /// # Errors
  ///
  /// Returns an [`Error`] if formatting fails.
  pub fn format(&self, config: &FormattingConfig) -> Result<String> {
    let file = if let Ok(path) = self.uri.to_file_path() {
      tempfile::Builder::new()
        .prefix(".justfile-fmt-")
        .tempfile_in(
          path
            .parent()
            .ok_or_else(|| Error::Format("file path has no parent".into()))?,
        )?
    } else {
      tempfile::Builder::new()
        .prefix(".justfile-fmt-")
        .tempfile()?
    };

    let content = self.content.to_string();

    fs::write(&file, content.as_bytes())?;

    let mut command = process::Command::new("just");

    command.arg("--fmt").arg("--unstable").arg("--quiet");

    if let Some(indentation) = &config.indentation {
      command.arg("--indentation").arg(indentation);
    }

    let output = command.arg("--justfile").arg(file.path()).output()?;

    if !output.status.success() {
      return Err(Error::Format(format!(
        "just formatting failed: {}",
        String::from_utf8_lossy(&output.stderr)
      )));
    }

    Ok(fs::read_to_string(&file)?)
  }

  #[must_use]
  pub fn function_calls(&self) -> Vec<FunctionCall> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("function_call")
        .into_iter()
        .filter_map(|function_call_node| {
          let identifier_node = function_call_node.find("identifier")?;

          let arguments = function_call_node
            .find("sequence")
            .map(|sequence| {
              sequence
                .find_all("^expression")
                .into_iter()
                .map(|argument_node| TextNode {
                  value: self.get_node_text(&argument_node),
                  range: argument_node.get_range(self),
                })
                .collect::<Vec<_>>()
            })
            .unwrap_or_default();

          Some(FunctionCall {
            name: TextNode {
              value: self.get_node_text(&identifier_node),
              range: identifier_node.get_range(self),
            },
            arguments,
            range: function_call_node.get_range(self),
          })
        })
        .collect()
    })
  }

  #[must_use]
  pub fn functions(&self) -> Vec<Function> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("function_definition")
        .iter()
        .filter_map(|function_node| {
          let name_node = function_node.child_by_field_name("name")?;

          let parameters = function_node
            .child_by_field_name("parameters")
            .map(|params_node| {
              params_node
                .find_all("^identifier")
                .iter()
                .map(|param_node| TextNode {
                  value: self.get_node_text(param_node),
                  range: param_node.get_range(self),
                })
                .collect::<Vec<_>>()
            })
            .unwrap_or_default();

          let body = function_node
            .child_by_field_name("body")
            .map(|body_node| self.get_node_text(&body_node))
            .unwrap_or_default();

          Some(Function {
            attributes: self.attributes_for_node(function_node),
            name: TextNode {
              value: self.get_node_text(&name_node),
              range: name_node.get_range(self),
            },
            parameters,
            body,
            content: self.get_node_text(function_node).trim().to_string(),
            range: function_node.get_range(self),
          })
        })
        .collect()
    })
  }

  #[must_use]
  pub fn get_node_text(&self, node: &Node) -> String {
    self
      .content
      .slice(
        self.content.byte_to_char(node.start_byte())
          ..self.content.byte_to_char(node.end_byte()),
      )
      .to_string()
  }

  #[must_use]
  pub fn imports(&self) -> Vec<Import> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("import")
        .iter()
        .filter_map(|import_node| {
          let path_node = import_node.find("string")?;

          let content = self.get_node_text(import_node);

          Some(Import {
            attributes: self.attributes_for_node(import_node),
            optional: content.contains('?'),
            path: TextNode {
              value: self.get_node_text(&path_node),
              range: path_node.get_range(self),
            },
            range: import_node.get_range(self),
          })
        })
        .collect()
    })
  }

  #[must_use]
  pub fn modules(&self) -> Vec<Module> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("module")
        .iter()
        .filter_map(|module_node| {
          let name_node = module_node.child_by_field_name("name")?;

          let content = self.get_node_text(module_node);

          let path = module_node.find("string").map(|path_node| TextNode {
            value: self.get_node_text(&path_node),
            range: path_node.get_range(self),
          });

          Some(Module {
            attributes: self.attributes_for_node(module_node),
            name: TextNode {
              value: self.get_node_text(&name_node),
              range: name_node.get_range(self),
            },
            optional: content.contains('?'),
            path,
            range: module_node.get_range(self),
          })
        })
        .collect()
    })
  }

  /// # Errors
  ///
  /// Returns an [`Error`] if the tree-sitter parser cannot be created or the
  /// contents fail to parse.
  pub fn new(source: &str, uri: lsp::Url) -> Result<Self> {
    let mut document = Self {
      content: Rope::from_str(source),
      tree: None,
      uri,
      version: 0,
    };

    document.parse()?;

    Ok(document)
  }

  /// Returns the syntax tree node at the given LSP `Position`.
  #[must_use]
  pub fn node_at_position(&self, position: lsp::Position) -> Option<Node<'_>> {
    let tree = self.tree.as_ref()?;
    let point = position.point(self);
    tree.root_node().descendant_for_point_range(point, point)
  }

  /// Parses the current document contents and updates the cached syntax tree.
  ///
  /// # Errors
  ///
  /// Returns an [`Error`] if the tree-sitter parser cannot be created or the
  /// contents fail to parse.
  pub fn parse(&mut self) -> Result {
    let mut parser = Parser::new();

    // SAFETY: tree_sitter_just returns a static language definition.
    parser.set_language(&unsafe { tree_sitter_just() })?;

    let old_tree = self.tree.take();

    self.tree = parser.parse(self.content.to_string(), old_tree.as_ref());

    Ok(())
  }

  #[must_use]
  pub fn recipes(&self) -> Vec<Recipe> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("recipe")
        .iter()
        .filter_map(|recipe_node| {
          let name_node = recipe_node.find("recipe_header > identifier")?;

          let recipe_name = TextNode {
            value: self.get_node_text(&name_node),
            range: name_node.get_range(self),
          };

          let attributes = recipe_node
            .find_all("attribute")
            .into_iter()
            .flat_map(|attribute_node| {
              attribute_node
                .find_all("^identifier")
                .into_iter()
                .map(|identifier_node| {
                  let arguments = identifier_node
                    .siblings()
                    .take_while(|sibling| sibling.kind() != "identifier")
                    .filter(|sibling| {
                      sibling.start_byte() != sibling.end_byte()
                        && matches!(
                          sibling.kind(),
                          "string" | "expression" | "attribute_named_param"
                        )
                    })
                    .map(|argument_node| TextNode {
                      value: self.get_node_text(&argument_node),
                      range: argument_node.get_range(self),
                    })
                    .collect::<Vec<_>>();

                  Attribute {
                    name: TextNode {
                      value: self.get_node_text(&identifier_node),
                      range: identifier_node.get_range(self),
                    },
                    arguments,
                    target: Some(AttributeTarget::Recipe),
                    range: attribute_node.get_range(self),
                  }
                })
                .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

          let dependencies = recipe_node
            .find("recipe_header > dependencies")
            .map(|dependencies_node| {
              let mut dependencies = Vec::new();
              let mut phase = DependencyPhase::Prior;

              for index in 0..dependencies_node.child_count() {
                let Ok(index) = index.try_into() else {
                  continue;
                };

                let Some(node) = dependencies_node.child(index) else {
                  continue;
                };

                match node.kind() {
                  "&&" => {
                    phase = DependencyPhase::Subsequent;
                  }
                  "dependency" => {
                    let dependency_node = node;

                    let Some(dependency_name) = dependency_node
                      .child_by_field_name("name")
                      .or_else(|| {
                        dependency_node
                          .find("dependency_expression")
                          .and_then(|node| node.child_by_field_name("name"))
                      })
                      .map(|node| self.get_node_text(&node))
                    else {
                      continue;
                    };

                    let arguments = dependency_node
                      .find("dependency_expression")
                      .map(|dependency_expression_node| {
                        let mut cursor = dependency_expression_node.walk();

                        dependency_expression_node
                          .named_children(&mut cursor)
                          .filter_map(|argument_node| {
                            match argument_node.kind() {
                              "expression" => Some(DependencyArgument {
                                value: self.get_node_text(&argument_node),
                                range: argument_node.get_range(self),
                                starred: None,
                              }),
                              "starred_dependency_argument" => {
                                let value_node = argument_node
                                  .child_by_field_name("argument")?;

                                Some(DependencyArgument {
                                  value: self.get_node_text(&value_node),
                                  range: value_node.get_range(self),
                                  starred: argument_node
                                    .child_by_field_name("star")
                                    .map(|node| node.get_range(self)),
                                })
                              }
                              _ => None,
                            }
                          })
                          .collect()
                      })
                      .unwrap_or_default();

                    let mapped = dependency_node
                      .find("dependency_expression")
                      .and_then(|dependency_expression_node| {
                        dependency_expression_node
                          .child_by_field_name("map")
                          .map(|node| node.get_range(self))
                      });

                    dependencies.push(Dependency {
                      name: dependency_name,
                      arguments,
                      mapped,
                      phase,
                      range: dependency_node.get_range(self),
                    });
                  }
                  _ => {}
                }
              }

              dependencies
            })
            .unwrap_or_default();

          let parameters = recipe_node
            .find("recipe_header > parameters")
            .map_or_else(Vec::new, |parameters_node| {
              parameters_node
                .find_all("^parameter, ^variadic_parameter")
                .iter()
                .filter_map(|parameter_node| {
                  Parameter::from_node(parameter_node, self)
                })
                .collect()
            });

          let shebang =
            recipe_node
              .find("recipe_body > shebang")
              .map(|shebang_node| TextNode {
                value: self.get_node_text(&shebang_node),
                range: shebang_node.get_range(self),
              });

          Some(Recipe {
            name: recipe_name,
            attributes,
            dependencies,
            content: self.get_node_text(recipe_node).trim().to_string(),
            parameters,
            range: recipe_node.get_range(self),
            shebang,
          })
        })
        .collect()
    })
  }

  #[must_use]
  pub fn settings(&self) -> Vec<Setting> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("setting")
        .iter()
        .filter_map(|setting_node| Setting::from_node(setting_node, self))
        .collect()
    })
  }

  #[must_use]
  pub fn unexports(&self) -> Vec<Unexport> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("unexport")
        .iter()
        .filter_map(|unexport_node| {
          let name_node = unexport_node.child_by_field_name("name")?;

          Some(Unexport {
            attributes: self.attributes_for_node(unexport_node),
            name: TextNode {
              value: self.get_node_text(&name_node),
              range: name_node.get_range(self),
            },
            range: unexport_node.get_range(self),
          })
        })
        .collect()
    })
  }

  #[must_use]
  pub fn variables(&self) -> Vec<Variable> {
    self.tree.as_ref().map_or(Vec::new(), |tree| {
      tree
        .root_node()
        .find_all("assignment")
        .iter()
        .filter_map(|assignment_node| {
          let identifier_node = assignment_node.child_by_field_name("left")?;

          let attribute_node = assignment_node
            .parent()
            .filter(|parent| matches!(parent.kind(), "eager" | "export"))
            .unwrap_or(*assignment_node);

          Some(Variable {
            attributes: self.attributes_for_node(&attribute_node),
            name: TextNode {
              value: self.get_node_text(&identifier_node),
              range: identifier_node.get_range(self),
            },
            export: identifier_node.get_parent("export").is_some(),
            content: self.get_node_text(assignment_node).trim().to_string(),
            range: assignment_node.get_range(self),
          })
        })
        .collect()
    })
  }
}

impl From<&str> for Document {
  fn from(value: &str) -> Self {
    let mut document = Self {
      content: value.into(),
      tree: None,
      uri: lsp::Url::parse("file:///test.just").unwrap(),
      version: 1,
    };

    document.parse().unwrap();

    document
  }
}

impl TryFrom<lsp::DidOpenTextDocumentParams> for Document {
  type Error = Error;

  fn try_from(params: lsp::DidOpenTextDocumentParams) -> Result<Self> {
    let lsp::TextDocumentItem {
      text, uri, version, ..
    } = params.text_document;

    let mut document = Self {
      content: Rope::from_str(&text),
      tree: None,
      uri,
      version,
    };

    document.parse()?;

    Ok(document)
  }
}

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

  #[test]
  fn apply_change() {
    let mut document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"
      "
    });

    let original_content = document.content.to_string();

    let change = lsp::DidChangeTextDocumentParams {
      text_document: lsp::VersionedTextDocumentIdentifier {
        uri: lsp::Url::parse("file:///test.just").unwrap(),
        version: 2,
      },
      content_changes: vec![lsp::TextDocumentContentChangeEvent {
        range: Some(lsp::Range::at(1, 7, 1, 13)),
        range_length: None,
        text: "\"bar\"".to_string(),
      }],
    };

    document.apply_change(change).unwrap();

    assert_ne!(document.content.to_string(), original_content);
    assert_eq!(document.content.to_string(), "foo:\n  echo \"bar\"");
  }

  #[test]
  fn create_document() {
    let content = indoc! {"
      foo:
        echo foo
    "};

    let document = Document::from(content);

    assert_eq!(document.content.to_string(), content);

    assert!(document.tree.is_some());
  }

  #[test]
  fn eager_variable_with_attributes() {
    let document = Document::from(indoc! {
      "
      [private]
      eager foo := 'bar'
      "
    });

    assert_eq!(
      document.variables(),
      vec![Variable {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "private".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Assignment),
        }],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(1, 6, 1, 9),
        },
        export: false,
        content: "foo := 'bar'".into(),
        range: lsp::Range::at(1, 6, 2, 0),
      }]
    );
  }

  #[test]
  fn find_function() {
    let document = Document::from(indoc! {
      "
      foo(x) := x + \"!\"
      "
    });

    assert!(document.find_function("foo").is_some());
    assert!(document.find_function("bar").is_none());
  }

  #[test]
  fn find_nonexistent_recipe() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"
      "
    });

    assert_eq!(document.find_recipe("nonexistent"), None);
  }

  #[test]
  fn find_recipe() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar:
        echo \"bar\"
      "
    });

    assert_eq!(
      document.find_recipe("foo").unwrap(),
      Recipe {
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        content: "foo:\n  echo \"foo\"".into(),
        parameters: vec![],
        range: lsp::Range::at(0, 0, 3, 0),
        shebang: None,
      }
    );

    assert_eq!(
      document.find_recipe("bar").unwrap(),
      Recipe {
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(3, 0, 3, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        content: "bar:\n  echo \"bar\"".into(),
        parameters: vec![],
        range: lsp::Range::at(3, 0, 5, 0),
        shebang: None,
      }
    );

    assert!(document.find_recipe("baz").is_none());
  }

  #[test]
  fn function_no_parameters() {
    let document = Document::from(indoc! {
      "
      foo() := \"bar\"
      "
    });

    assert_eq!(
      document.functions(),
      vec![Function {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 0, 0, 3),
        },
        parameters: vec![],
        body: "\"bar\"".into(),
        content: "foo() := \"bar\"".into(),
        range: lsp::Range::at(0, 0, 1, 0),
      }],
    );
  }

  #[test]
  fn function_with_attributes() {
    let document = Document::from(indoc! {
      "
      [macos]
      foo() := 'bar'
      "
    });

    assert_eq!(
      document.functions(),
      vec![Function {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "macos".into(),
            range: lsp::Range::at(0, 1, 0, 6),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Function),
        }],
        body: "'bar'".into(),
        content: "[macos]\nfoo() := 'bar'".into(),
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(1, 0, 1, 3),
        },
        parameters: vec![],
        range: lsp::Range::at(0, 0, 2, 0),
      }],
    );
  }

  #[test]
  fn get_alias_with_attributes() {
    let document = Document::from(indoc! {
      "
      [linux]
      alias foo := bar
      "
    });

    let aliases = document.aliases();

    assert_eq!(aliases.len(), 1);

    assert_eq!(
      aliases,
      vec![Alias {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "linux".into(),
            range: lsp::Range::at(0, 1, 0, 6),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Alias),
        }],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(1, 6, 1, 9),
        },
        range: lsp::Range::at(0, 0, 1, 16),
        value: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(1, 13, 1, 16),
        },
      }]
    );
  }

  #[test]
  fn get_alias_with_module_path() {
    let document = Document::from(indoc! {
      "
      alias a1 := tools::build
      "
    });

    let aliases = document.aliases();

    assert_eq!(aliases.len(), 1);

    assert_eq!(
      aliases,
      vec![Alias {
        attributes: vec![],
        name: TextNode {
          value: "a1".into(),
          range: lsp::Range::at(0, 6, 0, 8)
        },
        value: TextNode {
          value: "tools::build".into(),
          range: lsp::Range::at(0, 12, 0, 24)
        },
        range: lsp::Range::at(0, 0, 0, 24)
      }]
    );
  }

  #[test]
  fn get_array_setting() {
    let document = Document::from(indoc! {
      "
      set shell := ['foo']
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "shell".into(),
          range: lsp::Range::at(0, 4, 0, 9),
        },
        kind: SettingKind::Array,
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "['foo']".into(),
          range: lsp::Range::at(0, 13, 0, 20),
        },
      }]
    );
  }

  #[test]
  fn get_basic_alias() {
    let document = Document::from(indoc! {
      "
      alias a1 := foo
      "
    });

    let aliases = document.aliases();

    assert_eq!(aliases.len(), 1);

    assert_eq!(
      aliases,
      vec![Alias {
        attributes: vec![],
        name: TextNode {
          value: "a1".into(),
          range: lsp::Range::at(0, 6, 0, 8)
        },
        value: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 12, 0, 15)
        },
        range: lsp::Range::at(0, 0, 0, 15)
      }]
    );
  }

  #[test]
  fn get_boolean_false_setting() {
    let document = Document::from(indoc! {
      "
      set foo := false
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 4, 0, 7),
        },
        kind: SettingKind::Boolean(false),
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "false".into(),
          range: lsp::Range::at(0, 11, 0, 16),
        },
      }]
    );
  }

  #[test]
  fn get_boolean_flag_setting() {
    let document = Document::from(indoc! {
      "
      set export
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "export".into(),
          range: lsp::Range::at(0, 4, 0, 10),
        },
        kind: SettingKind::Boolean(true),
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: String::new(),
          range: lsp::Range::at(0, 10, 0, 10),
        },
      }]
    );
  }

  #[test]
  fn get_boolean_setting() {
    let document = Document::from(indoc! {
      "
      set export := true
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "export".into(),
          range: lsp::Range::at(0, 4, 0, 10),
        },
        kind: SettingKind::Boolean(true),
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "true".into(),
          range: lsp::Range::at(0, 14, 0, 18),
        },
      }]
    );
  }

  #[test]
  fn get_duplicate_aliases() {
    let document = Document::from(indoc! {
      "
      alias duplicate := foo
      alias duplicate := bar
      "
    });

    let aliases = document.aliases();

    assert_eq!(aliases.len(), 2);

    assert_eq!(
      aliases,
      vec![
        Alias {
          attributes: vec![],
          name: TextNode {
            value: "duplicate".into(),
            range: lsp::Range::at(0, 6, 0, 15)
          },
          value: TextNode {
            value: "foo".into(),
            range: lsp::Range::at(0, 19, 0, 22)
          },
          range: lsp::Range::at(0, 0, 0, 22)
        },
        Alias {
          attributes: vec![],
          name: TextNode {
            value: "duplicate".into(),
            range: lsp::Range::at(1, 6, 1, 15)
          },
          value: TextNode {
            value: "bar".into(),
            range: lsp::Range::at(1, 19, 1, 22)
          },
          range: lsp::Range::at(1, 0, 1, 22)
        }
      ]
    );
  }

  #[test]
  fn get_expression_setting() {
    let document = Document::from(indoc! {
      r#"
      set foo := "bar" / baz
      "#
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 4, 0, 7),
        },
        kind: SettingKind::String,
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "\"bar\" / baz".into(),
          range: lsp::Range::at(0, 11, 0, 22),
        },
      }]
    );
  }

  #[test]
  fn get_hyphenated_array_setting() {
    let document = Document::from(indoc! {
      r#"
      set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
      "#
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "windows-shell".into(),
          range: lsp::Range::at(0, 4, 0, 17),
        },
        kind: SettingKind::Array,
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "[\"powershell.exe\", \"-NoLogo\", \"-Command\"]".into(),
          range: lsp::Range::at(0, 21, 0, 62),
        },
      }]
    );
  }

  #[test]
  fn get_multiple_aliases() {
    let document = Document::from(indoc! {
      "
      alias a1 := foo
      alias a2 := bar
      "
    });

    let aliases = document.aliases();

    assert_eq!(aliases.len(), 2);

    assert_eq!(
      aliases,
      vec![
        Alias {
          attributes: vec![],
          name: TextNode {
            value: "a1".into(),
            range: lsp::Range::at(0, 6, 0, 8),
          },
          value: TextNode {
            value: "foo".into(),
            range: lsp::Range::at(0, 12, 0, 15),
          },
          range: lsp::Range::at(0, 0, 0, 15),
        },
        Alias {
          attributes: vec![],
          name: TextNode {
            value: "a2".into(),
            range: lsp::Range::at(1, 6, 1, 8),
          },
          value: TextNode {
            value: "bar".into(),
            range: lsp::Range::at(1, 12, 1, 15),
          },
          range: lsp::Range::at(1, 0, 1, 15),
        }
      ]
    );
  }

  #[test]
  fn get_multiple_settings() {
    let document = Document::from(indoc! {
      "
      set export := true
      set shell := ['foo']
      set bar := 'wow!'
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 3);

    assert_eq!(
      settings,
      vec![
        Setting {
          attributes: vec![],
          name: TextNode {
            value: "export".into(),
            range: lsp::Range::at(0, 4, 0, 10),
          },
          kind: SettingKind::Boolean(true),
          range: lsp::Range::at(0, 0, 1, 0),
          value: TextNode {
            value: "true".into(),
            range: lsp::Range::at(0, 14, 0, 18),
          },
        },
        Setting {
          attributes: vec![],
          name: TextNode {
            value: "shell".into(),
            range: lsp::Range::at(1, 4, 1, 9),
          },
          kind: SettingKind::Array,
          range: lsp::Range::at(1, 0, 2, 0),
          value: TextNode {
            value: "['foo']".into(),
            range: lsp::Range::at(1, 13, 1, 20),
          },
        },
        Setting {
          attributes: vec![],
          name: TextNode {
            value: "bar".into(),
            range: lsp::Range::at(2, 4, 2, 7),
          },
          kind: SettingKind::String,
          range: lsp::Range::at(2, 0, 3, 0),
          value: TextNode {
            value: "'wow!'".into(),
            range: lsp::Range::at(2, 11, 2, 17),
          },
        }
      ]
    );
  }

  #[test]
  fn get_setting_with_attributes() {
    let document = Document::from(indoc! {
      "
      [group(\"foo\")]
      set bar := true
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![Attribute {
          name: TextNode {
            value: "group".into(),
            range: lsp::Range::at(0, 1, 0, 6),
          },
          arguments: vec![TextNode {
            value: "\"foo\"".into(),
            range: lsp::Range::at(0, 7, 0, 12),
          }],
          target: Some(AttributeTarget::Setting),
          range: lsp::Range::at(0, 0, 1, 0),
        }],
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(1, 4, 1, 7),
        },
        kind: SettingKind::Boolean(true),
        range: lsp::Range::at(0, 0, 2, 0),
        value: TextNode {
          value: "true".into(),
          range: lsp::Range::at(1, 11, 1, 15),
        },
      }]
    );
  }

  #[test]
  fn get_string_setting() {
    let document = Document::from(indoc! {
      "
      set bar := 'wow!'
      "
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(0, 4, 0, 7),
        },
        kind: SettingKind::String,
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "'wow!'".into(),
          range: lsp::Range::at(0, 11, 0, 17),
        },
      }]
    );
  }

  #[test]
  fn get_string_setting_containing_walrus() {
    let document = Document::from(indoc! {
      r#"
      set foo := "bar := baz"
      "#
    });

    let settings = document.settings();

    assert_eq!(settings.len(), 1);

    assert_eq!(
      settings,
      vec![Setting {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 4, 0, 7),
        },
        kind: SettingKind::String,
        range: lsp::Range::at(0, 0, 1, 0),
        value: TextNode {
          value: "\"bar := baz\"".into(),
          range: lsp::Range::at(0, 11, 0, 23),
        },
      }]
    );
  }

  #[test]
  fn get_unexports() {
    let document = Document::from(indoc! {
      "
      [windows]
      unexport FOO
      "
    });

    let unexports = document.unexports();

    assert_eq!(unexports.len(), 1);

    assert_eq!(
      unexports,
      vec![Unexport {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "windows".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Unexport),
        }],
        name: TextNode {
          value: "FOO".into(),
          range: lsp::Range::at(1, 9, 1, 12),
        },
        range: lsp::Range::at(0, 0, 2, 0),
      }]
    );
  }

  #[test]
  fn get_variable_with_attributes() {
    let document = Document::from(indoc! {
      "
      [windows]
      foo := 'bar'
      "
    });

    let variables = document.variables();

    assert_eq!(variables.len(), 1);

    assert_eq!(
      variables,
      vec![Variable {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "windows".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Assignment),
        }],
        content: "[windows]\nfoo := 'bar'".into(),
        export: false,
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(1, 0, 1, 3),
        },
        range: lsp::Range::at(0, 0, 2, 0),
      }]
    );
  }

  #[test]
  fn get_variables() {
    let document = Document::from(indoc! {
      "
      tmpdir  := `mktemp -d`
      version := \"0.2.7\"
      tardir  := tmpdir / \"awesomesauce-\" + version
      tarball := tardir + \".tar.gz\"
      config  := quote(config_dir() / \".project-config\")
      export EDITOR := 'nvim'
      "
    });

    assert_eq!(
      document.variables(),
      vec![
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "tmpdir".into(),
            range: lsp::Range::at(0, 0, 0, 6),
          },
          export: false,
          content: "tmpdir  := `mktemp -d`".into(),
          range: lsp::Range::at(0, 0, 1, 0),
        },
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "version".into(),
            range: lsp::Range::at(1, 0, 1, 7),
          },
          export: false,
          content: "version := \"0.2.7\"".into(),
          range: lsp::Range::at(1, 0, 2, 0),
        },
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "tardir".into(),
            range: lsp::Range::at(2, 0, 2, 6),
          },
          export: false,
          content: "tardir  := tmpdir / \"awesomesauce-\" + version".into(),
          range: lsp::Range::at(2, 0, 3, 0),
        },
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "tarball".into(),
            range: lsp::Range::at(3, 0, 3, 7),
          },
          export: false,
          content: "tarball := tardir + \".tar.gz\"".into(),
          range: lsp::Range::at(3, 0, 4, 0),
        },
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "config".into(),
            range: lsp::Range::at(4, 0, 4, 6),
          },
          export: false,
          content: "config  := quote(config_dir() / \".project-config\")"
            .into(),
          range: lsp::Range::at(4, 0, 5, 0),
        },
        Variable {
          attributes: vec![],
          name: TextNode {
            value: "EDITOR".into(),
            range: lsp::Range::at(5, 7, 5, 13),
          },
          export: true,
          content: "EDITOR := 'nvim'".into(),
          range: lsp::Range::at(5, 7, 6, 0),
        },
      ]
    );
  }

  #[test]
  fn imports() {
    let document = Document::from(indoc! {
      "
      [linux]
      import 'foo/bar.just'

      a: b
        @echo A
      "
    });

    assert_eq!(
      document.imports(),
      vec![Import {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "linux".into(),
            range: lsp::Range::at(0, 1, 0, 6),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Import),
        }],
        optional: false,
        path: TextNode {
          value: "'foo/bar.just'".into(),
          range: lsp::Range::at(1, 7, 1, 21),
        },
        range: lsp::Range::at(0, 0, 1, 21),
      }]
    );
  }

  #[test]
  fn list_document_attributes() {
    let document = Document::from(indoc! {
      "
      [private, description: \"desc\"]
      foo:
        echo \"foo\"

      [alias_attr]
      alias build := foo

      [var_attr(\"value\")]
      bar := \"bar\"

      [export_attr]
      export baz := \"baz\"

      [module_attr]
      mod utils \"./utils.just\"

      [function_attr]
      function() := \"function\"

      [import_attr]
      import \"foo.just\"

      [unexport_attr]
      unexport FOO
      "
    });

    let attributes = document.attributes();

    assert_eq!(
      attributes,
      vec![
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "private".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Recipe),
        },
        Attribute {
          arguments: vec![TextNode {
            value: "\"desc\"".into(),
            range: lsp::Range::at(0, 23, 0, 29),
          }],
          name: TextNode {
            value: "description".into(),
            range: lsp::Range::at(0, 10, 0, 21),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Recipe),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "alias_attr".into(),
            range: lsp::Range::at(4, 1, 4, 11),
          },
          range: lsp::Range::at(4, 0, 5, 0),
          target: Some(AttributeTarget::Alias),
        },
        Attribute {
          arguments: vec![TextNode {
            value: "\"value\"".into(),
            range: lsp::Range::at(7, 10, 7, 17),
          }],
          name: TextNode {
            value: "var_attr".into(),
            range: lsp::Range::at(7, 1, 7, 9),
          },
          range: lsp::Range::at(7, 0, 8, 0),
          target: Some(AttributeTarget::Assignment),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "export_attr".into(),
            range: lsp::Range::at(10, 1, 10, 12),
          },
          range: lsp::Range::at(10, 0, 11, 0),
          target: Some(AttributeTarget::Assignment),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "module_attr".into(),
            range: lsp::Range::at(13, 1, 13, 12),
          },
          range: lsp::Range::at(13, 0, 14, 0),
          target: Some(AttributeTarget::Module),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "function_attr".into(),
            range: lsp::Range::at(16, 1, 16, 14),
          },
          range: lsp::Range::at(16, 0, 17, 0),
          target: Some(AttributeTarget::Function),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "import_attr".into(),
            range: lsp::Range::at(19, 1, 19, 12),
          },
          range: lsp::Range::at(19, 0, 20, 0),
          target: Some(AttributeTarget::Import),
        },
        Attribute {
          arguments: vec![],
          name: TextNode {
            value: "unexport_attr".into(),
            range: lsp::Range::at(22, 1, 22, 14),
          },
          range: lsp::Range::at(22, 0, 23, 0),
          target: Some(AttributeTarget::Unexport),
        },
      ],
    );
  }

  #[test]
  fn list_document_attributes_without_target() {
    let document = Document::from(indoc! {
      "
      [private]
      "
    });

    assert_eq!(
      document.attributes(),
      vec![Attribute {
        arguments: vec![],
        name: TextNode {
          value: "private".into(),
          range: lsp::Range::at(0, 1, 0, 8),
        },
        range: lsp::Range::at(0, 0, 1, 0),
        target: None,
      }]
    );
  }

  #[test]
  fn list_function_calls() {
    let document = Document::from(indoc! {
      "
      foo:
        echo {{arch()}}
        echo {{env_var(\"HOME\", \"fallback\")}}
        echo {{show(['foo', 'bar'])}}
      "
    });

    let calls = document.function_calls();

    assert_eq!(
      calls,
      vec![
        FunctionCall {
          arguments: vec![],
          name: TextNode {
            value: "arch".into(),
            range: lsp::Range::at(1, 9, 1, 13),
          },
          range: lsp::Range::at(1, 9, 1, 15),
        },
        FunctionCall {
          arguments: vec![
            TextNode {
              value: "\"HOME\"".into(),
              range: lsp::Range::at(2, 17, 2, 23),
            },
            TextNode {
              value: "\"fallback\"".into(),
              range: lsp::Range::at(2, 25, 2, 35),
            },
          ],
          name: TextNode {
            value: "env_var".into(),
            range: lsp::Range::at(2, 9, 2, 16),
          },
          range: lsp::Range::at(2, 9, 2, 36),
        },
        FunctionCall {
          arguments: vec![TextNode {
            value: "['foo', 'bar']".into(),
            range: lsp::Range::at(3, 14, 3, 28),
          }],
          name: TextNode {
            value: "show".into(),
            range: lsp::Range::at(3, 9, 3, 13),
          },
          range: lsp::Range::at(3, 9, 3, 29),
        },
      ],
    );
  }

  #[test]
  fn list_functions() {
    let document = Document::from(indoc! {
      "
      hello(name) := f\"Hello, \" + name

      greet(a, b) := hello(a) + \" and \" + hello(b)
      "
    });

    assert_eq!(
      document.functions(),
      vec![
        Function {
          attributes: vec![],
          name: TextNode {
            value: "hello".into(),
            range: lsp::Range::at(0, 0, 0, 5),
          },
          parameters: vec![TextNode {
            value: "name".into(),
            range: lsp::Range::at(0, 6, 0, 10),
          }],
          body: "f\"Hello, \" + name".into(),
          content: "hello(name) := f\"Hello, \" + name".into(),
          range: lsp::Range::at(0, 0, 1, 0),
        },
        Function {
          attributes: vec![],
          name: TextNode {
            value: "greet".into(),
            range: lsp::Range::at(2, 0, 2, 5),
          },
          parameters: vec![
            TextNode {
              value: "a".into(),
              range: lsp::Range::at(2, 6, 2, 7),
            },
            TextNode {
              value: "b".into(),
              range: lsp::Range::at(2, 9, 2, 10),
            },
          ],
          body: "hello(a) + \" and \" + hello(b)".into(),
          content: "greet(a, b) := hello(a) + \" and \" + hello(b)".into(),
          range: lsp::Range::at(2, 0, 3, 0),
        },
      ],
    );
  }

  #[test]
  fn module_with_path() {
    let document = Document::from(indoc! {
      r#"
      [private]
      mod foo "./utils.just"
      "#
    });

    assert_eq!(
      document.modules(),
      vec![Module {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "private".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Module),
        }],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(1, 4, 1, 7),
        },
        optional: false,
        path: Some(TextNode {
          value: "\"./utils.just\"".into(),
          range: lsp::Range::at(1, 8, 1, 22),
        }),
        range: lsp::Range::at(0, 0, 1, 22),
      }]
    );
  }

  #[test]
  fn module_without_path() {
    let document = Document::from(indoc! {
      "
      mod foo
      "
    });

    assert_eq!(
      document.modules(),
      vec![Module {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 4, 0, 7),
        },
        optional: false,
        path: None,
        range: lsp::Range::at(0, 0, 0, 7),
      }]
    );
  }

  #[test]
  fn multiple_imports() {
    let document = Document::from(indoc! {
      "
      import 'foo.just'
      import? 'bar.just'
      "
    });

    assert_eq!(
      document.imports(),
      vec![
        Import {
          attributes: vec![],
          optional: false,
          path: TextNode {
            value: "'foo.just'".into(),
            range: lsp::Range::at(0, 7, 0, 17),
          },
          range: lsp::Range::at(0, 0, 0, 17),
        },
        Import {
          attributes: vec![],
          optional: true,
          path: TextNode {
            value: "'bar.just'".into(),
            range: lsp::Range::at(1, 8, 1, 18),
          },
          range: lsp::Range::at(1, 0, 1, 18),
        },
      ]
    );
  }

  #[test]
  fn multiple_modules() {
    let document = Document::from(indoc! {
      r#"
      mod foo
      mod? bar "bar.just"
      "#
    });

    assert_eq!(
      document.modules(),
      vec![
        Module {
          attributes: vec![],
          name: TextNode {
            value: "foo".into(),
            range: lsp::Range::at(0, 4, 0, 7),
          },
          optional: false,
          path: None,
          range: lsp::Range::at(0, 0, 0, 7),
        },
        Module {
          attributes: vec![],
          name: TextNode {
            value: "bar".into(),
            range: lsp::Range::at(1, 5, 1, 8),
          },
          optional: true,
          path: Some(TextNode {
            value: "\"bar.just\"".into(),
            range: lsp::Range::at(1, 9, 1, 19),
          }),
          range: lsp::Range::at(1, 0, 1, 19),
        },
      ]
    );
  }

  #[test]
  fn multiple_recipes() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar:
        echo \"bar\"
      "
    });

    assert_eq!(
      document.find_recipe("foo"),
      Some(Recipe {
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![],
        content: "foo:\n  echo \"foo\"".into(),
        range: lsp::Range::at(0, 0, 3, 0),
        shebang: None,
      })
    );

    assert_eq!(
      document.find_recipe("bar"),
      Some(Recipe {
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(3, 0, 3, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![],
        content: "bar:\n  echo \"bar\"".into(),
        range: lsp::Range::at(3, 0, 5, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn node_at_position() {
    let document = Document::from(indoc! {"
      foo:
        echo \"foo\"

      bar: foo
        echo \"bar\"
    "});

    let node = document
      .node_at_position(lsp::Position {
        line: 1,
        character: 1,
      })
      .unwrap();

    assert_eq!(node.kind(), "recipe");
    assert_eq!(document.get_node_text(&node), "foo:\n  echo \"foo\"\n\n");

    let node = document
      .node_at_position(lsp::Position {
        line: 4,
        character: 6,
      })
      .unwrap();

    assert_eq!(node.kind(), "text");
    assert_eq!(document.get_node_text(&node), "echo \"bar\"");
  }

  #[test]
  fn node_at_position_handles_utf16_columns() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"a🧪b\"
      "
    });

    let node = document
      .node_at_position(lsp::Position {
        line: 1,
        character: 11,
      })
      .unwrap();

    assert_eq!(node.kind(), "text");
    assert_eq!(document.get_node_text(&node), "echo \"a🧪b\"");
  }

  #[test]
  fn optional_import() {
    let document = Document::from(indoc! {
      "
      import? 'foo/bar.just'
      "
    });

    assert_eq!(
      document.imports(),
      vec![Import {
        attributes: vec![],
        optional: true,
        path: TextNode {
          value: "'foo/bar.just'".into(),
          range: lsp::Range::at(0, 8, 0, 22),
        },
        range: lsp::Range::at(0, 0, 0, 22),
      }]
    );
  }

  #[test]
  fn optional_module() {
    let document = Document::from(indoc! {
      "
      mod? foo
      "
    });

    assert_eq!(
      document.modules(),
      vec![Module {
        attributes: vec![],
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 5, 0, 8),
        },
        optional: true,
        path: None,
        range: lsp::Range::at(0, 0, 0, 8),
      }]
    );
  }

  #[test]
  fn private_exported_variable_is_marked_exported() {
    let document = Document::from(indoc! {
      "
      [private]
      export PATH := '/usr/local/bin'
      "
    });

    let variables = document.variables();

    assert_eq!(variables.len(), 1);

    assert_eq!(
      variables,
      vec![Variable {
        attributes: vec![Attribute {
          arguments: vec![],
          name: TextNode {
            value: "private".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          range: lsp::Range::at(0, 0, 1, 0),
          target: Some(AttributeTarget::Assignment),
        }],
        name: TextNode {
          value: "PATH".into(),
          range: lsp::Range::at(1, 7, 1, 11),
        },
        export: true,
        content: "PATH := '/usr/local/bin'".into(),
        range: lsp::Range::at(1, 7, 2, 0),
      }]
    );
  }

  #[test]
  fn recipe_with_attributes() {
    let document = Document::from(indoc! {
      "
      [private]
      [description: \"This is a test recipe\"]
      [tags(\"test\", \"example\")]
      foo:
        echo \"foo\"
      "
    });

    let recipe = document.find_recipe("foo").unwrap();

    assert_eq!(recipe.attributes.len(), 3);

    assert_eq!(
      recipe.attributes,
      vec![
        Attribute {
          name: TextNode {
            value: "private".into(),
            range: lsp::Range::at(0, 1, 0, 8),
          },
          arguments: vec![],
          target: Some(AttributeTarget::Recipe),
          range: lsp::Range::at(0, 0, 1, 0),
        },
        Attribute {
          name: TextNode {
            value: "description".into(),
            range: lsp::Range::at(1, 1, 1, 12),
          },
          arguments: vec![TextNode {
            value: "\"This is a test recipe\"".into(),
            range: lsp::Range::at(1, 14, 1, 37),
          }],
          target: Some(AttributeTarget::Recipe),
          range: lsp::Range::at(1, 0, 2, 0),
        },
        Attribute {
          name: TextNode {
            value: "tags".into(),
            range: lsp::Range::at(2, 1, 2, 5),
          },
          arguments: vec![
            TextNode {
              value: "\"test\"".into(),
              range: lsp::Range::at(2, 6, 2, 12),
            },
            TextNode {
              value: "\"example\"".into(),
              range: lsp::Range::at(2, 14, 2, 23),
            }
          ],
          target: Some(AttributeTarget::Recipe),
          range: lsp::Range::at(2, 0, 3, 0),
        }
      ]
    );
  }

  #[test]
  fn recipe_with_complex_default_parameter() {
    let document = Document::from(indoc! {
      r#"
      foo triple=(arch + "-unknown-unknown"):
      "#
    });

    assert_eq!(
      document.find_recipe("foo").unwrap().parameters,
      vec![Parameter {
        name: "triple".into(),
        kind: ParameterKind::Normal,
        export: false,
        default_value: Some("(arch + \"-unknown-unknown\")".into()),
        content: "triple=(arch + \"-unknown-unknown\")".into(),
        range: lsp::Range::at(0, 4, 0, 38),
      }],
    );
  }

  #[test]
  fn recipe_with_default_parameter() {
    let document = Document::from(indoc! {
      "
      baz first second=\"default\":
        echo \"{{first}} {{second}}\"
      "
    });

    assert_eq!(
      document.find_recipe("baz"),
      Some(Recipe {
        name: TextNode {
          value: "baz".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![
          Parameter {
            name: "first".into(),
            kind: ParameterKind::Normal,
            export: false,
            default_value: None,
            content: "first".into(),
            range: lsp::Range::at(0, 4, 0, 9),
          },
          Parameter {
            name: "second".into(),
            kind: ParameterKind::Normal,
            export: false,
            default_value: Some("\"default\"".into()),
            content: "second=\"default\"".into(),
            range: lsp::Range::at(0, 10, 0, 26),
          }
        ],
        content:
          "baz first second=\"default\":\n  echo \"{{first}} {{second}}\""
            .into(),
        range: lsp::Range::at(0, 0, 2, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_dependency() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar: foo
        echo \"bar\"
      "
    });

    assert_eq!(
      document.find_recipe("bar"),
      Some(Recipe {
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(3, 0, 3, 3)
        },
        attributes: vec![],
        dependencies: vec![Dependency {
          name: "foo".into(),
          arguments: vec![],
          mapped: None,
          phase: DependencyPhase::Prior,
          range: lsp::Range::at(3, 5, 3, 8),
        }],
        parameters: vec![],
        content: "bar: foo\n  echo \"bar\"".into(),
        range: lsp::Range::at(3, 0, 5, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_dependency_arguments() {
    let document = Document::from(indoc! {
      "
      foo arg1 arg2:
        echo \"{{arg1}} {{arg2}}\"

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

    assert_eq!(
      document.find_recipe("bar"),
      Some(Recipe {
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(3, 0, 3, 3)
        },
        attributes: vec![],
        dependencies: vec![Dependency {
          name: "foo".into(),
          arguments: vec![
            DependencyArgument {
              value: "'value1'".into(),
              range: lsp::Range::at(3, 10, 3, 18),
              starred: None,
            },
            DependencyArgument {
              value: "'value2'".into(),
              range: lsp::Range::at(3, 19, 3, 27),
              starred: None,
            }
          ],
          mapped: None,
          phase: DependencyPhase::Prior,
          range: lsp::Range::at(3, 5, 3, 28),
        }],
        parameters: vec![],
        content: "bar: (foo 'value1' 'value2')\n  echo \"bar\"".into(),
        range: lsp::Range::at(3, 0, 5, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_invalid_parameters() {
    #[track_caller]
    fn case(source: &str) {
      assert_eq!(
        Document::from(source)
          .find_recipe("foo")
          .unwrap()
          .parameters,
        vec![],
      );
    }

    case("foo $:\n");
    case("foo +:\n");
    case("foo *:\n");
  }

  #[test]
  fn recipe_with_mapped_dependency_arguments() {
    let document = Document::from(indoc! {
      "
      bar arg *args:
        echo \"{{arg}} {{args}}\"

      foo args: *(bar args *args)
        echo \"foo\"
      "
    });

    assert_eq!(
      document.find_recipe("foo"),
      Some(Recipe {
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(3, 0, 3, 3)
        },
        attributes: vec![],
        dependencies: vec![Dependency {
          name: "bar".into(),
          arguments: vec![
            DependencyArgument {
              value: "args".into(),
              range: lsp::Range::at(3, 16, 3, 20),
              starred: None,
            },
            DependencyArgument {
              value: "args".into(),
              range: lsp::Range::at(3, 22, 3, 26),
              starred: Some(lsp::Range::at(3, 21, 3, 22)),
            }
          ],
          mapped: Some(lsp::Range::at(3, 10, 3, 11)),
          phase: DependencyPhase::Prior,
          range: lsp::Range::at(3, 10, 3, 27),
        }],
        parameters: vec![Parameter {
          name: "args".into(),
          kind: ParameterKind::Normal,
          export: false,
          default_value: None,
          content: "args".into(),
          range: lsp::Range::at(3, 4, 3, 8),
        }],
        content: "foo args: *(bar args *args)\n  echo \"foo\"".into(),
        range: lsp::Range::at(3, 0, 5, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_module_path_dependency() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar:
        echo \"bar\"

      baz: tools::foo
        echo \"baz\"
      "
    });

    assert_eq!(
      document.find_recipe("baz"),
      Some(Recipe {
        name: TextNode {
          value: "baz".into(),
          range: lsp::Range::at(6, 0, 6, 3)
        },
        attributes: vec![],
        dependencies: vec![Dependency {
          name: "tools::foo".into(),
          arguments: vec![],
          mapped: None,
          phase: DependencyPhase::Prior,
          range: lsp::Range::at(6, 5, 6, 15),
        }],
        parameters: vec![],
        content: "baz: tools::foo\n  echo \"baz\"".into(),
        range: lsp::Range::at(6, 0, 8, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_multiple_dependencies() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar:
        echo \"bar\"

      baz: foo bar
        echo \"baz\"
      "
    });

    assert_eq!(
      document.find_recipe("baz"),
      Some(Recipe {
        name: TextNode {
          value: "baz".into(),
          range: lsp::Range::at(6, 0, 6, 3)
        },
        attributes: vec![],
        dependencies: vec![
          Dependency {
            name: "foo".into(),
            arguments: vec![],
            mapped: None,
            phase: DependencyPhase::Prior,
            range: lsp::Range::at(6, 5, 6, 8),
          },
          Dependency {
            name: "bar".into(),
            arguments: vec![],
            mapped: None,
            phase: DependencyPhase::Prior,
            range: lsp::Range::at(6, 9, 6, 12),
          }
        ],
        parameters: vec![],
        content: "baz: foo bar\n  echo \"baz\"".into(),
        range: lsp::Range::at(6, 0, 8, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_parameters() {
    let document = Document::from(indoc! {
      "
      bar target $lol:
        echo \"Building {{target}}\"
      "
    });

    assert_eq!(
      document.find_recipe("bar"),
      Some(Recipe {
        name: TextNode {
          value: "bar".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![
          Parameter {
            name: "target".into(),
            kind: ParameterKind::Normal,
            export: false,
            default_value: None,
            content: "target".into(),
            range: lsp::Range::at(0, 4, 0, 10),
          },
          Parameter {
            name: "lol".into(),
            kind: ParameterKind::Normal,
            export: true,
            default_value: None,
            content: "$lol".into(),
            range: lsp::Range::at(0, 11, 0, 15),
          }
        ],
        content: "bar target $lol:\n  echo \"Building {{target}}\"".into(),
        range: lsp::Range::at(0, 0, 2, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_shebang() {
    let document = Document::from(indoc! {
      "
      foo:
        #!/usr/bin/env bash
        echo \"foo\"
      "
    });

    let recipe = document.find_recipe("foo").unwrap();

    assert_eq!(
      recipe.shebang,
      Some(TextNode {
        value: "#!/usr/bin/env bash".into(),
        range: lsp::Range::at(1, 2, 1, 21),
      })
    );
  }

  #[test]
  fn recipe_with_subsequent_dependency() {
    let recipe = Document::from(indoc! {
      "
      foo:
        echo \"foo\"

      bar:
        echo \"bar\"

      baz: foo && bar
        echo \"baz\"
      "
    })
    .find_recipe("baz")
    .unwrap();

    assert_eq!(
      recipe
        .dependencies
        .into_iter()
        .map(|dependency| dependency.phase)
        .collect::<Vec<_>>(),
      vec![DependencyPhase::Prior, DependencyPhase::Subsequent],
    );
  }

  #[test]
  fn recipe_with_variadic_parameter() {
    let document = Document::from(indoc! {
      "
      baz first +second=\"default\":
        echo \"{{first}} {{second}}\"
      "
    });

    assert_eq!(
      document.find_recipe("baz"),
      Some(Recipe {
        name: TextNode {
          value: "baz".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![
          Parameter {
            name: "first".into(),
            kind: ParameterKind::Normal,
            export: false,
            default_value: None,
            content: "first".into(),
            range: lsp::Range::at(0, 4, 0, 9),
          },
          Parameter {
            name: "second".into(),
            kind: ParameterKind::Variadic(VariadicType::OneOrMore),
            export: false,
            default_value: Some("\"default\"".into()),
            content: "+second=\"default\"".into(),
            range: lsp::Range::at(0, 10, 0, 27),
          }
        ],
        content:
          "baz first +second=\"default\":\n  echo \"{{first}} {{second}}\""
            .into(),
        range: lsp::Range::at(0, 0, 2, 0),
        shebang: None,
      })
    );
  }

  #[test]
  fn recipe_with_zero_or_more_variadic_parameter() {
    let document = Document::from(indoc! {
      "
      foo *FLAGS:
      "
    });

    assert_eq!(
      document.find_recipe("foo").unwrap().parameters,
      vec![Parameter {
        name: "FLAGS".into(),
        kind: ParameterKind::Variadic(VariadicType::ZeroOrMore),
        export: false,
        default_value: None,
        content: "*FLAGS".into(),
        range: lsp::Range::at(0, 4, 0, 10),
      }],
    );
  }

  #[test]
  fn recipe_with_exported_variadic_parameter() {
    #[track_caller]
    fn case(source: &str, expected: VariadicType) {
      let parameter = Document::from(source)
        .find_recipe("foo")
        .unwrap()
        .parameters
        .into_iter()
        .next()
        .unwrap();

      assert!(parameter.export);
      assert_eq!(parameter.kind, ParameterKind::Variadic(expected));
    }

    case("foo +$args:\n", VariadicType::OneOrMore);
    case("foo *$args:\n", VariadicType::ZeroOrMore);
  }

  #[test]
  fn recipe_without_parameters_or_dependencies() {
    let document = Document::from(indoc! {
      "
      foo:
        echo \"foo\"
      "
    });

    assert_eq!(
      document.find_recipe("foo"),
      Some(Recipe {
        name: TextNode {
          value: "foo".into(),
          range: lsp::Range::at(0, 0, 0, 3)
        },
        attributes: vec![],
        dependencies: vec![],
        parameters: vec![],
        content: "foo:\n  echo \"foo\"".into(),
        range: lsp::Range::at(0, 0, 2, 0),
        shebang: None,
      })
    );
  }
}
