//
// diagnostics.rs
//
// Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved.
//
//

use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;

use aether_lsp_utils::proto::to_proto;
use aether_lsp_utils::proto::PositionEncoding;
use anyhow::bail;
use anyhow::Result;
use harp::syntax::is_valid_symbol;
use harp::syntax::sym_quote_invalid;
use oak_db::File;
use oak_db::RootKind;
use oak_db::Severity as OakSeverity;
use stdext::*;
use tower_lsp_server::ls_types::Diagnostic;
use tower_lsp_server::ls_types::DiagnosticRelatedInformation;
use tower_lsp_server::ls_types::DiagnosticSeverity;
use tower_lsp_server::ls_types::Location;
use tower_lsp_server::ls_types::NumberOrString;
use tower_lsp_server::ls_types::Uri;
use tree_sitter::Node;
use tree_sitter::Point;
use tree_sitter::Range;

use crate::lsp;
use crate::lsp::analysis::WorldStateSnapshot;
use crate::lsp::db::ArkDb;
use crate::lsp::db::FileArkExt;
use crate::lsp::declarations::top_level_declare;
use crate::lsp::diagnostics_syntax::syntax_diagnostics;
use crate::lsp::indexer;
use crate::lsp::open_file::lsp_range_from_tree_sitter_range;
use crate::lsp::traits::node::NodeExt;
use crate::treesitter::node_has_error_or_missing;
use crate::treesitter::BinaryOperatorType;
use crate::treesitter::NodeType;
use crate::treesitter::NodeTypeExt;
use crate::treesitter::UnaryOperatorType;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiagnosticsConfig {
    pub enable: bool,

    /// Whether to publish diagnostics whose `DiagnosticKind::is_experimental()`
    /// is `true`.
    pub experimental: bool,
}

#[derive(Clone)]
pub struct DiagnosticContext<'a> {
    pub(crate) db: &'a dyn ArkDb,
    pub(crate) file: File,
    pub(crate) encoding: PositionEncoding,
    pub(crate) uri: &'a Uri,

    /// The symbols currently defined and available in the session.
    pub session_symbols: HashSet<String>,

    /// The symbols used within the document, as a 'stack' of symbols,
    /// mapping symbol names to the locations where they were defined.
    pub document_symbols: Vec<HashMap<String, Range>>,

    /// The symbols defined in the workspace.
    pub workspace_symbols: HashSet<String>,

    // The set of packages that are currently installed.
    pub installed_packages: HashSet<String>,

    /// The symbols exported by packages loaded via `library()` calls in this
    /// document. Currently global.
    pub library_symbols: BTreeMap<Point, HashSet<String>>,

    // Whether or not we're inside of a formula.
    pub in_formula: bool,

    // Whether or not we're inside of a call-like node's arguments
    pub in_call_like_arguments: bool,
}

impl Default for DiagnosticsConfig {
    fn default() -> Self {
        Self {
            enable: true,
            experimental: false,
        }
    }
}

impl<'a> DiagnosticContext<'a> {
    pub(crate) fn contents(&self) -> &'a str {
        self.file.source_text(self.db).as_str()
    }

    pub(crate) fn new(
        db: &'a dyn ArkDb,
        file: File,
        encoding: PositionEncoding,
        uri: &'a Uri,
    ) -> Self {
        Self {
            file,
            encoding,
            db,
            uri,
            document_symbols: Vec::new(),
            session_symbols: HashSet::new(),
            workspace_symbols: HashSet::new(),
            installed_packages: HashSet::new(),
            library_symbols: BTreeMap::new(),
            in_formula: false,
            in_call_like_arguments: false,
        }
    }

    pub fn add_defined_variable(&mut self, name: &str, location: Range) {
        let symbols = self.document_symbols.last_mut().unwrap();
        symbols.insert(name.to_string(), location);
    }

    pub fn has_definition(&self, name: &str, start_position: Point) -> bool {
        // Check document symbols
        for symbols in &self.document_symbols {
            if symbols.contains_key(name) {
                return true;
            }
        }

        // Check workspace symbols
        if self.workspace_symbols.contains(name) {
            return true;
        }

        // Check all symbols exported by `library()` calls before the given position
        for (library_position, exports) in self.library_symbols.iter() {
            if *library_position > start_position {
                break;
            }
            if exports.contains(name) {
                return true;
            }
        }

        // Finally, check session symbols
        self.session_symbols.contains(name)
    }
}

pub(crate) fn generate_diagnostics(
    file: File,
    state: WorldStateSnapshot,
    testthat: bool,
    uri: &Uri,
) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();

    if !state.config.diagnostics.enable {
        return diagnostics;
    }

    let db = state.db();

    // Library files are foreign code the user can't fix (installed packages,
    // fetched CRAN/base sources), so diagnostics don't apply (even though
    // symbol navigation works).
    if file
        .root(db)
        .is_some_and(|root| root.kind(db) == RootKind::Library)
    {
        return diagnostics;
    }

    // Check that diagnostics are not disabled in top-level declarations for
    // this document
    let tree = file.tree_sitter(db);
    let contents = file.source_text(db).as_str();
    let decls = top_level_declare(tree, contents);
    if !decls.diagnostics {
        return diagnostics;
    }

    let encoding = state.config.position_encoding;
    let mut context = DiagnosticContext::new(db, file, encoding, uri);

    // Add a 'root' context for the document.
    context.document_symbols.push(HashMap::new());

    // Add the current workspace symbols.
    indexer::map(db, |_file, _symbol, entry| match &entry.data {
        indexer::IndexEntryData::Function { name, arguments: _ } => {
            context.workspace_symbols.insert(name.to_string());
        },
        indexer::IndexEntryData::Variable { name } => {
            context.workspace_symbols.insert(name.to_string());
        },
        _ => {},
    });

    // If this is a package, add imported symbols to workspace
    if let Some(package) = file.package(db) {
        let namespace = package.namespace(db);

        // Add symbols from `importFrom()` directives
        for import in &namespace.imports {
            context.workspace_symbols.insert(import.name.clone());
        }

        // Add symbols from `import()` directives
        for package_import in &namespace.package_imports {
            if let Some(pkg) = db.package_by_name(package_import) {
                for export in &pkg.namespace(db).exports {
                    context.workspace_symbols.insert(export.clone());
                }
            }
        }
    }

    // Simple workaround to include testthat exports in test files. I think the
    // general principle would be that (a) files in `tests/testthat/` include
    // `testthat.R` as a preamble (note that people modify that file e.g. to add
    // more `library()` calls), and (b) all helper files are included in a
    // test-specific workspace (which is effectively the case currently as we
    // don't special-case how workspace inclusion works for packages). We might
    // want to provide a mechanism for test packages to declare this sort of
    // test files setup.
    if testthat {
        if let Some(pkg) = db.package_by_name("testthat") {
            for export in &pkg.namespace(db).exports {
                context.workspace_symbols.insert(export.clone());
            }
        }
    }

    // Add per-environment session symbols
    for scope in state.console_scopes.iter() {
        for name in scope.iter() {
            if is_valid_symbol(name.as_str()) {
                context.session_symbols.insert(name.clone());
            } else {
                let name = sym_quote_invalid(name.as_str());
                context.session_symbols.insert(name.clone());
            }
        }
    }

    // Add `_` pipe placeholder as a "known" session symbol so we never flag it with
    // "symbol not found" when it shows up as an `identifier` node
    context.session_symbols.insert(String::from("_"));

    for pkg in state.installed_packages.iter() {
        context.installed_packages.insert(pkg.clone());
    }

    // Start iterating through the nodes.
    let root = file.tree_sitter(db).root_node();

    // Collect syntax related diagnostics for `ERROR` and `MISSING` nodes
    match syntax_diagnostics(root, &context) {
        Ok(mut syntax_diagnostics) => diagnostics.append(&mut syntax_diagnostics),
        Err(err) => log::error!("Error while generating syntax diagnostics: {err:?}"),
    }

    // Collect semantic related diagnostics
    match semantic_diagnostics(root, &mut context) {
        Ok(mut semantic_diagnostics) => diagnostics.append(&mut semantic_diagnostics),
        Err(err) => log::error!("Error while generating semantic diagnostics: {err:?}"),
    }

    // Collect diagnostics from `oak_db`'s semantic index
    match oak_diagnostics(&context, state.config.diagnostics.experimental) {
        Ok(mut oak_diagnostics) => diagnostics.append(&mut oak_diagnostics),
        Err(err) => log::error!("Error while generating oak diagnostics: {err:?}"),
    }

    diagnostics
}

/// Convert `oak_db`'s file-level diagnostics into LSP diagnostics.
fn oak_diagnostics(
    context: &DiagnosticContext,
    experimental: bool,
) -> anyhow::Result<Vec<Diagnostic>> {
    let mut diagnostics = Vec::new();

    for diagnostic in context.file.diagnostics(context.db) {
        if diagnostic.kind().is_experimental() && !experimental {
            continue;
        }

        let range = to_proto::range(
            diagnostic.range(),
            context.file.line_index(context.db),
            context.encoding,
        )?;

        let related_information = if diagnostic.annotations().is_empty() {
            None
        } else {
            Some(
                diagnostic
                    .annotations()
                    .iter()
                    .map(|annotation| {
                        let range = to_proto::range(
                            annotation.range,
                            context.file.line_index(context.db),
                            context.encoding,
                        )?;
                        Ok(DiagnosticRelatedInformation {
                            location: Location {
                                uri: context.uri.clone(),
                                range,
                            },
                            message: annotation.message.clone(),
                        })
                    })
                    .collect::<anyhow::Result<Vec<_>>>()?,
            )
        };

        diagnostics.push(Diagnostic {
            range,
            severity: Some(oak_severity_to_lsp(diagnostic.kind().severity())),
            code: Some(NumberOrString::String(
                diagnostic.kind().as_str().to_string(),
            )),
            source: Some("ark".to_string()),
            message: diagnostic.message().to_string(),
            related_information,
            ..Default::default()
        });
    }

    Ok(diagnostics)
}

fn oak_severity_to_lsp(severity: OakSeverity) -> DiagnosticSeverity {
    match severity {
        OakSeverity::Error => DiagnosticSeverity::ERROR,
        OakSeverity::Warning => DiagnosticSeverity::WARNING,
        OakSeverity::Info => DiagnosticSeverity::INFORMATION,
        OakSeverity::Hint => DiagnosticSeverity::HINT,
    }
}

fn semantic_diagnostics(
    root: Node,
    context: &mut DiagnosticContext,
) -> anyhow::Result<Vec<Diagnostic>> {
    let mut diagnostics = Vec::new();

    // For semantic diagnostics, we only compute diagnostics for a top level
    // expression if that entire expression's subtree parsed successfully (i.e. if
    // `node.has_error()` returns `false` for that subtree).
    //
    // If a subtree failed to parse successfully, syntax diagnostics will
    // be reported for that subtree instead.
    //
    // This scheme allows us to emit some semantic diagnostics even when
    // we see syntax errors in other parts of the file.
    let mut cursor = root.walk();

    for child in root.children(&mut cursor) {
        if node_has_error_or_missing(&child) {
            continue;
        }

        recurse(child, context, &mut diagnostics)?;
    }

    Ok(diagnostics)
}

fn recurse(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    match node.node_type() {
        NodeType::FunctionDefinition => recurse_function(node, context, diagnostics),
        NodeType::ForStatement => recurse_for(node, context, diagnostics),
        NodeType::WhileStatement => recurse_while(node, context, diagnostics),
        NodeType::RepeatStatement => recurse_repeat(node, context, diagnostics),
        NodeType::IfStatement => recurse_if(node, context, diagnostics),
        NodeType::BracedExpression => recurse_braced_expression(node, context, diagnostics),
        NodeType::ParenthesizedExpression => {
            recurse_parenthesized_expression(node, context, diagnostics)
        },
        NodeType::Subset | NodeType::Subset2 => {
            recurse_subset_or_subset2(node, context, diagnostics)
        },
        NodeType::Call => recurse_call(node, context, diagnostics),
        NodeType::UnaryOperator(UnaryOperatorType::Tilde) => {
            recurse_formula(node, context, diagnostics)
        },
        NodeType::UnaryOperator(_) => recurse_default(node, context, diagnostics),
        NodeType::BinaryOperator(op) => match op {
            BinaryOperatorType::Tilde => recurse_formula(node, context, diagnostics),
            BinaryOperatorType::LeftAssignment => {
                recurse_left_assignment(node, context, diagnostics)
            },
            BinaryOperatorType::EqualsAssignment => {
                recurse_equals_assignment(node, context, diagnostics)
            },
            BinaryOperatorType::RightAssignment => {
                recurse_right_assignment(node, context, diagnostics)
            },
            BinaryOperatorType::LeftSuperAssignment => {
                recurse_left_super_assignment(node, context, diagnostics)
            },
            BinaryOperatorType::RightSuperAssignment => {
                recurse_right_super_assignment(node, context, diagnostics)
            },
            _ => recurse_default(node, context, diagnostics),
        },
        NodeType::NamespaceOperator(_) => recurse_namespace(node, context, diagnostics),
        NodeType::Error => bail!("`Error` nodes should have been skipped entirely."),
        _ => recurse_default(node, context, diagnostics),
    }
}

fn recurse_function(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // TODO: How should we handle default values for formal arguments to a function?
    // Note that the following is valid R code:
    //
    //    (function(a = b) { b <- 42; a })()
    //
    // So, to accurately diagnose the usage of a formal parameter,
    // we need to see what's in scope at the time when the parameter
    // is first used in the body of the function. (Then, add all the
    // wrinkles related to non-standard evaluation.)

    // Add a new symbols context for this scope.
    let mut context = context.clone();
    context.document_symbols.push(HashMap::new());
    let context = &mut context;

    // Recurse through the arguments, adding their symbols to the `context`
    let parameters = unwrap!(node.child_by_field_name("parameters"), None => {
        bail!("Missing `parameters` field in a `function_definition` node");
    });

    recurse_parameters(parameters, context, diagnostics)?;

    // Recurse through the body, if one exists
    if let Some(body) = node.child_by_field_name("body") {
        recurse(body, context, diagnostics)?;
    }

    Ok(())
}

fn recurse_for(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // First, scan the 'sequence' node.
    let sequence = unwrap!(node.child_by_field_name("sequence"), None => {
        bail!("Missing `sequence` field in a `for` node");
    });

    recurse(sequence, context, diagnostics)?;

    // Now, check for an identifier, and put that in scope.
    let variable = unwrap!(node.child_by_field_name("variable"), None => {
        bail!("Missing `variable` field in a `for` node");
    });

    if variable.is_identifier() {
        let name = variable.node_as_str(context.contents())?;
        let range = variable.range();
        context.add_defined_variable(name, range);
    }

    // Now, scan the body, if it exists
    if let Some(body) = node.child_by_field_name("body") {
        recurse(body, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_if(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // First scan the `condition`.
    let condition = unwrap!(node.child_by_field_name("condition"), None => {
        bail!("Missing `condition` field in an `if` node.");
    });

    recurse(condition, context, diagnostics)?;

    // Now, scan the `consequence`.
    let consequence = unwrap!(node.child_by_field_name("consequence"), None => {
        bail!("Missing `consequence` field in an `if` node.");
    });

    recurse(consequence, context, diagnostics)?;

    // And finally the optional `alternative`
    if let Some(alternative) = node.child_by_field_name("alternative") {
        recurse(alternative, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_while(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // First scan the `condition`.
    let condition = unwrap!(node.child_by_field_name("condition"), None => {
        bail!("Missing `condition` field in a `while` node.");
    });

    recurse(condition, context, diagnostics)?;

    // Now, scan the `body`, if it exists.
    if let Some(body) = node.child_by_field_name("body") {
        recurse(body, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_repeat(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // Only thing to scan is the `body`, if it exists
    if let Some(body) = node.child_by_field_name("body") {
        recurse(body, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_formula(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // TODO: Are there any sensible diagnostics we can do in a formula?
    // Beyond just checking for syntax errors, or things of that form?
    let mut context = context.clone();
    context.in_formula = true;
    let context = &mut context;

    if let Some(lhs) = node.child_by_field_name("lhs") {
        recurse(lhs, context, diagnostics)?;
    }
    if let Some(rhs) = node.child_by_field_name("rhs") {
        recurse(rhs, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_left_super_assignment(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let identifier = node.child_by_field_name("lhs");
    let expression = node.child_by_field_name("rhs");
    recurse_super_assignment(identifier, expression, context, diagnostics)
}

fn recurse_right_super_assignment(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let identifier = node.child_by_field_name("rhs");
    let expression = node.child_by_field_name("lhs");
    recurse_super_assignment(identifier, expression, context, diagnostics)
}

fn recurse_super_assignment(
    identifier: Option<Node>,
    expression: Option<Node>,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // TODO: Check for a target within a parent scope.
    // We could probably add some more advanced diagnostics here, but for
    // now we want to make sure that the `identifier` isn't hit with a "symbol
    // not in scope" diagnostic, so we add it to the `document_symbols` map.
    recurse_assignment(identifier, expression, context, diagnostics)
}

fn recurse_left_assignment(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let identifier = node.child_by_field_name("lhs");
    let expression = node.child_by_field_name("rhs");
    recurse_assignment(identifier, expression, context, diagnostics)
}

fn recurse_equals_assignment(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let identifier = node.child_by_field_name("lhs");
    let expression = node.child_by_field_name("rhs");
    recurse_assignment(identifier, expression, context, diagnostics)
}

fn recurse_right_assignment(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let identifier = node.child_by_field_name("rhs");
    let expression = node.child_by_field_name("lhs");
    recurse_assignment(identifier, expression, context, diagnostics)
}

fn recurse_assignment(
    identifier: Option<Node>,
    expression: Option<Node>,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    if let Some(identifier) = identifier {
        // Check for newly-defined variable
        handle_assignment_variable(&identifier, context)?;

        // Check for dotty assignment
        handle_assignment_dotty(&identifier, context)?;
    }

    // Recurse into expression for assignment.
    if let Some(expression) = expression {
        recurse(expression, context, diagnostics)?;
    }

    ().ok()
}

fn handle_assignment_variable(
    identifier: &Node,
    context: &mut DiagnosticContext,
) -> anyhow::Result<()> {
    if !identifier.is_identifier_or_string() {
        return Ok(());
    }

    let name = identifier.node_as_str(context.contents())?;
    let range = identifier.range();
    context.add_defined_variable(name, range);

    Ok(())
}

/// Support for dotty assignment
///
/// Comes in a few forms
/// - `.[x, y] <- list(1, 2)`
/// - `.[one, .., five] <- list(1, 2, 3, 4, 5)`
/// - `.[foo = y] <- list(x = 1, y = 2, z = 3)`
/// - `.[x, .[y, .[z]]] <- list(1, list(2, list(3)))`
///
/// https://cran.r-project.org/web/packages/dotty/index.html
fn handle_assignment_dotty(
    identifier: &Node,
    context: &mut DiagnosticContext,
) -> anyhow::Result<()> {
    if !identifier.is_subset() {
        // Not a subset call of the form `.[]`
        return Ok(());
    };

    let Some(dot) = identifier.child_by_field_name("function") else {
        return Ok(());
    };
    if !dot.is_identifier() {
        return Ok(());
    };

    let dot = dot.node_as_str(context.contents())?;
    if dot != "." {
        return Ok(());
    };

    // Make sure we're not being invoked within a magrittr pipe, since
    // '.' has special semantics in that scope.
    if node_is_within_magrittr_pipe(identifier, context)? {
        return Ok(());
    }

    // Iterate over each argument, and look for identifiers.
    let Some(arguments) = identifier.child_by_field_name("arguments") else {
        return Ok(());
    };

    let mut cursor = arguments.walk();

    for child in arguments.children_by_field_name("argument", &mut cursor) {
        // i.e. `.[foo = x] <- lst`
        // If we find a name, we are done. The above example means "extract x from lst as foo",
        // so we don't want to define a variable for `x` there.
        if let Some(name) = child.child_by_field_name("name") {
            let range = name.range();
            let name = name.node_as_str(context.contents())?;
            context.add_defined_variable(name, range);
            continue;
        };

        let Some(value) = child.child_by_field_name("value") else {
            continue;
        };

        // i.e. `.[x, y]` where `value` is just a name that dotty assigns to
        if value.is_identifier() {
            let range = value.range();
            let name = value.node_as_str(context.contents())?;
            context.add_defined_variable(name, range);
            continue;
        }

        // i.e. `.[x, .[y]]` where unpacking is recursive
        if value.is_subset() {
            handle_assignment_dotty(&value, context)?;
            continue;
        }
    }

    Ok(())
}

fn node_is_within_magrittr_pipe(node: &Node, context: &DiagnosticContext) -> anyhow::Result<bool> {
    // Start search with the parent so we don't return `true` if we are already on the `%>%`
    let Some(node) = node.parent() else {
        return Ok(false);
    };

    match node_find_magrittr_pipe(&node, context)? {
        Some(_) => Ok(true),
        None => Ok(false),
    }
}

fn node_find_magrittr_pipe<'tree>(
    node: &Node<'tree>,
    context: &DiagnosticContext,
) -> anyhow::Result<Option<Node<'tree>>> {
    if node.is_magrittr_pipe_operator(context.contents())? {
        // Found one!
        return Ok(Some(*node));
    }

    if node.is_braced_expression() {
        // Stop recursion if we hit a brace
        return Ok(None);
    }

    let Some(node) = node.parent() else {
        // Stop recursion if no parent
        return Ok(None);
    };

    node_find_magrittr_pipe(&node, context)
}

fn recurse_namespace(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // This is really a syntax issue, but the RHS is optional in the grammar,
    // so `dplyr::` is technically allowed and we have to check for this in
    // the semantic path instead.
    crate::lsp::diagnostics_syntax::diagnose_missing_namespace_operator(
        node,
        context,
        diagnostics,
    )?;

    let lhs = unwrap!(node.child_by_field_name("lhs"), None => {
        return ().ok();
    });

    // Check for a valid package name.
    let package = lhs.node_as_str(context.contents())?;
    if !context.installed_packages.contains(package) {
        let range = lhs.range();
        let range = lsp_range_from_tree_sitter_range(
            range,
            context.file.line_index(context.db),
            context.encoding,
        )?;
        let message = format!("Package '{}' is not installed.", package);
        let diagnostic = Diagnostic::new_simple(range, message);
        diagnostics.push(diagnostic);
    }

    // Check for a symbol in this namespace.
    let rhs = unwrap!(node.child_by_field_name("rhs"), None => {
        return ().ok();
    });

    if !rhs.is_identifier_or_string() {
        return ().ok();
    }

    // TODO: Check if this variable is defined in the requested namespace.
    ().ok()
}

fn recurse_parameters(
    node: Node,
    context: &mut DiagnosticContext,
    _diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // TODO: Should we do anything with default values? i.e. `function(x = 4)`?
    // They are marked with a field name of `"default"`.
    let mut cursor = node.walk();

    for child in node.children_by_field_name("parameter", &mut cursor) {
        let name = unwrap!(child.child_by_field_name("name"), None => {
            bail!("Missing a `name` field in a `parameter` node.");
        });

        let symbol = name.node_as_str(context.contents())?;
        let location = name.range();

        context.add_defined_variable(symbol, location);
    }

    ().ok()
}

fn recurse_braced_expression(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // Recurse into body statements.
    let mut cursor = node.walk();

    for child in node.children_by_field_name("body", &mut cursor) {
        recurse(child, context, diagnostics)?;
    }

    ().ok()
}

fn recurse_parenthesized_expression(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let Some(body) = node.child_by_field_name("body") else {
        // Would be unexpected, grammar requires exactly 1 `body`
        return Ok(());
    };
    recurse(body, context, diagnostics)
}

/// Default recursion for arguments of a call-like node
///
/// This applies for function calls, subset, and subset2, all of which are guaranteed
/// to have the same tree-sitter node structure (i.e., with an `arguments` child).
fn recurse_call_like_arguments_default(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> anyhow::Result<()> {
    // TODO: Can we better handle NSE in things like `quote()` and
    // `dplyr::mutate()` so we don't have to turn off certain diagnostics when
    // we are inside a call's arguments?

    // We used to clone `context` here to prevent assignments from applying to
    // the caller. We now purposely preserve the caller's context with its
    // `document_symbols`. We explicitly want to index symbols assigned in the
    // call arguments to avoid false positive lints about missing symbols, see
    // https://github.com/posit-dev/positron/issues/3048.
    //
    // Because of the way we traverse the syntax tree, this makes the assumption
    // that execution order is deterministic from left to right. This is not
    // necessarily correct since arguments are lazily evaluated, and whether
    // this is true depends on the function's implementation. For now we assume
    // every function behaves like `list()`, which is our default model of
    // strict evaluation.
    with_in_call_like_arguments(context, |context| {
        let Some(arguments) = node.child_by_field_name("arguments") else {
            return Ok(());
        };

        // Iterate over and recurse into `arguments`
        let mut cursor = arguments.walk();
        let children = arguments.children_by_field_name("argument", &mut cursor);

        for child in children {
            // Recurse into `value`s
            if let Some(value) = child.child_by_field_name("value") {
                recurse(value, context, diagnostics)?;
            }
        }

        Ok(())
    })
}

fn with_in_call_like_arguments<F>(context: &mut DiagnosticContext, f: F) -> anyhow::Result<()>
where
    F: FnOnce(&mut DiagnosticContext) -> anyhow::Result<()>,
{
    // Save `in_call_like_arguments` to restore it on exit. Necessary to handle nested calls
    // and maintain the state to `true` until we've left the outermost call.
    let in_call_like_arguments = context.in_call_like_arguments;
    context.in_call_like_arguments = true;

    let result = f(context);

    // Reset on exit
    context.in_call_like_arguments = in_call_like_arguments;
    result
}

fn recurse_call(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // Run diagnostics on the call itself
    dispatch(node, context, diagnostics);

    // Recurse into the callee
    let Some(callee) = node.child_by_field_name("function") else {
        return Ok(());
    };
    recurse(callee, context, diagnostics)?;

    // dispatch based on the function
    //
    // TODO: Handle certain 'scope-generating' function calls, e.g.
    // things like 'local({ ... })'.
    let fun = callee.node_as_str(context.contents())?;

    match fun {
        "library" | "require" => {
            // Track symbols exported by `library()` or `require()` calls
            if let Err(err) = handle_package_attach_call(node, context) {
                lsp::log_warn!("Can't handle attach call: {err:?}");
            }
        },
        _ => {},
    };

    // Continue with default recursion to handle any other arguments
    recurse_call_like_arguments_default(node, context, diagnostics)?;

    ().ok()
}

fn handle_package_attach_call(node: Node, context: &mut DiagnosticContext) -> anyhow::Result<()> {
    // Find the first argument (package name). Positionally for now, no attempt
    // at argument matching whatsoever.
    let Some(package_node) = node.arguments_values().flatten().next() else {
        return Err(anyhow::anyhow!("Can't unpack attached package argument"));
    };

    // Just bail if `character.only` is passed, even if it's actually `FALSE`.
    // We'll do better when we have a more capable argument inspection
    // infrastructure.
    if node
        .arguments_names_as_string(context.contents())
        .flatten()
        .any(|n| n == "character.only")
    {
        return Ok(());
    }

    let package_name = package_node.get_identifier_or_string_text(context.contents())?;
    let attach_pos = node.end_position();

    insert_package_exports(package_name, attach_pos, context);

    // Also attach packages from `Depends` field, if any
    if let Some(package_names) = context
        .db
        .package_by_name(package_name)
        .and_then(|package| package.depends(context.db).as_ref())
    {
        for package_name in package_names.iter() {
            insert_package_exports(package_name, attach_pos, context);
        }
    }

    // Special handling for the tidyverse and tidymodels packages. Hard-coded
    // for now but in the future, this should probably be expressed as a
    // `DESCRIPTION` field like `Config/Needs/attach`.
    let attach_field = match package_name {
        // https://github.com/tidyverse/tidyverse/blob/0231aafb/R/attach.R#L1
        "tidyverse" => {
            vec![
                "dplyr",
                "readr",
                "forcats",
                "stringr",
                "ggplot2",
                "tibble",
                "lubridate",
                "tidyr",
                "purrr",
            ]
        },
        // https://github.com/tidymodels/tidymodels/blob/aa3f82cf/R/attach.R#L1
        "tidymodels" => {
            vec![
                "broom",
                "dials",
                "dplyr",
                "ggplot2",
                "infer",
                "modeldata",
                "parsnip",
                "purrr",
                "recipes",
                "rsample",
                "tibble",
                "tidyr",
                "tune",
                "workflows",
                "workflowsets",
                "yardstick",
            ]
        },
        _ => vec![],
    };
    for package_name in attach_field {
        insert_package_exports(package_name, attach_pos, context);
    }

    Ok(())
}

fn insert_package_exports(package_name: &str, attach_pos: Point, context: &mut DiagnosticContext) {
    let Some(package) = context.db.package_by_name(package_name) else {
        // Package is not installed. This is linted via another path.
        return;
    };

    // Start from explicit `NAMESPACE` exports
    let mut exports = package.namespace(context.db).exports.clone().into_vec();

    // Add all documented symbols. This should cover documented datasets to avoid some
    // false positives in favor of allowing some false negatives. This is admittedly a
    // bit of a stopgap!
    if let Some(index) = package.index(context.db) {
        exports.extend(index.names().iter().cloned());
    }

    // No need to worry about sorting or deduplicating, the `HashSet` takes care of this
    context
        .library_symbols
        .entry(attach_pos)
        .or_default()
        .extend(exports);
}

fn recurse_subset_or_subset2(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // Run diagnostics on the call itself
    dispatch(node, context, diagnostics);

    // Recurse into the callee
    let Some(callee) = node.child_by_field_name("function") else {
        return Ok(());
    };
    recurse(callee, context, diagnostics)?;

    recurse_call_like_arguments_default(node, context, diagnostics)
}

fn recurse_default(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    // Apply diagnostic functions to node.
    dispatch(node, context, diagnostics);
    recurse_children(node, context, diagnostics)
}

fn recurse_children(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
    let mut cursor = node.walk();

    for child in node.children(&mut cursor) {
        recurse(child, context, diagnostics)?;
    }

    Ok(())
}

fn dispatch(node: Node, context: &mut DiagnosticContext, diagnostics: &mut Vec<Diagnostic>) {
    let result: Result<bool> = local! {
        check_invalid_na_comparison(node, context, diagnostics)?;
        check_symbol_in_scope(node, context, diagnostics)?;
        check_unexpected_assignment_in_if_conditional(node, context, diagnostics)?;
        true.ok()
    };

    if let Err(error) = result {
        log::error!("{error}");
    }
}

// TODO: Move this to `recurse_binary_equal()` and get it out of `dispatch()`
fn check_invalid_na_comparison(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<bool> {
    let n = node.child_count();
    if n == 0 {
        return false.ok();
    }

    if node.node_type() != NodeType::BinaryOperator(BinaryOperatorType::Equal) {
        return false.ok();
    }

    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        let contents = child.node_as_str(context.contents())?;

        if matches!(contents, "NA" | "NaN" | "NULL") {
            let message = match contents {
                "NA" => "Consider using `is.na()` to check `NA` values.",
                "NaN" => "Consider using `is.nan()` to check `NaN` values.",
                "NULL" => "Consider using `is.null()` to check `NULL` values.",
                _ => continue,
            };
            let range = child.range();
            let range = lsp_range_from_tree_sitter_range(
                range,
                context.file.line_index(context.db),
                context.encoding,
            )?;
            let mut diagnostic = Diagnostic::new_simple(range, message.into());
            diagnostic.severity = Some(DiagnosticSeverity::INFORMATION);
            diagnostics.push(diagnostic);
        }
    }

    true.ok()
}

// TODO: Move this to `recurse_if()` and get it out of `dispatch()`
fn check_unexpected_assignment_in_if_conditional(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<bool> {
    let n = node.child_count();
    if n == 0 {
        return false.ok();
    }

    if node.node_type() != NodeType::IfStatement {
        return false.ok();
    }

    let condition = unwrap!(node.child_by_field_name("condition"), None => {
        return false.ok();
    });

    if condition.node_type() != NodeType::BinaryOperator(BinaryOperatorType::EqualsAssignment) {
        return false.ok();
    }

    let range = condition.range();
    let range = lsp_range_from_tree_sitter_range(
        range,
        context.file.line_index(context.db),
        context.encoding,
    )?;
    let message = "Unexpected '='; use '==' to compare values for equality.";
    let diagnostic = Diagnostic::new_simple(range, message.into());
    diagnostics.push(diagnostic);

    true.ok()
}

// TODO: Move this to `recurse_identifier()` and get it out of `dispatch()`
fn check_symbol_in_scope(
    node: Node,
    context: &mut DiagnosticContext,
    diagnostics: &mut Vec<Diagnostic>,
) -> Result<bool> {
    // Skip if we're in a formula.
    if context.in_formula {
        return false.ok();
    }

    // Skip if we're working on the arguments of a call-like node
    if context.in_call_like_arguments {
        return false.ok();
    }

    // Skip if this isn't an identifier.
    if !node.is_identifier() {
        return false.ok();
    }

    // Skip if this identifier belongs to a '$' or `@` node.
    if let Some(parent) = node.parent() {
        if matches!(parent.node_type(), NodeType::ExtractOperator(_)) {
            if let Some(rhs) = parent.child_by_field_name("rhs") {
                if rhs == node {
                    return false.ok();
                }
            }
        }
    }

    // Skip if a symbol with this name is in scope.
    let name = node.node_as_str(context.contents())?;
    if context.has_definition(name, node.start_position()) {
        return false.ok();
    }

    // No symbol in scope; provide a diagnostic.
    let range = node.range();
    let range = lsp_range_from_tree_sitter_range(
        range,
        context.file.line_index(context.db),
        context.encoding,
    )?;
    let identifier = node.node_as_str(context.contents())?;
    let message = format!("No symbol named '{}' in scope.", identifier);
    let mut diagnostic = Diagnostic::new_simple(range, message);
    diagnostic.severity = Some(DiagnosticSeverity::WARNING);
    diagnostics.push(diagnostic);

    true.ok()
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use aether_path::FilePath;
    use harp::eval::RParseEvalOptions;
    use oak_db::Db;
    use oak_db::OakDatabase;
    use oak_scan::DbScan;
    use tempfile::TempDir;
    use tower_lsp_server::ls_types as lsp_types;
    use tower_lsp_server::ls_types::Position;

    use crate::console::console_inputs;
    use crate::lsp::state::WorldState;
    use crate::lsp::traits::url::UrlExt;
    use crate::r_task;

    fn generate_diagnostics(code: &str, state: &WorldState) -> Vec<lsp_types::Diagnostic> {
        let url = url::Url::parse("file:///test.R").unwrap();
        let uri = url.to_uri().unwrap();
        let file = oak_db::File::new(
            &state.db,
            FilePath::from_url(&url),
            oak_db::FileRevision::zero(),
            Some(code.to_string()),
            None,
        );
        super::generate_diagnostics(file, state.snapshot(), false, &uri)
    }

    fn current_state() -> WorldState {
        let inputs = console_inputs().unwrap();

        WorldState {
            console_scopes: inputs.console_scopes,
            installed_packages: inputs.installed_packages,
            ..Default::default()
        }
    }

    /// Install a package named `name` exporting `exports` into the library
    /// directory `library`, writing the `DESCRIPTION` and `NAMESPACE` files
    /// that the library scanner reads.
    fn install_package(library: &Path, name: &str, exports: &[&str]) {
        let package = library.join(name);
        std::fs::create_dir(&package).unwrap();
        std::fs::write(package.join("DESCRIPTION"), format!("Package: {name}\n")).unwrap();
        let namespace: String = exports
            .iter()
            .map(|export| format!("export({export})\n"))
            .collect();
        std::fs::write(package.join("NAMESPACE"), namespace).unwrap();
    }

    #[test]
    fn test_mixed_syntax_and_semantic_diagnostics() {
        r_task(|| {
            // - `foo` is an unknown symbol
            // - `}` is a syntax error, but localized to that line
            let text = "
foo

1 }";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert_eq!(diagnostics.len(), 2);

            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
            assert_eq!(diagnostic.range.start, Position::new(3, 2));
            assert_eq!(diagnostic.range.end, Position::new(3, 3));

            let diagnostic = diagnostics.get(1).unwrap();
            insta::assert_snapshot!(diagnostic.message);
            assert_eq!(diagnostic.range.start, Position::new(1, 0));
            assert_eq!(diagnostic.range.end, Position::new(1, 3));
        })
    }

    #[test]
    fn test_comment_after_call_argument() {
        r_task(|| {
            let text = "
            match(
                1,
                2 # hi there
            )";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert!(diagnostics.is_empty());
        })
    }

    #[test]
    fn test_missing_namespace_rhs() {
        r_task(|| {
            let text = "base::";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert_eq!(diagnostics.len(), 1);
            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
        })
    }

    #[test]
    fn test_no_diagnostic_for_dot_dot_i() {
        r_task(|| {
            let text = "..1 + ..2 + 3";

            let diagnostics = generate_diagnostics(text, &current_state());

            assert!(diagnostics.is_empty());
        })
    }

    #[test]
    fn test_no_diagnostic_for_rhs_of_extractor() {
        r_task(|| {
            let options = RParseEvalOptions {
                forbid_function_calls: false,
                ..Default::default()
            };

            // Put the LHS in scope
            harp::parse_eval("x <- NULL", options.clone()).unwrap();
            let state = current_state();

            let text = "x$foo";
            let diagnostics = generate_diagnostics(text, &state);
            assert!(diagnostics.is_empty());

            let text = "x@foo";
            let diagnostics = generate_diagnostics(text, &state);
            assert!(diagnostics.is_empty());

            // Clean up
            harp::parse_eval("remove(x)", options.clone()).unwrap();
        })
    }

    #[test]
    fn test_no_diagnostic_for_assignment_bindings() {
        r_task(|| {
            let text = "
                x <- 1
                2 -> y
                z = 3
                y + x + z
            ";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert!(diagnostics.is_empty());
        })
    }

    #[test]
    fn test_no_diagnostic_for_super_assignment_bindings() {
        r_task(|| {
            let text = "
                x <<- 1
                2 ->> y
                y + x
            ";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert!(diagnostics.is_empty());
        })
    }

    #[test]
    fn test_symbol_not_in_scope_diagnostic_is_ordering_dependent() {
        r_task(|| {
            let text = "
                x + 1
                x <- 1
                x + 1
            ";

            let diagnostics = generate_diagnostics(text, &current_state());
            assert_eq!(diagnostics.len(), 1);

            // Only marks the `x` before the `x <- 1`
            let diagnostic = diagnostics.first().unwrap();
            assert_eq!(diagnostic.range.start.line, 1)
        })
    }

    #[test]
    fn test_no_diagnostic_formula() {
        r_task(|| {
            let text = "
                foo ~ bar
                ~foo
                identity(foo ~ bar)
                identity(~foo)
            ";
            let diagnostics = generate_diagnostics(text, &current_state());
            assert!(diagnostics.is_empty());
        })
    }

    #[test]
    fn test_dotty_assignment_basic() {
        r_task(|| {
            let code = "
                .[apple, banana] <- c(1, 2)
                apple
                banana
                cherry
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 1);

            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
        })
    }

    #[test]
    fn test_dotty_right_assignment_basic() {
        r_task(|| {
            let code = "
                c(1, 2) -> .[apple, banana]
                apple
                banana
                cherry
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 1);

            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
        })
    }

    #[test]
    fn test_dotty_assignment_named() {
        r_task(|| {
            // `x` should not be defined
            let code = "
                .[apple = x, banana] <- list(w = 1, x = 2, y = 3, z = 4)
                apple
                banana
                x
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 1);

            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
        })
    }

    #[test]
    fn test_dotty_assignment_recursive() {
        r_task(|| {
            let code = "
                .[apple, .[banana]] <- list(1, list(2))
                apple
                banana
                cherry
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 1);

            let diagnostic = diagnostics.first().unwrap();
            insta::assert_snapshot!(diagnostic.message);
        })
    }

    #[test]
    fn test_dotty_assignment_within_magrittr_pipe_braced_expr() {
        r_task(|| {
            let code = "
                mtcars %>% list({ .[apple] <- 1; apple })
                apple
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 0);
        })
    }

    #[test]
    fn test_dotty_assignment_within_native_pipe_braced_expr() {
        r_task(|| {
            let code = "
                mtcars |> list({ .[apple] <- 1; apple })
                apple
            ";

            let diagnostics = generate_diagnostics(code, &current_state());
            assert_eq!(diagnostics.len(), 0);
        })
    }

    #[test]
    fn test_assignment_within_call_like() {
        // https://github.com/posit-dev/positron/issues/3048
        // With our current approach we also incorrectly index symbols in calls
        // with local scopes such as `local()` or `test_that()`. We prefer to be
        // overly permissive than the opposite to avoid annoying false positive
        // diagnostics.

        // Calls
        r_task(|| {
            let code = "
                list(x <- 1)
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            let code = "
                list({ x <- 1 })
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        });

        // Subset
        r_task(|| {
            let code = "
                foo <- list()
                foo[x <- 1]
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            let code = "
                foo <- list()
                foo[{x <- 1}]
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        });

        // Subset2
        r_task(|| {
            let code = "
                foo <- list()
                foo[[x <- 1]]
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            let code = "
                foo <- list()
                foo[[{x <- 1}]]
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        });
    }

    #[test]
    fn test_no_symbol_diagnostics_in_call_like() {
        // For now we never check for missing symbols inside call-like nodes because we
        // don't have a good way to deal with NSE in functions like `quote()` or
        // `mutate()` or data.table's `[`.

        // Calls
        r_task(|| {
            let code = "
                list(x)
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            // Important to test nested case. We have a dynamic stack of state
            // variable to keep track of whether we are in a call. The inner
            // call should restore the outer state on exit.
            let code = "
                list(list(), x)
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            // `in_call_like_arguments` state variable is reset
            let code = "
                list()
                x
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 1);
        });

        // Subset
        r_task(|| {
            // Imagine this is `data.table()` (we don't necessarily have the package
            // installed in the test)
            // https://github.com/posit-dev/positron/issues/5271
            let code = "
                data <- data.frame(x = 1)
                data[x]
                data[,x]
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            // Imagine this is `data.table()` (we don't necessarily have the package
            // installed in the test)
            // https://github.com/posit-dev/positron/issues/3749
            let code = "
                data <- data.frame(x = 1)
                data[, y := x + 1]
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        });

        // Subset2
        r_task(|| {
            let code = "
                foo <- list()
                foo[[x]]
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        });
    }

    #[test]
    fn test_no_symbol_diagnostics_for_native_pipe_placeholder() {
        r_task(|| {
            // https://github.com/posit-dev/positron/issues/4102
            let code = "
                x <- list(a = 1)
                x |> _$a[1]
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            // Imagine this is a data.table
            // https://github.com/posit-dev/positron/issues/3749
            let code = "
                data <- data.frame(a = 1)
                data |> _[1]
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);

            // We technically disable diagnostics for this symbol everywhere, even outside
            // of pipe scope, which is probably fine
            let code = "
                _
            ";
            assert_eq!(generate_diagnostics(code, &current_state()).len(), 0);
        })
    }

    #[test]
    fn test_library_static_exports() {
        // `mockpkg` exports `foo` and `bar`
        let library = TempDir::new().unwrap();
        install_package(library.path(), "mockpkg", &["foo", "bar"]);

        let mut db = OakDatabase::new();
        db.set_library_paths(&[library.path().to_path_buf()]);

        // Whereas `current_state()` returns a state with the base package
        // attached, this world state only contains `mockpkg` as an installed
        // package and `library()` on the search path.
        let state = WorldState {
            db,
            console_scopes: vec![vec!["library".to_string()]],
            ..Default::default()
        };

        // Test that exported symbols are recognized
        let code = "
                library(mockpkg)
                foo()
                bar
            ";
        let diagnostics = generate_diagnostics(code, &state);

        assert_eq!(diagnostics.len(), 0);

        // Test that non-exported symbols still generate diagnostics
        let code = "
                library('mockpkg')
                undefined()
                also_undefined
            ";

        let diagnostics = generate_diagnostics(code, &state);
        assert_eq!(diagnostics.len(), 2);

        assert!(diagnostics
            .first()
            .unwrap()
            .message
            .contains("No symbol named 'undefined' in scope"));
        assert!(diagnostics
            .get(1)
            .unwrap()
            .message
            .contains("No symbol named 'also_undefined' in scope"));

        // Test duplicate call
        let code = "
                library(mockpkg)
                library(mockpkg)  # duplicate is fine
                foo()
                bar
            ";
        let diagnostics = generate_diagnostics(code, &state);
        assert_eq!(diagnostics.len(), 0);

        // If the library call includes the `character.only` argument, we bail
        let code = r#"
                library(mockpkg, character.only = TRUE)
                foo()
            "#;
        let diagnostics = generate_diagnostics(code, &state);
        assert_eq!(diagnostics.len(), 1);

        // Same if passed `FALSE`, we're not trying to be smart (yet)
        let code = r#"
                library(mockpkg, character.only = FALSE)
                foo()
            "#;
        let diagnostics = generate_diagnostics(code, &state);
        assert_eq!(diagnostics.len(), 1);
    }

    #[test]
    fn test_library_static_exports_multiple_packages() {
        // pkg1 exports `foo` and `bar`, pkg2 exports `bar` and `baz`
        let library = TempDir::new().unwrap();
        install_package(library.path(), "pkg1", &["foo", "bar"]);
        install_package(library.path(), "pkg2", &["bar", "baz"]);

        let mut db = OakDatabase::new();
        db.set_library_paths(&[library.path().to_path_buf()]);

        let state = WorldState {
            db,
            console_scopes: vec![vec!["library".to_string()]],
            ..Default::default()
        };

        // Code with two library calls at different points
        let code = "
                    foo           # not in scope
                    bar           # not in scope
                    baz           # not in scope

                    library(pkg1)
                    foo           # in scope
                    bar           # in scope
                    baz           # not in scope

                    library(pkg2)
                    foo           # in scope
                    bar           # in scope
                    baz           # in scope
                ";
        let diagnostics = generate_diagnostics(code, &state);

        let messages: Vec<_> = diagnostics.iter().map(|d| d.message.clone()).collect();
        assert!(messages.iter().any(|m| m.contains("No symbol named 'foo'")));
        assert!(messages.iter().any(|m| m.contains("No symbol named 'bar'")));
        assert!(messages.iter().any(|m| m.contains("No symbol named 'baz'")));
        assert!(messages.iter().any(|m| m.contains("No symbol named 'baz'")));
        assert_eq!(messages.len(), 4);
    }

    #[test]
    fn test_library_static_exports_require() {
        // `pkg` exports `foo` and `bar`
        let library = TempDir::new().unwrap();
        install_package(library.path(), "pkg", &["foo", "bar"]);

        let mut db = OakDatabase::new();
        db.set_library_paths(&[library.path().to_path_buf()]);

        let state = WorldState {
            db,
            console_scopes: vec![vec!["require".to_string()]],
            ..Default::default()
        };

        let code = "
                    foo()
                    require(pkg)
                    bar
                    foo()
                ";
        let diagnostics = generate_diagnostics(code, &state);
        assert!(diagnostics
            .iter()
            .any(|d| d.message.contains("No symbol named 'foo'")));
        assert_eq!(diagnostics.len(), 1);
    }

    #[test]
    fn test_penguins_symbol_no_diagnostic() {
        let library = oak_package_metadata::tests::temp_palmerpenguin();

        let mut db = OakDatabase::new();
        db.set_library_paths(&[library.path().to_path_buf()]);

        // Simulate a world state with the penguins package installed and
        // `library()` on the search path
        let state = WorldState {
            db,
            console_scopes: vec![vec!["library".to_string()]],
            ..Default::default()
        };

        let code = r#"
                library(penguins)
                penguins
                path_to_file
                penguins_raw
            "#;
        let diagnostics = generate_diagnostics(code, &state);
        assert!(diagnostics.is_empty());

        let code = r#"
                penguins
                path_to_file
                penguins_raw
                library(penguins)
            "#;
        let diagnostics = generate_diagnostics(code, &state);
        assert_eq!(diagnostics.len(), 3);
    }

    #[test]
    fn test_no_diagnostics_for_library_files() {
        // Library files are foreign code the user can't fix, so we don't
        // diagnose them even though `undefined_symbol` is unresolved.
        let library = TempDir::new().unwrap();
        let pkg_dir = library.path().join("mockpkg");
        std::fs::create_dir_all(pkg_dir.join("R")).unwrap();
        std::fs::write(
            pkg_dir.join("DESCRIPTION"),
            "Package: mockpkg\nVersion: 1.0.0\n",
        )
        .unwrap();
        std::fs::write(pkg_dir.join("R").join("a.R"), "undefined_symbol\n").unwrap();

        let mut db = OakDatabase::new();
        db.set_library_paths(&[library.path().to_path_buf()]);

        let package = db.package_by_name("mockpkg").unwrap();
        // The scanner registers installed packages without reading their
        // sources; simulate the Oak cache's `set_package_sources` ingestion.
        db.set_package_sources(package, &pkg_dir.join("R"));
        let file = *package.files(&db).first().unwrap();

        let state = WorldState {
            db,
            ..Default::default()
        };

        let url = url::Url::parse("file:///a.R").unwrap();
        let uri = url.to_uri().unwrap();
        let diagnostics = super::generate_diagnostics(file, state.snapshot(), false, &uri);
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_oak_diagnostics_ambiguous_effect_experimental() {
        r_task(|| {
            // `local`'s NSE reading could be shadowed by the later
            // `local <- identity` at file scope, with undetermined timing
            // relative to `f`'s call.
            let text = "f <- function() local({ x <- 1 })\nlocal <- identity\n";

            let mut state = current_state();
            state.config.diagnostics.experimental = true;
            let diagnostics = generate_diagnostics(text, &state);

            // This snippet doesn't trip any of the legacy tree-sitter checks:
            // `local` and `identity` both resolve to base R.
            assert_eq!(diagnostics.len(), 1);
            let diagnostic = &diagnostics[0];

            assert_eq!(
                diagnostic.code,
                Some(lsp_types::NumberOrString::String(
                    "ambiguous-effect".to_string()
                ))
            );
            assert_eq!(diagnostic.range.start, Position::new(0, 16));
            assert_eq!(diagnostic.range.end, Position::new(0, 33));
            assert_eq!(diagnostic.source, Some("ark".to_string()));
            assert_eq!(
                diagnostic.severity,
                Some(lsp_types::DiagnosticSeverity::INFORMATION)
            );

            let related = diagnostic.related_information.as_ref().unwrap();
            assert_eq!(related.len(), 1);
            assert_eq!(related[0].message, "could run before the call");
            assert_eq!(related[0].location.range.start, Position::new(1, 0));
            assert_eq!(related[0].location.range.end, Position::new(1, 5));
        })
    }

    #[test]
    fn test_oak_diagnostics_ambiguous_effect_disabled_by_default() {
        r_task(|| {
            let text = "f <- function() local({ x <- 1 })\nlocal <- identity\n";

            let diagnostics = generate_diagnostics(text, &current_state());

            assert!(diagnostics.is_empty());
        })
    }
}
