diff --git a/crates/ark/resources/snippets/r.code-snippets b/crates/ark/resources/snippets/r.code-snippets deleted file mode 100644 index 375bdfd70..000000000 --- a/crates/ark/resources/snippets/r.code-snippets +++ /dev/null @@ -1,181 +0,0 @@ -{ - "lib": { - "prefix": "lib", - "body": "library(${1:package})", - "description": "Attach an R package" - }, - "src": { - "prefix": "src", - "body": "source(\"${1:file.R}\")", - "description": "Source an R file" - }, - "ret": { - "prefix": "ret", - "body": "return(${1:code})", - "description": "Return a value from a function" - }, - "mat": { - "prefix": "mat", - "body": "matrix(${1:data}, nrow = ${2:rows}, ncol = ${3:cols})", - "description": "Define a matrix" - }, - "sg": { - "prefix": "sg", - "body": [ - "setGeneric(\"${1:generic}\", function(${2:x, ...}) {", - "\tstandardGeneric(\"${1:generic}\")", - "})" - ], - "description": "Define a generic" - }, - "sm": { - "prefix": "sm", - "body": [ - "setMethod(\"${1:generic}\", ${2:class}, function(${2:x, ...}) {", - "\t${0}", - "})" - ], - "description": "Define a method for a generic function" - }, - "sc": { - "prefix": "sc", - "body": "setClass(\"${1:Class}\", slots = c(${2:name = \"type\"}))", - "description": "Define a class definition" - }, - "if": { - "prefix": "if", - "body": [ - "if (${1:condition}) {", - "\t${0}", - "}" - ], - "description": "Conditional expression" - }, - "el": { - "prefix": "el", - "body": [ - "else {", - "\t${0}", - "}" - ], - "description": "Conditional expression" - }, - "ei": { - "prefix": "ei", - "body": [ - "else if (${1:condition}) {", - "\t${0}", - "}" - ], - "description": "Conditional expression" - }, - "fun": { - "prefix": "fun", - "body": [ - "${1:name} <- function(${2:variables}) {", - "\t${0}", - "}" - ], - "description": "Function skeleton" - }, - "for": { - "prefix": "for", - "body": [ - "for (${1:variable} in ${2:vector}) {", - "\t${0}", - "}" - ], - "description": "Define a loop" - }, - "while": { - "prefix": "while", - "body": [ - "while (${1:condition}) {", - "\t${0}", - "}" - ], - "description": "Define a loop" - }, - "switch": { - "prefix": "switch", - "body": [ - "switch (${1:object},", - "\t${2:case} = ${3:action}", - ")" - ], - "description": "Define a switch statement" - }, - "apply": { - "prefix": "apply", - "body": "apply(${1:array}, ${2:margin}, ${3:...})", - "description": "Use the apply family" - }, - "lapply": { - "prefix": "lapply", - "body": "lapply(${1:list}, ${2:function})", - "description": "Use the apply family" - }, - "sapply": { - "prefix": "sapply", - "body": "sapply(${1:list}, ${2:function})", - "description": "Use the apply family" - }, - "mapply": { - "prefix": "mapply", - "body": "mapply(${1:function}, ${2:...})", - "description": "Use the apply family" - }, - "tapply": { - "prefix": "tapply", - "body": "tapply(${1:vector}, ${2:index}, ${3:function})", - "description": "Use the apply family" - }, - "vapply": { - "prefix": "vapply", - "body": "vapply(${1:list}, ${2:function}, FUN.VALUE = ${3:type}, ${4:...})", - "description": "Use the apply family" - }, - "rapply": { - "prefix": "rapply", - "body": "rapply(${1:list}, ${2:function})", - "description": "Use the apply family" - }, - "ts": { - "prefix": "ts", - "body": "`r paste(\"#\", date(), \"------------------------------\\n\")`", - "description": "Insert a datetime" - }, - "shinyapp": { - "prefix": "shinyapp", - "body": [ - "library(shiny)", - "", - "ui <- fluidPage(", - " ${0}", - ")", - "", - "server <- function(input, output, session) {", - " ", - "}", - "", - "shinyApp(ui, server)" - ], - "description": "Define a Shiny app" - }, - "shinymod": { - "prefix": "shinymod", - "body": [ - "${1:name}_UI <- function(id) {", - " ns <- NS(id)", - " tagList(", - "\t${0}", - " )", - "}", - "", - "${1:name} <- function(input, output, session) {", - " ", - "}" - ], - "description": "Define a Shiny module" - } -} diff --git a/crates/ark/src/lsp/completions/sources/composite.rs b/crates/ark/src/lsp/completions/sources/composite.rs index 085d371d9..83f444692 100644 --- a/crates/ark/src/lsp/completions/sources/composite.rs +++ b/crates/ark/src/lsp/completions/sources/composite.rs @@ -10,7 +10,6 @@ mod document; mod keyword; pub(crate) mod pipe; mod search_path; -mod snippets; mod subset; mod workspace; @@ -27,6 +26,23 @@ use crate::lsp::completions::sources::CompletionSource; use crate::treesitter::NodeType; use crate::treesitter::NodeTypeExt; +#[derive(Clone, Hash, PartialEq, Eq)] +struct CompletionItemKey { + label: String, + kind_str: String, +} + +impl CompletionItemKey { + fn new(item: &CompletionItem) -> Self { + Self { + label: item.label.clone(), + kind_str: item + .kind + .map_or_else(|| "Text".to_string(), |k| format!("{:?}", k)), + } + } +} + // Locally useful data structure for tracking completions and their source #[derive(Clone, Default)] struct CompletionItemWithSource { @@ -60,12 +76,6 @@ pub(crate) fn get_completions( if is_identifier_like(completion_context.document_context.node) { push_completions(keyword::KeywordSource, completion_context, &mut completions)?; - push_completions( - snippets::SnippetSource, - completion_context, - &mut completions, - )?; - push_completions( search_path::SearchPathSource, completion_context, @@ -94,7 +104,7 @@ pub(crate) fn get_completions( fn push_completions( source: S, completion_context: &CompletionContext, - completions: &mut HashMap, + completions: &mut HashMap, ) -> anyhow::Result<()> where S: CompletionSource, @@ -103,15 +113,17 @@ where if let Some(source_completions) = collect_completions(source, completion_context)? { for item in source_completions { - if let Some(existing) = completions.get(&item.label) { + let key = CompletionItemKey::new(&item); + if let Some(existing) = completions.get(&key) { log::trace!( - "Completion with label '{}' already exists (first contributed by source: {}, now also from: {})", - item.label, + "Completion with label '{}' and kind '{:?}' already exists (first contributed by source: {}, now also from: {})", + key.label, + key.kind_str, existing.source, source_name ); } else { - completions.insert(item.label.clone(), CompletionItemWithSource { + completions.insert(key, CompletionItemWithSource { item, source: source_name.to_string(), }); @@ -124,7 +136,7 @@ where /// Produce plain old CompletionItems and sort them fn finalize_completions( - completions: HashMap, + completions: HashMap, ) -> Vec { let mut items: Vec = completions .into_values() @@ -183,8 +195,7 @@ fn is_identifier_like(x: Node) -> bool { // non-`identifier` kinds. However, we do still want to provide completions // here, especially in two cases: // - `for` should provide completions for things like `forcats` - // - `for` should provide snippet completions for the `for` snippet - // The keywords here come from matching snippets in `r.code-snippets`. + // - completions of certain reserved words from the keyword source if matches!(x.node_type(), NodeType::Anonymous(kind) if matches!(kind.as_str(), "if" | "for" | "while")) { return true; @@ -208,7 +219,7 @@ mod tests { fn test_completions_on_anonymous_node_keywords() { r_task(|| { // `if`, `for`, and `while` in particular are both tree-sitter - // anonymous nodes and snippet keywords, so they need to look like + // anonymous nodes and keywords, so they need to look like // identifiers that we provide completions for for keyword in ["if", "for", "while"] { let point = Point { row: 0, column: 0 }; diff --git a/crates/ark/src/lsp/completions/sources/composite/keyword.rs b/crates/ark/src/lsp/completions/sources/composite/keyword.rs index d26ad53e6..df9e8a7d5 100644 --- a/crates/ark/src/lsp/completions/sources/composite/keyword.rs +++ b/crates/ark/src/lsp/completions/sources/composite/keyword.rs @@ -8,6 +8,11 @@ use stdext::unwrap; use tower_lsp::lsp_types::CompletionItem; use tower_lsp::lsp_types::CompletionItemKind; +use tower_lsp::lsp_types::CompletionItemLabelDetails; +use tower_lsp::lsp_types::Documentation; +use tower_lsp::lsp_types::InsertTextFormat; +use tower_lsp::lsp_types::MarkupContent; +use tower_lsp::lsp_types::MarkupKind; use crate::lsp::completions::completion_context::CompletionContext; use crate::lsp::completions::completion_item::completion_item; @@ -32,28 +37,82 @@ impl CompletionSource for KeywordSource { pub fn completions_from_keywords() -> anyhow::Result>> { let mut completions = vec![]; - // provide keyword completion results - // NOTE: Some R keywords have definitions provided in the R - // base namespace, so we don't need to provide duplicate - // definitions for these here. - let keywords = vec![ - "NULL", - "NA", - "TRUE", - "FALSE", - "Inf", - "NaN", - "NA_integer_", - "NA_real_", - "NA_character_", - "NA_complex_", - "in", - "else", - "next", - "break", - ]; - - for keyword in keywords { + add_bare_keywords(&mut completions); + add_keyword_snippets(&mut completions); + + Ok(Some(completions)) +} + +const BARE_KEYWORDS: &[&str] = &[ + "TRUE", + "FALSE", + "NULL", + "Inf", + "NaN", + "NA", + "NA_integer_", + "NA_real_", + "NA_complex_", + "NA_character_", + "if", + "else", + "repeat", + "while", + "function", + "for", + "in", + "next", + "break", +]; + +struct KeywordSnippet { + keyword: &'static str, + label: &'static str, + snippet: &'static str, + label_details_description: &'static str, +} + +const KEYWORD_SNIPPETS: &[KeywordSnippet] = &[ + KeywordSnippet { + keyword: "if", + label: "if", + snippet: "if (${1:condition}) {\n\t${0}\n}", + label_details_description: "An if statement", + }, + KeywordSnippet { + keyword: "else", + label: "else", + snippet: "else {\n\t${0}\n}", + label_details_description: "An else statement", + }, + KeywordSnippet { + keyword: "repeat", + label: "repeat", + snippet: "repeat {\n\t${0}\n}", + label_details_description: "A repeat loop", + }, + KeywordSnippet { + keyword: "while", + label: "while", + snippet: "while (${1:condition}) {\n\t${0}\n}", + label_details_description: "A while loop", + }, + KeywordSnippet { + keyword: "function", + label: "fun", + snippet: "${1:name} <- function(${2:variables}) {\n\t${0}\n}", + label_details_description: "Define a function", + }, + KeywordSnippet { + keyword: "for", + label: "for", + snippet: "for (${1:variable} in ${2:vector}) {\n\t${0}\n}", + label_details_description: "A for loop", + }, +]; + +fn add_bare_keywords(completions: &mut Vec) { + for keyword in BARE_KEYWORDS { let item = completion_item(keyword.to_string(), CompletionData::Keyword { name: keyword.to_string(), }); @@ -63,11 +122,107 @@ pub fn completions_from_keywords() -> anyhow::Result> continue; }); - item.detail = Some("[keyword]".to_string()); item.kind = Some(CompletionItemKind::KEYWORD); + item.label_details = Some(CompletionItemLabelDetails { + detail: None, + description: Some("[keyword]".to_string()), + }); completions.push(item); } +} - Ok(Some(completions)) +fn add_keyword_snippets(completions: &mut Vec) { + for KeywordSnippet { + keyword, + label, + snippet, + label_details_description, + } in KEYWORD_SNIPPETS + { + let item = completion_item(label.to_string(), CompletionData::Snippet { + text: snippet.to_string(), + }); + + let mut item = match item { + Ok(item) => item, + Err(err) => { + log::trace!("Failed to construct completion item for reserved keyword '{keyword}' due to {err:?}"); + continue; + }, + }; + + // Markup shows up in the quick suggestion documentation window, + // so you can see what the snippet expands to + let markup = vec!["```r", snippet, "```"].join("\n"); + let markup = MarkupContent { + kind: MarkupKind::Markdown, + value: markup, + }; + + item.documentation = Some(Documentation::MarkupContent(markup)); + item.kind = Some(CompletionItemKind::SNIPPET); + item.insert_text = Some(snippet.to_string()); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + item.label_details = Some(CompletionItemLabelDetails { + detail: None, + description: Some(label_details_description.to_string()), + }); + + completions.push(item); + } +} + +#[cfg(test)] +mod tests { + use tower_lsp::lsp_types::CompletionItemLabelDetails; + + #[test] + fn test_presence_bare_keywords() { + let completions = super::completions_from_keywords().unwrap().unwrap(); + let keyword_completions: Vec<_> = completions + .iter() + .filter(|item| item.kind == Some(tower_lsp::lsp_types::CompletionItemKind::KEYWORD)) + .collect(); + + for keyword in super::BARE_KEYWORDS { + let item = keyword_completions + .iter() + .find(|item| item.label == *keyword); + assert!( + item.is_some(), + "Expected keyword '{keyword}' not found in completions" + ); + let item = item.unwrap(); + assert_eq!( + item.label_details, + Some(CompletionItemLabelDetails { + detail: None, + description: Some("[keyword]".to_string()), + }) + ); + } + } + + #[test] + fn test_presence_keyword_snippets() { + let completions = super::completions_from_keywords().unwrap().unwrap(); + let snippet_completions: Vec<_> = completions + .iter() + .filter(|item| item.kind == Some(tower_lsp::lsp_types::CompletionItemKind::SNIPPET)) + .collect(); + + let snippet_labels: Vec<&str> = super::KEYWORD_SNIPPETS + .iter() + .map(|snippet| snippet.label) + .collect(); + + for label in snippet_labels { + let item = snippet_completions.iter().find(|item| item.label == label); + assert!( + item.is_some(), + "Expected snippet '{label}' with SNIPPET kind not found in completions" + ); + } + } } diff --git a/crates/ark/src/lsp/completions/sources/composite/search_path.rs b/crates/ark/src/lsp/completions/sources/composite/search_path.rs index 24ca2aed9..5601cec23 100644 --- a/crates/ark/src/lsp/completions/sources/composite/search_path.rs +++ b/crates/ark/src/lsp/completions/sources/composite/search_path.rs @@ -52,8 +52,8 @@ fn completions_from_search_path( ) -> anyhow::Result>> { let mut completions = vec![]; - const R_CONTROL_FLOW_KEYWORDS: &[&str] = &[ - "if", "else", "for", "in", "while", "repeat", "break", "next", + const KEYWORD_SOURCE: &[&str] = &[ + "if", "else", "repeat", "while", "function", "for", "in", "next", "break", ]; unsafe { @@ -94,9 +94,9 @@ fn completions_from_search_path( continue; }; - // Skip control flow keywords. + // Skip anything that is covered by the keyword source. let symbol = symbol.as_str(); - if R_CONTROL_FLOW_KEYWORDS.contains(&symbol) { + if KEYWORD_SOURCE.contains(&symbol) { continue; } diff --git a/crates/ark/src/lsp/completions/sources/composite/snippets.rs b/crates/ark/src/lsp/completions/sources/composite/snippets.rs deleted file mode 100644 index 455323c7c..000000000 --- a/crates/ark/src/lsp/completions/sources/composite/snippets.rs +++ /dev/null @@ -1,131 +0,0 @@ -// -// snippets.rs -// -// Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. -// -// - -use std::collections::HashMap; -use std::sync::LazyLock; - -use rust_embed::RustEmbed; -use serde::Deserialize; -use tower_lsp::lsp_types::CompletionItem; -use tower_lsp::lsp_types::CompletionItemKind; -use tower_lsp::lsp_types::Documentation; -use tower_lsp::lsp_types::InsertTextFormat; -use tower_lsp::lsp_types::MarkupContent; -use tower_lsp::lsp_types::MarkupKind; - -use crate::lsp::completions::completion_context::CompletionContext; -use crate::lsp::completions::completion_item::completion_item; -use crate::lsp::completions::sources::CompletionSource; -use crate::lsp::completions::types::CompletionData; - -#[derive(RustEmbed)] -#[folder = "resources/snippets/"] -struct Asset; - -#[derive(Deserialize)] -struct Snippet { - prefix: String, - body: SnippetBody, - description: String, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum SnippetBody { - Scalar(String), - Vector(Vec), -} - -pub(super) struct SnippetSource; - -impl CompletionSource for SnippetSource { - fn name(&self) -> &'static str { - "snippet" - } - - fn provide_completions( - &self, - _completion_context: &CompletionContext, - ) -> anyhow::Result>> { - completions_from_snippets() - } -} - -pub(crate) fn completions_from_snippets() -> anyhow::Result>> { - // Return clone of cached snippet completion items - let completions = get_completions_from_snippets().clone(); - - Ok(Some(completions)) -} - -fn get_completions_from_snippets() -> &'static Vec { - static SNIPPETS: LazyLock> = - LazyLock::new(|| init_completions_from_snippets()); - &SNIPPETS -} - -fn init_completions_from_snippets() -> Vec { - // Load snippets JSON from embedded file - let file = Asset::get("r.code-snippets").unwrap(); - let snippets: HashMap = serde_json::from_slice(&file.data).unwrap(); - - let mut completions = vec![]; - - for snippet in snippets.values() { - let label = snippet.prefix.clone(); - let details = snippet.description.clone(); - - let body = match &snippet.body { - SnippetBody::Scalar(body) => body.clone(), - SnippetBody::Vector(body) => body.join("\n"), - }; - - // Markup shows up in the quick suggestion documentation window, - // so you can see what the snippet expands to - let markup = vec!["```r", body.as_str(), "```"].join("\n"); - let markup = MarkupContent { - kind: MarkupKind::Markdown, - value: markup, - }; - - let mut item = - completion_item(label, CompletionData::Snippet { text: body.clone() }).unwrap(); - - item.detail = Some(details); - item.documentation = Some(Documentation::MarkupContent(markup)); - item.kind = Some(CompletionItemKind::SNIPPET); - - item.insert_text = Some(body); - item.insert_text_format = Some(InsertTextFormat::SNIPPET); - - completions.push(item); - } - - completions -} - -#[cfg(test)] -mod tests { - use crate::lsp::completions::sources::composite::snippets::completions_from_snippets; - - #[test] - fn test_snippets() { - let snippets = completions_from_snippets().unwrap().unwrap(); - - // Hash map isn't stable with regards to ordering - let item = snippets.iter().find(|item| item.label == "lib").unwrap(); - assert_eq!(item.detail, Some("Attach an R package".to_string())); - assert_eq!(item.insert_text, Some("library(${1:package})".to_string())); - - // Multiline body - let item = snippets.iter().find(|item| item.label == "if").unwrap(); - assert_eq!( - item.insert_text, - Some("if (${1:condition}) {\n\t${0}\n}".to_string()) - ); - } -}