qlue-ls 3.2.1

A language server for SPARQL
Documentation
use super::super::{
    CompletionEnvironment, CompletionLocation, error::CompletionError, utils::matches_search_term,
};
use crate::server::lsp::{
    CompletionItem, CompletionItemBuilder, CompletionItemKind, CompletionList, InsertTextFormat,
    ItemDefaults, textdocument::TextEdit,
};
use ll_sparql_parser::{
    ast::{AstNode, QueryUnit},
    syntax_kind::SyntaxKind,
};
use std::collections::HashSet;

pub fn completions(context: &CompletionEnvironment) -> Result<CompletionList, CompletionError> {
    if let CompletionLocation::SelectBinding(_select_clause) = &context.location {
        // NOTE: Here I replace the select_clause node.
        // The one in the CompletionLocation is from the truncated tree.
        // This tree does not have access to everything after the cursor.
        let select_clause = QueryUnit::cast(context.full_tree.clone())
            .unwrap()
            .select_query()
            .unwrap()
            .select_clause()
            .unwrap();
        let mut items = Vec::new();
        let search_term = context.search_term.as_deref();
        // NOTE: suggest keywords DISTINCT & REDUCED
        if context.continuations.contains(&SyntaxKind::DISTINCT) {
            items.extend(
                vec![
                    CompletionItem::new(
                        "DISTINCT",
                        Some("Ensure unique results".to_string()),
                        None,
                        "DISTINCT ",
                        CompletionItemKind::Keyword,
                        None,
                    ),
                    CompletionItem::new(
                        "REDUCED",
                        Some("Permit elimination of some non-distinct solutions".to_string()),
                        None,
                        "REDUCED ",
                        CompletionItemKind::Keyword,
                        None,
                    ),
                ]
                .into_iter()
                .filter(|item| matches_search_term(&item.label, search_term)),
            );
        }
        // NOTE: suggest availible variables (non duplicates).
        let result_vars: HashSet<String> = HashSet::from_iter(
            select_clause
                .variables()
                .iter()
                .map(|var| var.syntax().text().to_string()),
        );
        let availible_vars: HashSet<String> =
            select_clause
                .select_query()
                .map_or(HashSet::new(), |select_query| {
                    HashSet::from_iter(
                        select_query
                            .variables()
                            .iter()
                            .map(|var| var.syntax().text().to_string()),
                    )
                });
        let group_vars: HashSet<String> = HashSet::from_iter(
            select_clause
                .select_query()
                .and_then(|sq| sq.soulution_modifier())
                .and_then(|sm| sm.group_clause())
                .map(move |gc| gc.visible_variables().into_iter().map(|var| var.text()))
                .into_iter()
                .flatten(),
        );
        let vars = if group_vars.is_empty() {
            &availible_vars
        } else {
            &group_vars
        } - &result_vars;
        items.extend(vars.into_iter().map(|var| {
            CompletionItemBuilder::new()
                .label(&var)
                .detail("variable")
                .kind(CompletionItemKind::Variable)
                .filter_text(&var)
                .text_edit(TextEdit::new(
                    context.replace_range.clone(),
                    &format!("{} ", var),
                ))
                .insert_text_format(InsertTextFormat::PlainText)
                .build()
        }));
        // NOTE: suggest aggregates
        let group_by = select_clause
            .select_query()
            .and_then(|sq| sq.soulution_modifier())
            .and_then(|sm| sm.group_clause());
        // NOTE: If no variables are selected, implicit GROUP BY is allowed.
        if group_by.is_some() || result_vars.is_empty() {
            let grouped_vars: HashSet<String> =
                HashSet::from_iter(group_by.into_iter().flat_map(|group_by| {
                    group_by
                        .visible_variables()
                        .into_iter()
                        .map(|var| var.syntax().text().to_string())
                }));
            let vars = &availible_vars - &grouped_vars;

            let query = context.search_term.clone().map(|s| format!("({s}"));
            items.extend(
                vars.iter()
                    .flat_map(|var| {
                        ["COUNT", "SUM", "MIN", "MAX", "AVG", "SAMPLE"]
                            .into_iter()
                            .map(move |aggregate| {
                                CompletionItemBuilder::new()
                                    .label(&format!(
                                        "({aggregate}({var}) AS ?{}_{})",
                                        aggregate.to_lowercase(),
                                        var.split_at(1).1
                                    ))
                                    .kind(CompletionItemKind::Snippet)
                                    .text_edit(TextEdit::new(
                                        context.replace_range.clone(),
                                        &format!(
                                            "({aggregate}({var}) AS ?${{0:{}_{}}})",
                                            aggregate.to_lowercase(),
                                            var.split_at(1).1
                                        ),
                                    ))
                                    .insert_text_format(InsertTextFormat::Snippet)
                                    .build()
                            })
                            .chain(std::iter::once(
                                CompletionItemBuilder::new()
                                    .label(&format!(
                                        "(GROUP_CONCAT(DISTINCT {var}; SEPARATOR=\", \") AS ?concat_{})",
                                        var.split_at(1).1
                                    ))
                                    .kind(CompletionItemKind::Snippet)
                                    .text_edit(TextEdit::new(
                                        context.replace_range.clone(),
                                        &format!(
                                            "(GROUP_CONCAT(DISTINCT {var}; SEPARATOR=\", \") AS ?${{0:concat_{}}})",
                                            var.split_at(1).1
                                        ),
                                    ))
                                    .insert_text_format(InsertTextFormat::Snippet)
                                    .build(),
                            ))
                    })
                    .chain(std::iter::once(
                        CompletionItemBuilder::new()
                            .label("(COUNT(*) AS ?count)")
                            .kind(CompletionItemKind::Function)
                            .text_edit(TextEdit::new(
                                context.replace_range.clone(),
                                "(COUNT(*) AS ?count)",
                            ))
                            .insert_text_format(InsertTextFormat::PlainText)
                            .build(),
                    ))
                    .filter(|comp_item| {
                        matches_search_term(&comp_item.label, query.as_deref())
                    }),
            );
        }

        Ok(CompletionList {
            is_incomplete: true,
            item_defaults: Some(ItemDefaults {
                edit_range: None,
                commit_characters: None,
                data: None,
                insert_text_format: None,
                insert_text_mode: None,
            }),
            items,
        })
    } else {
        Err(CompletionError::Resolve(format!(
            "select binding completions was called with location: {:?}",
            context.location
        )))
    }
}

#[cfg(test)]
mod tests {
    use super::matches_search_term;

    const SELECT_BINDING_KEYWORDS: [&str; 2] = ["DISTINCT", "REDUCED"];

    fn filter_keywords(search_term: Option<&str>) -> Vec<&'static str> {
        SELECT_BINDING_KEYWORDS
            .into_iter()
            .filter(|label| matches_search_term(label, search_term))
            .collect()
    }

    #[test]
    fn no_search_term_returns_all_keywords() {
        let labels = filter_keywords(None);
        assert_eq!(labels.len(), 2);
        assert!(labels.contains(&"DISTINCT"));
        assert!(labels.contains(&"REDUCED"));
    }

    #[test]
    fn distinct_prefix_returns_distinct() {
        let labels = filter_keywords(Some("DI"));
        assert_eq!(labels, vec!["DISTINCT"]);
    }

    #[test]
    fn reduced_prefix_returns_reduced() {
        let labels = filter_keywords(Some("RE"));
        assert_eq!(labels, vec!["REDUCED"]);
    }

    #[test]
    fn d_prefix_returns_distinct() {
        let labels = filter_keywords(Some("D"));
        assert_eq!(labels, vec!["DISTINCT"]);
    }

    #[test]
    fn r_prefix_returns_reduced() {
        let labels = filter_keywords(Some("R"));
        assert_eq!(labels, vec!["REDUCED"]);
    }

    #[test]
    fn non_keyword_prefix_returns_empty() {
        let labels = filter_keywords(Some("Germany"));
        assert!(labels.is_empty());
    }

    #[test]
    fn case_insensitive_matching() {
        let labels = filter_keywords(Some("distinct"));
        assert_eq!(labels, vec!["DISTINCT"]);
    }

    #[test]
    fn variable_like_prefix_returns_empty() {
        // A variable like "?foo" should not match keywords
        let labels = filter_keywords(Some("?foo"));
        assert!(labels.is_empty());
    }
}