qlue-ls 2.8.0

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,
};
use ll_sparql_parser::{ast::AstNode, syntax_kind::SyntaxKind};
use std::collections::HashSet;

pub fn completions(context: &CompletionEnvironment) -> Result<CompletionList, CompletionError> {
    if let CompletionLocation::SelectBinding(select_clause) = &context.location {
        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| {
            CompletionItem::new(
                &var,
                Some("variable".to_string()),
                None,
                &format!("{} ", var),
                CompletionItemKind::Variable,
                None,
            )
        }));
        // 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;

            items.extend(
                ["COUNT", "SUM", "MIN", "MAX", "AVG", "SAMPLE"]
                    .into_iter()
                    .flat_map(|aggregate| {
                        vars.iter().map(move |var| {
                            CompletionItemBuilder::new()
                                .label(&format!(
                                    "({aggregate}({var}) AS ?{}_{})",
                                    aggregate.to_lowercase(),
                                    var.split_at(1).1
                                ))
                                .kind(CompletionItemKind::Snippet)
                                .insert_text(&format!(
                                    "({aggregate}({var}) AS ?${{0:{}_{}}})",
                                    aggregate.to_lowercase(),
                                    var.split_at(1).1
                                ))
                                .insert_text_format(InsertTextFormat::Snippet)
                                .build()
                        })
                    }),
            );

            items.push(
                CompletionItemBuilder::new()
                    .label("(COUNT(*) AS ?count)")
                    .kind(CompletionItemKind::Snippet)
                    .insert_text("(COUNT(*) AS ?count)")
                    .insert_text_format(InsertTextFormat::Snippet)
                    .build(),
            );
        }

        Ok(CompletionList {
            is_incomplete: false,
            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());
    }
}