diff --git a/crates/csslsrs/src/features/completions.rs b/crates/csslsrs/src/features/completions.rs new file mode 100644 index 0000000..56209be --- /dev/null +++ b/crates/csslsrs/src/features/completions.rs @@ -0,0 +1,97 @@ +use biome_rowan::{AstNode, SyntaxNodeOptionExt}; +use lsp_types::{CompletionList, Position, TextDocumentItem}; + +use crate::{ + converters::{from_proto::offset, PositionEncoding}, + service::LanguageService, + store::StoreEntry, +}; + +fn compute_completions( + store_entry: &StoreEntry, + position: Position, + encoding: PositionEncoding, +) -> CompletionList { + let offset = offset(&store_entry.line_index, position, encoding).unwrap(); + let parent = store_entry + .css_tree + .tree() + .syntax() + .token_at_offset(offset) + .left_biased() + .unwrap() + .parent(); + + if let Some(kind) = parent.kind() { + match kind { + biome_css_syntax::CssSyntaxKind::CSS_ROOT => { + + } + _ => todo!(), + } + }; + + CompletionList { + is_incomplete: true, + items: vec![], + } +} + +impl LanguageService { + pub fn get_completions( + &self, + document: TextDocumentItem, + position: Position, + ) -> CompletionList { + let store_document = self.store.get(&document.uri); + + match store_document { + Some(store_document) => { + compute_completions(store_document, position, self.options.encoding) + } + None => empty_completion_list(), + } + } +} + +fn empty_completion_list() -> CompletionList { + CompletionList { + is_incomplete: true, + items: vec![], + } +} + +#[cfg(feature = "wasm")] +mod wasm_bindings { + use std::str::FromStr; + + use crate::{ + features::completions::{compute_completions, empty_completion_list}, + service::wasm_bindings::WASMLanguageService, + }; + use lsp_types::{Position, Uri}; + use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + + #[wasm_bindgen(typescript_custom_section)] + const TS_APPEND_CONTENT: &'static str = r#" + declare function get_completions(documentUri: string, position: import("vscode-languageserver-types").Position): import("vscode-languageserver-types").FoldingRange[]; + "#; + + #[wasm_bindgen] + impl WASMLanguageService { + #[wasm_bindgen(skip_typescript, js_name = getCompletions)] + pub fn get_completions(&self, document_uri: String, position: JsValue) -> JsValue { + let store_document = self.store.get(&Uri::from_str(&document_uri).unwrap()); + + let completions = match store_document { + Some(store_document) => { + let position: Position = serde_wasm_bindgen::from_value(position).unwrap(); + compute_completions(store_document, position, self.options.encoding) + } + None => empty_completion_list(), + }; + + serde_wasm_bindgen::to_value(&completions).unwrap() + } + } +} diff --git a/crates/csslsrs/src/lib.rs b/crates/csslsrs/src/lib.rs index 1e7d66b..e3f1e11 100644 --- a/crates/csslsrs/src/lib.rs +++ b/crates/csslsrs/src/lib.rs @@ -8,6 +8,7 @@ pub mod store; pub mod features { pub mod color_parser; pub mod colors; + pub mod completions; pub mod folding; pub mod hover; } diff --git a/crates/csslsrs/src/service.rs b/crates/csslsrs/src/service.rs index eef8ee3..1112f02 100644 --- a/crates/csslsrs/src/service.rs +++ b/crates/csslsrs/src/service.rs @@ -187,6 +187,7 @@ pub mod wasm_bindings { getDocumentColors: typeof get_document_colors; getColorPresentations: typeof get_color_presentations; getFoldingRanges: typeof get_folding_ranges; + getCompletions: typeof get_completions; free(): void; } "#; diff --git a/crates/csslsrs/tests/completions.rs b/crates/csslsrs/tests/completions.rs new file mode 100644 index 0000000..3222033 --- /dev/null +++ b/crates/csslsrs/tests/completions.rs @@ -0,0 +1,194 @@ +use std::str::FromStr; + +use csslsrs::{ + css_data::CssCustomData, + service::{LanguageService, LanguageServiceOptions}, +}; +use lsp_types::{ + Command, CompletionItem, CompletionItemKind, CompletionList, CompletionTextEdit, Documentation, + InsertTextFormat, Position, TextDocumentItem, Uri, +}; + +#[derive(Debug, Default)] +pub struct ItemDescription { + label: String, + detail: Option, + documentation: Option, + documentation_includes: Option, + kind: Option, + insert_text_format: Option, + result_text: Option, + not_available: Option, + command: Option, + sort_text: Option, +} + +pub fn assert_completion( + completions: &CompletionList, + expected: &ItemDescription, + document_content: &str, +) { + let matches: Vec<&CompletionItem> = completions + .items + .iter() + .filter(|completion| completion.label == expected.label) + .collect(); + + if expected.not_available.is_some() { + assert_eq!(matches.len(), 0, "{} should not be present", expected.label); + } else { + assert_eq!( + matches.len(), + 1, + "{} should only exist once: Actual: {}", + expected.label, + completions + .items + .iter() + .map(|c| c.label.as_str()) + .collect::>() + .join(", ") + ); + } + + let match_item = matches[0]; + if let Some(detail) = &expected.detail { + assert_eq!(match_item.detail, Some(detail.clone())); + } + if let Some(documentation) = &expected.documentation { + assert_eq!(match_item.documentation, Some(documentation.clone())); + } + if let Some(documentation_includes) = &expected.documentation_includes { + if let Some(doc) = &match_item.documentation { + match doc { + Documentation::String(doc) => { + assert!(doc.contains(documentation_includes)); + } + Documentation::MarkupContent(doc) => { + assert!(doc.value.contains(documentation_includes)); + } + } + } + } + if let Some(kind) = &expected.kind { + assert_eq!(match_item.kind, Some(*kind)); + } + if let Some(result_text) = &expected.result_text { + if let Some(text_edit) = &match_item.text_edit { + let edited_text = apply_text_edit(document_content, text_edit); + assert_eq!(edited_text, *result_text); + } + } + if let Some(insert_text_format) = &expected.insert_text_format { + assert_eq!(match_item.insert_text_format, Some(*insert_text_format)); + } + if let Some(command) = &expected.command { + assert_eq!(match_item.command, Some(command.clone())); + } + if let Some(sort_text) = &expected.sort_text { + assert_eq!(match_item.sort_text, Some(sort_text.clone())); + } +} + +fn apply_text_edit(document: &str, text_edit: &CompletionTextEdit) -> String { + let mut lines: Vec<&str> = document.lines().collect(); + + match text_edit { + CompletionTextEdit::Edit(edit) => { + let start_line = edit.range.start.line as usize; + let start_char = edit.range.start.character as usize; + let end_line = edit.range.end.line as usize; + let end_char = edit.range.end.character as usize; + + let start_line_content = &lines[start_line][..start_char]; + let end_line_content = &lines[end_line][end_char..]; + + let formatted_line = format!( + "{}{}{}", + start_line_content, edit.new_text, end_line_content + ); + lines[start_line] = formatted_line.as_str(); + lines[start_line + 1..=end_line] + .iter_mut() + .for_each(|line| *line = ""); + + lines.join("\n") + } + CompletionTextEdit::InsertAndReplace(_) => unreachable!(), + } +} + +struct ExpectedCompletions { + count: Option, + items: Option>, +} + +fn test_completion_for( + content: &str, + expected: ExpectedCompletions, + test_uri: &str, + custom_data: Vec, +) { + let offset = content.find('|').expect("| missing in value"); + let value = content.replace("|", ""); + + let mut ls = LanguageService::new(LanguageServiceOptions { + include_base_css_custom_data: true, + ..Default::default() + }); + + for data in custom_data { + ls.add_css_custom_data(data); + } + + let document = TextDocumentItem::new( + Uri::from_str(test_uri).unwrap(), + "css".to_string(), + 0, + value.clone(), + ); + + ls.upsert_document(document.clone()); + + let position = Position::new(0, offset as u32); + + let list = ls.get_completions(document, position); + + if let Some(count) = expected.count { + assert_eq!(list.items.len(), count); + } + if let Some(items) = expected.items { + for item in items { + assert_completion(&list, &item, &value); + } + } +} + +#[test] +fn test_top_level_completions() { + test_completion_for( + "| ", + ExpectedCompletions { + count: None, + items: Some(vec![ + ItemDescription { + label: "@import".to_string(), + result_text: Some("@import body {".to_string()), + ..Default::default() + }, + ItemDescription { + label: "@keyframes".to_string(), + result_text: Some("@keyframes body {".to_string()), + ..Default::default() + }, + ItemDescription { + label: "html".to_string(), + result_text: Some("html body {".to_string()), + ..Default::default() + }, + ]), + }, + "file:///test", + vec![], + ); +}