diff --git a/Cargo.lock b/Cargo.lock index 7f935c1cb..04600a102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,7 @@ dependencies = [ "devenv-eval-cache", "devenv-tasks", "dotlock", + "fs_extra", "hex", "include_dir", "indoc", @@ -617,6 +618,7 @@ dependencies = [ "serde_yaml", "sha2", "sqlx", + "temp-dir", "tempdir", "tempfile", "thiserror", @@ -854,6 +856,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -2659,6 +2667,12 @@ dependencies = [ "libc", ] +[[package]] +name = "temp-dir" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" + [[package]] name = "tempdir" version = "0.3.7" @@ -2798,6 +2812,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 2c7436185..86cfee319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,8 @@ tokio = { version = "1.39.3", features = [ "sync", "time", "io-std", + "test-util", + "full", ] } which = "6.0.0" whoami = "1.5.1" @@ -77,6 +79,8 @@ xdg = "2.5.2" tower-lsp = "0.20.0" tracing-appender = "0.2.3" dashmap = "6.1.0" +fs_extra = "1.3.0" +temp-dir = "0.1.13" # Using older version of tree-sitter due to tree-sitter-nix not being updated yet tree-sitter = "0.20" tree-sitter-nix = "0.0.1" diff --git a/devenv/Cargo.toml b/devenv/Cargo.toml index 194970eb6..53b99605c 100644 --- a/devenv/Cargo.toml +++ b/devenv/Cargo.toml @@ -48,6 +48,8 @@ tracing-appender.workspace = true dashmap.workspace = true tree-sitter.workspace = true tree-sitter-nix.workspace = true +fs_extra.workspace = true +temp-dir.workspace = true [build-dependencies] cc = "*" diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 326b556ff..0d3fc9ca6 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -1,4 +1,4 @@ -use super::{cli, cnix, config, log, lsp, tasks, utils}; +use super::{cli, cnix, config, lsp, tasks, utils}; use clap::crate_version; use cli_table::Table; use cli_table::{print_stderr, WithTitle}; @@ -8,7 +8,6 @@ use miette::{bail, Result}; use nix::sys::signal; use nix::unistd::Pid; use serde::Deserialize; -use serde_json::Value; use sha2::Digest; use std::collections::HashMap; use std::io::Write; @@ -398,7 +397,9 @@ impl Devenv { } pub async fn lsp(&mut self) -> Result<()> { + self.assemble(false)?; let options = self.nix.build(&["optionsJSON"]).await?; + debug!("{:?}", options); let options_path = options[0] .join("share") .join("doc") diff --git a/devenv/src/lsp.rs b/devenv/src/lsp.rs index 00c9f11b2..73c611374 100644 --- a/devenv/src/lsp.rs +++ b/devenv/src/lsp.rs @@ -1,29 +1,150 @@ use dashmap::DashMap; use regex::Regex; use serde_json::Value; -use std::ops::Deref; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; use tower_lsp::{Client, LanguageServer}; use tracing::{debug, info}; -#[derive(Debug)] +use tree_sitter::{Node, Parser, Point, Tree, TreeCursor}; +use tree_sitter_nix::language; + +#[derive(Clone, Debug)] pub struct Backend { pub client: Client, - // document store in memory - pub document_map: DashMap, + // pub document_map: DashMap, + pub document_map: DashMap, pub completion_json: Value, } + impl Backend { - fn parse_line(&self, line: &str) -> (Vec, String) { + pub fn new(client: Client, completion_json: Value) -> Self { + let mut parser = Parser::new(); + parser + .set_language(tree_sitter_nix::language()) + .expect("Unable to load the nix language file"); + Backend { + client, + document_map: DashMap::new(), + completion_json, + } + } + + fn get_completion_items(&self, uri: &str, position: Position) -> Vec { + println!("Test document uri {:?}", uri); + let (content, tree) = self + .document_map + .get(uri) + .expect("Document not found") + .clone(); + let root_node = tree.root_node(); + let point = Point::new(position.line as usize, position.character as usize); + + let scope_path = self.get_scope(root_node, point, &content); + let line_content = content + .lines() + .nth(position.line as usize) + .unwrap_or_default(); + let line_until_cursor = &line_content[..position.character as usize]; + let dot_path = self.get_path(line_until_cursor); + + let re = Regex::new(r".*\W(.*)").unwrap(); + let current_word = re + .captures(line_until_cursor) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) + .unwrap_or(""); + + let search_path = [scope_path.clone(), dot_path].concat(); + let completions = self.search_json(&search_path, current_word); + + completions + .into_iter() + .map(|(item, description)| { + CompletionItem::new_simple(item, description.unwrap_or_default()) + }) + .collect() + } + + fn parse_document(&self, content: &str) -> Tree { + let mut parser = Parser::new(); + let nix_grammar = language(); + parser + .set_language(nix_grammar) + .expect("Error loading Nix grammar"); + parser + .parse(content, None) + .expect("Failed to parse document") + } + + fn update_document(&self, uri: &str, content: String) { + let tree = self.parse_document(&content); + self.document_map.insert(uri.to_string(), (content, tree)); + } + + fn get_scope(&self, root_node: Node, cursor_position: Point, source: &str) -> Vec { + debug!("Getting scope for cursor position: {:?}", cursor_position); + debug!("Source code is: {:?}", source); + let mut scope = Vec::new(); + + if let Some(node) = root_node.descendant_for_point_range(cursor_position, cursor_position) { + let mut cursor = node.walk(); + self.traverse_up(&mut cursor, &mut scope, source); + } + + scope.reverse(); + debug!("Final scope: {:?}", scope); + scope + } + + fn traverse_up(&self, cursor: &mut TreeCursor, scope: &mut Vec, source: &str) { + loop { + let node = cursor.node(); + debug!( + "Current node kind: {}, text: {:?}", + node.kind(), + node.utf8_text(source.as_bytes()) + ); + + match node.kind() { + "attrpath" => { + if let Ok(text) = node.utf8_text(source.as_bytes()) { + let attrs: Vec = text.split('.').map(String::from).collect(); + scope.extend(attrs); + } + } + "binding" => { + if let Some(attrpath) = node.child_by_field_name("attrpath") { + if let Ok(text) = attrpath.utf8_text(source.as_bytes()) { + let attrs: Vec = text.split('.').map(String::from).collect(); + scope.extend(attrs); + } + } + } + "attrset_expression" => { + // We've reached an attribute set, continue traversing up + } + _ => { + // For other node types, we don't add to the scope + } + } + + if !cursor.goto_parent() { + break; + } + } + } + + fn get_path(&self, line: &str) -> Vec { let parts: Vec<&str> = line.split('.').collect(); - let partial_key = parts.last().unwrap_or(&"").to_string(); + let path = parts[..parts.len() - 1] .iter() - .map(|&s| s.to_string()) + .map(|&s| s.trim().to_string()) .collect(); - (path, partial_key) + return path; } - fn search_json(&self, path: &[String], partial_key: &str) -> Vec { + + fn search_json(&self, path: &[String], partial_key: &str) -> Vec<(String, Option)> { let mut current = &self.completion_json; for key in path { if let Some(value) = current.get(key) { @@ -32,16 +153,27 @@ impl Backend { return Vec::new(); } } + match current { Value::Object(map) => map - .keys() - .filter(|k| k.starts_with(partial_key)) - .cloned() + .iter() + .filter(|(k, _)| k.starts_with(partial_key)) + .map(|(k, v)| { + let description = match v { + Value::Object(obj) => obj + .get("description") + .and_then(|d| d.as_str()) + .map(String::from), + _ => None, + }; + (k.clone(), description) + }) .collect(), _ => Vec::new(), } } } + #[tower_lsp::async_trait] impl LanguageServer for Backend { async fn initialize(&self, _: InitializeParams) -> Result { @@ -53,13 +185,13 @@ impl LanguageServer for Backend { )), completion_provider: Some(CompletionOptions { resolve_provider: Some(false), - trigger_characters: Some(vec![".".to_string()]), + trigger_characters: Some(vec![".".to_string(), "\n".to_string()]), work_done_progress_options: Default::default(), all_commit_characters: None, ..Default::default() }), execute_command_provider: Some(ExecuteCommandOptions { - commands: vec!["dummy.do_something".to_string()], + commands: vec![], work_done_progress_options: Default::default(), }), workspace: Some(WorkspaceServerCapabilities { @@ -74,106 +206,90 @@ impl LanguageServer for Backend { ..Default::default() }) } + async fn initialized(&self, _: InitializedParams) { self.client .log_message(MessageType::INFO, "devenv lsp is now initialized!") .await; } + async fn shutdown(&self) -> Result<()> { Ok(()) } + async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) { self.client .log_message(MessageType::INFO, "workspace folders changed!") .await; } + async fn did_change_configuration(&self, _: DidChangeConfigurationParams) { self.client .log_message(MessageType::INFO, "configuration changed!") .await; } + async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) { self.client .log_message(MessageType::INFO, "watched files have changed!") .await; } + async fn execute_command(&self, _: ExecuteCommandParams) -> Result> { self.client .log_message(MessageType::INFO, "command executed!") .await; + match self.client.apply_edit(WorkspaceEdit::default()).await { Ok(res) if res.applied => self.client.log_message(MessageType::INFO, "applied").await, Ok(_) => self.client.log_message(MessageType::INFO, "rejected").await, Err(err) => self.client.log_message(MessageType::ERROR, err).await, } + Ok(None) } - async fn did_open(&self, _: DidOpenTextDocumentParams) { + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + self.client + .log_message(MessageType::INFO, "file opened!") + .await; + let uri = params.text_document.uri.to_string(); + let content = params.text_document.text; + self.update_document(&uri, content); self.client .log_message(MessageType::INFO, "file opened!") .await; - info!("textDocument/DidOpen"); } + async fn did_change(&self, params: DidChangeTextDocumentParams) { - // info!("textDocument/DidChange, params: {:?}", params); - self.document_map.insert( - params.text_document.uri.to_string(), - params.content_changes[0].text.clone(), - ); + let uri = params.text_document.uri.to_string(); + let content = params.content_changes[0].text.clone(); + self.update_document(&uri, content); self.client .log_message(MessageType::INFO, "file changed!") .await; } + async fn did_save(&self, _: DidSaveTextDocumentParams) { info!("textDocument/DidSave"); self.client .log_message(MessageType::INFO, "file saved!") .await; } + async fn did_close(&self, _: DidCloseTextDocumentParams) { info!("textDocument/DidClose"); self.client .log_message(MessageType::INFO, "file closed!") .await; } + async fn completion(&self, params: CompletionParams) -> Result> { - info!("textDocument/Completion"); - let uri = params.text_document_position.text_document.uri; - let file_content = match self.document_map.get(uri.as_str()) { - Some(content) => { - debug!("Text document content via DashMap: {:?}", content.deref()); - content.clone() - } - None => { - info!("No content found for the given URI"); - String::new() - } - }; + let uri = params.text_document_position.text_document.uri.to_string(); let position = params.text_document_position.position; - let line = position.line as usize; - let character = position.character as usize; - let line_content = file_content.lines().nth(line).unwrap_or_default(); - let line_until_cursor = &line_content[..character]; - // handling regex for getting the current word - let re = Regex::new(r".*\W(.*)").unwrap(); // Define the regex pattern - let current_word = re - .captures(line_until_cursor) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .unwrap_or(""); - debug!("Current line content {:?}", line_content); - debug!("Line until cursor: {:?}", line_until_cursor); - debug!("Current word {:?}", current_word); - // Parse the line to get the current path and partial key - let (path, partial_key) = self.parse_line(current_word); - // Search for completions in the JSON - let completions = self.search_json(&path, &partial_key); - info!("Probable completion items {:?}", completions); - // covert completions to CompletionItems format - let completion_items: Vec<_> = completions - .iter() - .map(|item| CompletionItem::new_simple(item.to_string(), "".to_string())) - .collect(); + + let completion_items = self.get_completion_items(&uri, position); + Ok(Some(CompletionResponse::List(CompletionList { is_incomplete: false, items: completion_items, diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 9732c7167..1ea402526 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -1,13 +1,12 @@ use clap::crate_version; use devenv::{ cli::{Cli, Commands, ContainerCommand, InputsCommand, ProcessesCommand, TasksCommand}, - config, log, utils, Devenv, + config, log, Devenv, }; use miette::Result; -use std::fs::File; use std::io::BufWriter; use tracing::level_filters::LevelFilter; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; #[tokio::main] async fn main() -> Result<()> { diff --git a/devenv/src/utils.rs b/devenv/src/utils.rs index d1760087c..6a0237c0e 100644 --- a/devenv/src/utils.rs +++ b/devenv/src/utils.rs @@ -1,5 +1,4 @@ use serde_json::{Map, Value}; -use tracing::debug; fn filter_attr(json_value: &mut Value, key: &str) { match json_value { Value::Object(ref mut map) => { diff --git a/devenv/tests/basic.rs b/devenv/tests/basic.rs new file mode 100644 index 000000000..c9e811283 --- /dev/null +++ b/devenv/tests/basic.rs @@ -0,0 +1,7 @@ +mod common; +use crate::common::*; +#[tokio::test] +async fn should_initialize() { + TestContext::new("simple").initialize().await; + // panic!("Don’t panic!"); +} diff --git a/devenv/tests/common/mod.rs b/devenv/tests/common/mod.rs new file mode 100644 index 000000000..19afd52d8 --- /dev/null +++ b/devenv/tests/common/mod.rs @@ -0,0 +1,332 @@ +#![allow(dead_code)] + +use core::panic; +use devenv::lsp::Backend; +use fs_extra::dir::CopyOptions; +use std::fmt::Debug; +use std::fs; +use std::io::Write; +use std::path::Path; +use temp_dir::TempDir; + +use tokio::io::{duplex, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, DuplexStream}; +use tower_lsp::lsp_types::notification::Notification; +use tower_lsp::lsp_types::{InitializedParams, Url, WorkspaceFolder}; +use tower_lsp::{jsonrpc, lsp_types, lsp_types::request::Request, LspService, Server}; + +fn encode_message(content_type: Option<&str>, message: &str) -> String { + let content_type = content_type + .map(|ty| format!("\r\nContent-Type: {ty}")) + .unwrap_or_default(); + format!( + "Content-Length: {}{}\r\n\r\n{}", + message.len(), + content_type, + message + ) +} + +pub struct TestContext { + pub request_tx: DuplexStream, + pub response_rx: BufReader, + pub _server: tokio::task::JoinHandle<()>, + pub request_id: i64, + pub workspace: TempDir, +} + +impl TestContext { + pub fn new(base: &str) -> Self { + let (request_tx, req_server) = duplex(1024); + let (resp_server, response_rx) = duplex(1024); + let response_rx = BufReader::new(response_rx); + // create a demo completion json file + let completion_json = serde_json::json!({ "languages": { + "python": { "description": "Python language" }, + "nodejs": { "description": "Node.js runtime" } + }, + "services": { + "nginx": { "description": "Web server" }, + "redis": { "description": "Cache server" } + } + }); + + let (service, socket) = + LspService::build(|client| Backend::new(client, completion_json.clone())).finish(); + let server = tokio::spawn(Server::new(req_server, resp_server, socket).serve(service)); + // + // create a temporary workspace an init it with our test inputs + let workspace = TempDir::new().unwrap(); + for item in fs::read_dir(Path::new("tests").join("workspace").join(base)).unwrap() { + eprintln!("copying {item:?}"); + fs_extra::copy_items( + &[item.unwrap().path()], + workspace.path(), + &CopyOptions::new(), + ) + .unwrap(); + } + + Self { + request_tx, + response_rx, + _server: server, + request_id: 0, + workspace, + } + } + + pub fn doc_uri(&self, path: &str) -> Url { + Url::from_file_path(self.workspace.path().join(path)).unwrap() + } + + pub async fn recv(&mut self) -> R { + loop { + // first line is the content length header + let mut clh = String::new(); + self.response_rx.read_line(&mut clh).await.unwrap(); + if !clh.starts_with("Content-Length") { + panic!("missing content length header"); + } + let length = clh + .trim_start_matches("Content-Length: ") + .trim() + .parse::() + .unwrap(); + // next line is just a blank line + self.response_rx.read_line(&mut clh).await.unwrap(); + // then the message, of the size given by the content length header + let mut content = vec![0; length]; + self.response_rx.read_exact(&mut content).await.unwrap(); + let content = String::from_utf8(content).unwrap(); + eprintln!("received: {content}"); + std::io::stderr().flush().unwrap(); + // skip log messages + if content.contains("window/logMessage") { + continue; + } + let response = serde_json::from_str::(&content).unwrap(); + let (_method, _id, params) = response.into_parts(); + return serde_json::from_value(params.unwrap()).unwrap(); + } + } + + pub async fn response(&mut self) -> R { + loop { + // first line is the content length header + let mut clh = String::new(); + self.response_rx.read_line(&mut clh).await.unwrap(); + if !clh.starts_with("Content-Length") { + panic!("missing content length header"); + } + let length = clh + .trim_start_matches("Content-Length: ") + .trim() + .parse::() + .unwrap(); + // next line is just a blank line + self.response_rx.read_line(&mut clh).await.unwrap(); + // then the message, of the size given by the content length header + let mut content = vec![0; length]; + self.response_rx.read_exact(&mut content).await.unwrap(); + let content = String::from_utf8(content).unwrap(); + eprintln!("received: {content}"); + std::io::stderr().flush().unwrap(); + // skip log messages + if content.contains("window/logMessage") { + continue; + } + let response = serde_json::from_str::(&content).unwrap(); + let (_id, result) = response.into_parts(); + return serde_json::from_value(result.unwrap()).unwrap(); + } + } + + pub async fn send(&mut self, request: &jsonrpc::Request) { + let content = serde_json::to_string(request).unwrap(); + eprintln!("\nsending: {content}"); + std::io::stderr().flush().unwrap(); + self.request_tx + .write_all(encode_message(None, &content).as_bytes()) + .await + .unwrap(); + } + + pub async fn notify(&mut self, params: N::Params) { + let notification = jsonrpc::Request::build(N::METHOD) + .params(serde_json::to_value(params).unwrap()) + .finish(); + self.send(¬ification).await; + } + + pub async fn request(&mut self, params: R::Params) -> R::Result + where + R::Result: Debug, + { + let request = jsonrpc::Request::build(R::METHOD) + .id(self.request_id) + .params(serde_json::to_value(params).unwrap()) + .finish(); + self.request_id += 1; + self.send(&request).await; + self.response().await + } + + pub async fn initialize(&mut self) { + // a real set of initialize param from helix. We just have to change the workspace configuration + let initialize = r#"{ + "capabilities": { + "general": { + "positionEncodings": [ + "utf-8", + "utf-32", + "utf-16" + ] + }, + "textDocument": { + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "dataSupport": true, + "disabledSupport": true, + "isPreferredSupport": true, + "resolveSupport": { + "properties": [ + "edit", + "command" + ] + } + }, + "completion": { + "completionItem": { + "deprecatedSupport": true, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "snippetSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + } + }, + "completionItemKind": {} + }, + "hover": { + "contentFormat": [ + "markdown" + ] + }, + "inlayHint": { + "dynamicRegistration": false + }, + "publishDiagnostics": { + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "versionSupport": true + }, + "rename": { + "dynamicRegistration": false, + "honorsChangeAnnotations": false, + "prepareSupport": true + }, + "signatureHelp": { + "signatureInformation": { + "activeParameterSupport": true, + "documentationFormat": [ + "markdown" + ], + "parameterInformation": { + "labelOffsetSupport": true + } + } + } + }, + "window": { + "workDoneProgress": true + }, + "workspace": { + "applyEdit": true, + "configuration": true, + "didChangeConfiguration": { + "dynamicRegistration": false + }, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": false + }, + "executeCommand": { + "dynamicRegistration": false + }, + "fileOperations": { + "didRename": true, + "willRename": true + }, + "inlayHint": { + "refreshSupport": false + }, + "symbol": { + "dynamicRegistration": false + }, + "workspaceEdit": { + "documentChanges": true, + "failureHandling": "abort", + "normalizesLineEndings": false, + "resourceOperations": [ + "create", + "rename", + "delete" + ] + }, + "workspaceFolders": true + } + }, + "clientInfo": { + "name": "helix", + "version": "24.3 (109f53fb)" + }, + "processId": 28774, + "rootPath": "/Users/glehmann/src/earthlyls", + "rootUri": "file:///Users/glehmann/src/earthlyls", + "workspaceFolders": [ + { + "name": "sdk", + "uri": "file:///Users/glehmann/src/earthlyls" + } + ] + }"#; + let mut initialize: ::Params = + serde_json::from_str(initialize).unwrap(); + let workspace_url = Url::from_file_path(self.workspace.path()).unwrap(); + // initialize.root_path = Some(self.workspace.path().to_string_lossy().to_string()); + initialize.root_uri = Some(workspace_url.clone()); + initialize.workspace_folders = Some(vec![WorkspaceFolder { + name: "tmp".to_owned(), + uri: workspace_url.clone(), + }]); + self.request::(initialize) + .await; + self.notify::(InitializedParams {}) + .await; + } +} diff --git a/devenv/tests/completions.rs b/devenv/tests/completions.rs new file mode 100644 index 000000000..b869f6a62 --- /dev/null +++ b/devenv/tests/completions.rs @@ -0,0 +1,117 @@ +mod common; +use crate::common::*; +use tower_lsp::lsp_types::{ + CompletionContext, CompletionParams, CompletionTriggerKind, Position, TextDocumentIdentifier, + TextDocumentPositionParams, +}; +#[tokio::test] +async fn test_simple_completions() { + let mut ctx = TestContext::new("simple"); + ctx.initialize().await; + let test_content = r#"{ pkgs, lib, config, inputs, ... }: + { + languages. + }"#; + ctx.notify::( + tower_lsp::lsp_types::DidOpenTextDocumentParams { + text_document: tower_lsp::lsp_types::TextDocumentItem { + uri: ctx.doc_uri("test.nix"), + language_id: "nix".to_string(), + version: 1, + text: test_content.to_string(), + }, + }, + ) + .await; + // Add a small delay to ensure the document is processed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let completion_response = ctx + .request::(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: ctx.doc_uri("test.nix"), + }, + position: Position { + line: 2, + character: 18, + }, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(".".to_string()), + }), + }) + .await; + if let Some(tower_lsp::lsp_types::CompletionResponse::List(list)) = completion_response { + assert!(!list.items.is_empty(), "Should have completion items"); + let item_labels: Vec = list.items.into_iter().map(|item| item.label).collect(); + assert!( + item_labels.contains(&"python".to_string()), + "Should suggest python" + ); + assert!( + item_labels.contains(&"nodejs".to_string()), + "Should suggest nodejs" + ); + } else { + panic!("Expected CompletionResponse::List"); + } +} +#[tokio::test] +async fn test_simple_nested_completions() { + let mut ctx = TestContext::new("simple"); + ctx.initialize().await; + let test_content = r#"{ pkgs, lib, config, inputs, ... }: + { + languages = { + p + }"#; + ctx.notify::( + tower_lsp::lsp_types::DidOpenTextDocumentParams { + text_document: tower_lsp::lsp_types::TextDocumentItem { + uri: ctx.doc_uri("test.nix"), + language_id: "nix".to_string(), + version: 1, + text: test_content.to_string(), + }, + }, + ) + .await; + // Add a small delay to ensure the document is processed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let completion_response = ctx + .request::(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: ctx.doc_uri("test.nix"), + }, + position: Position { + line: 3, + character: 8, + }, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(".".to_string()), + }), + }) + .await; + if let Some(tower_lsp::lsp_types::CompletionResponse::List(list)) = completion_response { + assert!(!list.items.is_empty(), "Should have completion items"); + let item_labels: Vec = list.items.into_iter().map(|item| item.label).collect(); + assert!( + item_labels.contains(&"python".to_string()), + "Should suggest python" + ); + assert!( + item_labels.contains(&"nodejs".to_string()), + "Should suggest nodejs" + ); + } else { + panic!("Expected CompletionResponse::List"); + } +} diff --git a/devenv/tests/workspace/simple/test.nix b/devenv/tests/workspace/simple/test.nix new file mode 100644 index 000000000..783fdacbf --- /dev/null +++ b/devenv/tests/workspace/simple/test.nix @@ -0,0 +1,10 @@ +{ pkgs, config, ... }: { + env.GREET = "determinism"; + packages = [ + pkgs.ncdu + ]; + enterShell = '' + echo hello ${config.env.GREET} + ncdu --version + ''; +}