From 0b2ea717268ebe77429f12c796bcfcd9abde47a5 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Wed, 1 Oct 2025 20:01:39 +0200 Subject: [PATCH 01/11] refactor: extract lsp server module --- src/app/{lsp_server.rs => lsp_server/mod.rs} | 166 ++++++------------- src/app/lsp_server/supported_commands.rs | 94 +++++++++++ tests/general.rs | 122 +++++++++++--- 3 files changed, 244 insertions(+), 138 deletions(-) rename src/app/{lsp_server.rs => lsp_server/mod.rs} (68%) create mode 100644 src/app/lsp_server/supported_commands.rs diff --git a/src/app/lsp_server.rs b/src/app/lsp_server/mod.rs similarity index 68% rename from src/app/lsp_server.rs rename to src/app/lsp_server/mod.rs index 6e6615a..141b457 100644 --- a/src/app/lsp_server.rs +++ b/src/app/lsp_server/mod.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::path::PathBuf; use std::str::FromStr; -use serde_json::{Value, json}; +use serde_json::Value; use tokio::sync::RwLock; use tower_lsp::LanguageServer; use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; @@ -10,8 +10,8 @@ use tower_lsp::lsp_types::{ CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, Location, + MessageType, Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; @@ -21,6 +21,9 @@ use super::queries::QueryExecutor; use super::{InMemoryDocumentDatabase, LSPClient}; use crate::infra::{parse_compose_file, parse_dockerfile}; +mod supported_commands; +use supported_commands::SupportedCommands; + pub struct LSPServer { command_executor: CommandExecutor, query_executor: QueryExecutor, @@ -58,32 +61,6 @@ where } } -pub enum SupportedCommands { - ExecuteBaseImageScan, - ExecuteBuildAndScan, -} - -impl std::fmt::Display for SupportedCommands { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::ExecuteBaseImageScan => "sysdig-lsp.execute-scan", - Self::ExecuteBuildAndScan => "sysdig-lsp.execute-build-and-scan", - }) - } -} - -impl TryFrom<&str> for SupportedCommands { - type Error = String; - - fn try_from(value: &str) -> std::result::Result { - match value { - "sysdig-lsp.execute-scan" => Ok(SupportedCommands::ExecuteBaseImageScan), - "sysdig-lsp.execute-build-and-scan" => Ok(SupportedCommands::ExecuteBuildAndScan), - _ => Err(format!("command not supported: {value}")), - } - } -} - struct CommandInfo { title: String, command: String, @@ -115,22 +92,19 @@ where fn generate_compose_commands( &self, - uri: &tower_lsp::lsp_types::Url, + url: &tower_lsp::lsp_types::Url, content: &str, ) -> Vec { let mut commands = vec![]; if let Ok(instructions) = parse_compose_file(content) { for instruction in instructions { - commands.push(CommandInfo { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![ - json!(uri), - json!(instruction.range), - json!(instruction.image_name), - ]), - range: instruction.range, - }); + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(url.clone(), instruction.range), + image: instruction.image_name, + } + .into(), + ); } } commands @@ -149,21 +123,20 @@ where .next_back() { let range = last_from_instruction.range; - let line = last_from_instruction.range.start.line; - commands.push(CommandInfo { - title: "Build and scan".to_string(), - command: SupportedCommands::ExecuteBuildAndScan.to_string(), - arguments: Some(vec![json!(uri), json!(line)]), - range, - }); - - if let Some(image_name) = last_from_instruction.arguments.first() { - commands.push(CommandInfo { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![json!(uri), json!(range), json!(image_name)]), - range, - }); + commands.push( + SupportedCommands::ExecuteBuildAndScan { + location: Location::new(uri.clone(), range), + } + .into(), + ); + if let Some(image) = last_from_instruction.arguments.first() { + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(uri.clone(), range), + image: image.to_owned(), + } + .into(), + ); } } commands @@ -196,10 +169,7 @@ where resolve_provider: Some(false), }), execute_command_provider: Some(ExecuteCommandOptions { - commands: vec![ - SupportedCommands::ExecuteBaseImageScan.to_string(), - SupportedCommands::ExecuteBuildAndScan.to_string(), - ], + commands: SupportedCommands::all_supported_commands_as_string(), ..Default::default() }), ..Default::default() @@ -296,20 +266,25 @@ where } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - let command: SupportedCommands = params.command.as_str().try_into().map_err(|e| { - Error::internal_error().with_message(format!("unable to parse command: {e}")) - })?; + let command: SupportedCommands = params.try_into()?; + + let result = match command.clone() { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + execute_command_scan_base_image( + self, + location.uri.to_string(), + location.range, + image, + ) + .await + .map(|_| None) + } - let result = match command { - SupportedCommands::ExecuteBaseImageScan => { - execute_command_scan_base_image(self, ¶ms) + SupportedCommands::ExecuteBuildAndScan { location } => { + execute_command_build_and_scan(self, location.uri.to_string(), location.range) .await .map(|_| None) } - - SupportedCommands::ExecuteBuildAndScan => execute_command_build_and_scan(self, ¶ms) - .await - .map(|_| None), }; match result { @@ -331,32 +306,10 @@ where async fn execute_command_scan_base_image( server: &LSPServer, - params: &ExecuteCommandParams, + file: String, + range: Range, + image: String, ) -> Result<()> { - let Some(uri) = params.arguments.first() else { - return Err(Error::internal_error().with_message("no uri was provided")); - }; - - let Some(uri) = uri.as_str() else { - return Err(Error::internal_error().with_message("uri is not a string")); - }; - - let Some(range) = params.arguments.get(1) else { - return Err(Error::internal_error().with_message("no range was provided")); - }; - - let Ok(range) = serde_json::from_value::(range.clone()) else { - return Err(Error::internal_error().with_message("range is not a Range object")); - }; - - let Some(image_name) = params.arguments.get(2) else { - return Err(Error::internal_error().with_message("no image name was provided")); - }; - - let Some(image_name) = image_name.as_str() else { - return Err(Error::internal_error().with_message("image name is not a string")); - }; - let image_scanner = { let mut lock = server.component_factory.write().await; lock.image_scanner().map_err(|e| { @@ -366,7 +319,7 @@ async fn execute_command_scan_base_image( server .command_executor - .scan_image(uri, range, image_name, &image_scanner) + .scan_image(&file, range, &image, &image_scanner) .await?; Ok(()) @@ -374,24 +327,9 @@ async fn execute_command_scan_base_image( async fn execute_command_build_and_scan( server: &LSPServer, - params: &ExecuteCommandParams, + file: String, + range: Range, ) -> Result<()> { - let Some(uri) = params.arguments.first() else { - return Err(Error::internal_error().with_message("no uri was provided")); - }; - - let Some(uri) = uri.as_str() else { - return Err(Error::internal_error().with_message("uri is not a string")); - }; - - let Some(line) = params.arguments.get(1) else { - return Err(Error::internal_error().with_message("no line was provided")); - }; - - let Some(line) = line.as_u64().and_then(|x| u32::try_from(x).ok()) else { - return Err(Error::internal_error().with_message("line is not a u32")); - }; - let (image_scanner, image_builder) = { let mut factory = server.component_factory.write().await; @@ -408,8 +346,8 @@ async fn execute_command_build_and_scan( server .command_executor .build_and_scan_from_file( - &PathBuf::from_str(uri).unwrap(), - line, + &PathBuf::from_str(&file).unwrap(), + range.start.line, &image_builder, &image_scanner, ) diff --git a/src/app/lsp_server/supported_commands.rs b/src/app/lsp_server/supported_commands.rs new file mode 100644 index 0000000..6afaea2 --- /dev/null +++ b/src/app/lsp_server/supported_commands.rs @@ -0,0 +1,94 @@ +use std::fmt::Display; + +use super::CommandInfo; +use serde_json::json; +use tower_lsp::{ + jsonrpc::{self, Error}, + lsp_types::{ExecuteCommandParams, Location}, +}; + +const CMD_EXECUTE_SCAN: &str = "sysdig-lsp.execute-scan"; +const CMD_BUILD_AND_SCAN: &str = "sysdig-lsp.execute-build-and-scan"; + +#[derive(Debug, Clone)] +pub enum SupportedCommands { + ExecuteBaseImageScan { location: Location, image: String }, + ExecuteBuildAndScan { location: Location }, +} + +impl SupportedCommands { + fn as_string_command(&self) -> String { + match self { + SupportedCommands::ExecuteBaseImageScan { .. } => CMD_EXECUTE_SCAN, + SupportedCommands::ExecuteBuildAndScan { .. } => CMD_BUILD_AND_SCAN, + } + .to_string() + } + + pub fn all_supported_commands_as_string() -> Vec { + [CMD_EXECUTE_SCAN, CMD_BUILD_AND_SCAN] + .into_iter() + .map(|s| s.to_string()) + .collect() + } +} + +impl From for CommandInfo { + fn from(value: SupportedCommands) -> Self { + match &value { + SupportedCommands::ExecuteBaseImageScan { location, image } => CommandInfo { + title: "Scan base image".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location), json!(image)]), + range: location.range, + }, + + SupportedCommands::ExecuteBuildAndScan { location } => CommandInfo { + title: "Build and scan".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location)]), + range: location.range, + }, + } + } +} + +impl TryFrom for SupportedCommands { + type Error = jsonrpc::Error; + + fn try_from(value: ExecuteCommandParams) -> std::result::Result { + match (value.command.as_str(), value.arguments.as_slice()) { + (CMD_EXECUTE_SCAN, [location, image]) => Ok(SupportedCommands::ExecuteBaseImageScan { + location: serde_json::from_value(location.clone()) + .map_err(|_| Error::invalid_params("location must be a Location object"))?, + image: image + .as_str() + .ok_or_else(|| Error::invalid_params("image must be string"))? + .to_owned(), + }), + (CMD_BUILD_AND_SCAN, [location]) => Ok(SupportedCommands::ExecuteBuildAndScan { + location: serde_json::from_value(location.clone()) + .map_err(|_| Error::invalid_params("location must be a Location object"))?, + }), + (other, _) => Err(Error::invalid_params(format!( + "command not supported: {other}" + ))), + } + } +} + +impl Display for SupportedCommands { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + write!( + f, + "ExecuteBaseImageScan(location: {location:?}, image: {image})", + ) + } + SupportedCommands::ExecuteBuildAndScan { location } => { + write!(f, "ExecuteBuildAndScan(location: {location:?})") + } + } + } +} diff --git a/tests/general.rs b/tests/general.rs index d091dc4..a848541 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -37,14 +37,25 @@ async fn when_the_client_asks_for_the_existing_code_actions_it_receives_the_avai CodeActionOrCommand::Command(Command { title: "Build and scan".to_string(), command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) + arguments: Some(vec![json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 11} + } + })]) }), CodeActionOrCommand::Command(Command { title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), + json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 11} + } + }), json!("alpine"), ]) }) @@ -76,14 +87,25 @@ async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_c CodeActionOrCommand::Command(Command { title: "Build and scan".to_string(), command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) + arguments: Some(vec![json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 11} + } + })]) }), CodeActionOrCommand::Command(Command { title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), + json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 11} + } + }), json!("ubuntu"), ]) }) @@ -114,7 +136,13 @@ async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_availab command: Some(Command { title: "Build and scan".to_string(), command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) + arguments: Some(vec![json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 11} + } + })]) }), data: None }, @@ -124,8 +152,13 @@ async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_availab title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), + json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 11} + } + }), json!("alpine"), ]) }), @@ -155,7 +188,13 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont command: Some(Command { title: "Build and scan".to_string(), command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) + arguments: Some(vec![json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 11} + } + })]) }), data: None }, @@ -165,8 +204,13 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), + json!({ + "uri": "file://dockerfile/", + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 11} + } + }), json!("ubuntu"), ]) }), @@ -199,8 +243,13 @@ async fn when_the_client_asks_for_code_lens_in_a_compose_file_it_receives_them() title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 2, "character": 11}, "end": {"line": 2, "character": 23}}), + json!({ + "uri": "file://docker-compose.yml/", + "range": { + "start": {"line": 2, "character": 11}, + "end": {"line": 2, "character": 23} + } + }), json!("nginx:latest") ]) }), @@ -212,8 +261,13 @@ async fn when_the_client_asks_for_code_lens_in_a_compose_file_it_receives_them() title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 4, "character": 11}, "end": {"line": 4, "character": 22}}), + json!({ + "uri": "file://docker-compose.yml/", + "range": { + "start": {"line": 4, "character": 11}, + "end": {"line": 4, "character": 22} + } + }), json!("postgres:13") ]) }), @@ -243,8 +297,13 @@ async fn when_the_client_asks_for_code_actions_in_a_compose_file_it_receives_the title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 2, "character": 11}, "end": {"line": 2, "character": 23}}), + json!({ + "uri": "file://docker-compose.yml/", + "range": { + "start": {"line": 2, "character": 11}, + "end": {"line": 2, "character": 23} + } + }), json!("nginx:latest"), ]) })] @@ -271,8 +330,13 @@ async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_re title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 4, "character": 13}, "end": {"line": 4, "character": 25}}), + json!({ + "uri": "file://compose.yaml/", + "range": { + "start": {"line": 4, "character": 13}, + "end": {"line": 4, "character": 25} + } + }), json!("nginx:latest") ]) }), @@ -284,8 +348,13 @@ async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_re title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 9, "character": 6}, "end": {"line": 9, "character": 17}}), + json!({ + "uri": "file://compose.yaml/", + "range": { + "start": {"line": 9, "character": 6}, + "end": {"line": 9, "character": 17} + } + }), json!("postgres:13") ]) }), @@ -297,8 +366,13 @@ async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_re title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 13, "character": 11}, "end": {"line": 13, "character": 21}}), + json!({ + "uri": "file://compose.yaml/", + "range": { + "start": {"line": 13, "character": 11}, + "end": {"line": 13, "character": 21} + } + }), json!("my-api:1.0") ]) }), From 5abe2828dbe023e79dfae266df67652fcd482bd1 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 09:31:22 +0200 Subject: [PATCH 02/11] refactor(app): extract command generation logic --- src/app/lsp_server/command_generator.rs | 90 ++++++ src/app/lsp_server/lsp_server_inner.rs | 275 ++++++++++++++++++ src/app/lsp_server/mod.rs | 349 +++-------------------- src/app/lsp_server/supported_commands.rs | 24 +- 4 files changed, 403 insertions(+), 335 deletions(-) create mode 100644 src/app/lsp_server/command_generator.rs create mode 100644 src/app/lsp_server/lsp_server_inner.rs diff --git a/src/app/lsp_server/command_generator.rs b/src/app/lsp_server/command_generator.rs new file mode 100644 index 0000000..cc7155d --- /dev/null +++ b/src/app/lsp_server/command_generator.rs @@ -0,0 +1,90 @@ +use serde_json::{Value, json}; +use tower_lsp::lsp_types::{Location, Range, Url}; + +use crate::app::lsp_server::supported_commands::SupportedCommands; +use crate::infra::{parse_compose_file, parse_dockerfile}; + +pub struct CommandInfo { + pub title: String, + pub command: String, + pub arguments: Option>, + pub range: Range, +} + +impl From for CommandInfo { + fn from(value: SupportedCommands) -> Self { + match &value { + SupportedCommands::ExecuteBaseImageScan { location, image } => CommandInfo { + title: "Scan base image".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location), json!(image)]), + range: location.range, + }, + + SupportedCommands::ExecuteBuildAndScan { location } => CommandInfo { + title: "Build and scan".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location)]), + range: location.range, + }, + } + } +} + +pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Vec { + let file_uri = uri.as_str(); + + if file_uri.contains("docker-compose.yml") + || file_uri.contains("compose.yml") + || file_uri.contains("docker-compose.yaml") + || file_uri.contains("compose.yaml") + { + generate_compose_commands(uri, content) + } else { + generate_dockerfile_commands(uri, content) + } +} + +fn generate_compose_commands(url: &Url, content: &str) -> Vec { + let mut commands = vec![]; + if let Ok(instructions) = parse_compose_file(content) { + for instruction in instructions { + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(url.clone(), instruction.range), + image: instruction.image_name, + } + .into(), + ); + } + } + commands +} + +fn generate_dockerfile_commands(uri: &Url, content: &str) -> Vec { + let mut commands = vec![]; + let instructions = parse_dockerfile(content); + if let Some(last_from_instruction) = instructions + .iter() + .filter(|instruction| instruction.keyword == "FROM") + .next_back() + { + let range = last_from_instruction.range; + commands.push( + SupportedCommands::ExecuteBuildAndScan { + location: Location::new(uri.clone(), range), + } + .into(), + ); + if let Some(image) = last_from_instruction.arguments.first() { + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(uri.clone(), range), + image: image.to_owned(), + } + .into(), + ); + } + } + commands +} diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs new file mode 100644 index 0000000..13aa47c --- /dev/null +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -0,0 +1,275 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use serde_json::Value; +use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; +use tower_lsp::lsp_types::{ + CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, + CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, + ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, + Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, +}; +use tracing::{debug, info}; + +use super::super::commands::CommandExecutor; +use super::super::component_factory::{ComponentFactory, Config}; +use super::super::queries::QueryExecutor; +use super::command_generator; +use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; + +use super::supported_commands::SupportedCommands; + +pub struct LSPServerInner { + command_executor: CommandExecutor, + query_executor: QueryExecutor, + component_factory: ComponentFactory, +} + +impl LSPServerInner { + pub fn new(client: C) -> LSPServerInner { + let document_database = InMemoryDocumentDatabase::default(); + + LSPServerInner { + command_executor: CommandExecutor::new(client, document_database.clone()), + query_executor: QueryExecutor::new(document_database.clone()), + component_factory: Default::default(), // to be initialized in the initialize method of the LSP + } + } +} + +impl LSPServerInner +where + C: LSPClient + Send + Sync + 'static, +{ + async fn initialize_component_factory_with(&mut self, config: &Value) -> Result<()> { + let Ok(config) = serde_json::from_value::(config.clone()) else { + return Err(Error::internal_error() + .with_message(format!("unable to transform json into config: {config}"))); + }; + + debug!("updating with configuration: {config:?}"); + + self.component_factory.initialize_with(config); + + debug!("updated configuration"); + Ok(()) + } +} + +impl LSPServerInner +where + C: LSPClient + Send + Sync + 'static, +{ + pub async fn initialize( + &mut self, + initialize_params: InitializeParams, + ) -> Result { + let Some(config) = initialize_params.initialization_options else { + return Err(Error { + code: ErrorCode::InvalidParams, + message: "expected parameters to configure the LSP, received nothing".into(), + data: None, + }); + }; + + self.initialize_component_factory_with(&config).await?; + + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + code_action_provider: Some(CodeActionProviderCapability::Simple(true)), + code_lens_provider: Some(CodeLensOptions { + resolve_provider: Some(false), + }), + execute_command_provider: Some(ExecuteCommandOptions { + commands: SupportedCommands::all_supported_commands_as_string(), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }) + } + + pub async fn initialized(&self, _: InitializedParams) { + info!("Initialized"); + self.command_executor + .show_message(MessageType::INFO, "Sysdig LSP initialized") + .await; + } + + pub async fn did_change_configuration(&mut self, params: DidChangeConfigurationParams) { + let _ = self + .initialize_component_factory_with(¶ms.settings) + .await; + } + + pub async fn did_open(&self, params: DidOpenTextDocumentParams) { + self.command_executor + .update_document_with_text( + params.text_document.uri.as_str(), + params.text_document.text.as_str(), + ) + .await; + } + + pub async fn did_change(&self, params: DidChangeTextDocumentParams) { + if let Some(change) = params.content_changes.into_iter().next_back() { + self.command_executor + .update_document_with_text(params.text_document.uri.as_str(), &change.text) + .await; + } + } + + pub async fn code_action( + &self, + params: CodeActionParams, + ) -> Result> { + let Some(content) = self + .query_executor + .get_document_text(params.text_document.uri.as_str()) + .await + else { + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {}", + ¶ms.text_document.uri + ))); + }; + + let commands = + command_generator::generate_commands_for_uri(¶ms.text_document.uri, &content); + let code_actions: Vec = commands + .into_iter() + .filter(|cmd| cmd.range.start.line == params.range.start.line) + .map(|cmd| { + CodeActionOrCommand::Command(Command { + title: cmd.title, + command: cmd.command, + arguments: cmd.arguments, + }) + }) + .collect(); + + Ok(Some(code_actions)) + } + + pub async fn code_lens(&self, params: CodeLensParams) -> Result>> { + let Some(content) = self + .query_executor + .get_document_text(params.text_document.uri.as_str()) + .await + else { + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {}", + ¶ms.text_document.uri + ))); + }; + + let commands = + command_generator::generate_commands_for_uri(¶ms.text_document.uri, &content); + let code_lenses = commands + .into_iter() + .map(|cmd| CodeLens { + range: cmd.range, + command: Some(Command { + title: cmd.title, + command: cmd.command, + arguments: cmd.arguments, + }), + data: None, + }) + .collect(); + + Ok(Some(code_lenses)) + } + + pub async fn execute_command(&mut self, params: ExecuteCommandParams) -> Result> { + let command: SupportedCommands = params.try_into()?; + + let result = match command.clone() { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + execute_command_scan_base_image( + self, + location.uri.to_string(), + location.range, + image, + ) + .await + .map(|_| None) + } + + SupportedCommands::ExecuteBuildAndScan { location } => { + execute_command_build_and_scan(self, location.uri.to_string(), location.range) + .await + .map(|_| None) + } + }; + + match result { + Ok(_) => result, + Err(mut e) => { + self.command_executor + .show_message(MessageType::ERROR, e.to_string().as_str()) + .await; + e.message = format!("error calling command: '{command}': {e}").into(); + Err(e) + } + } + } + + pub async fn shutdown(&self) -> Result<()> { + Ok(()) + } +} + +async fn execute_command_scan_base_image( + server: &mut LSPServerInner, + file: String, + range: Range, + image: String, +) -> Result<()> { + let image_scanner = { + server.component_factory.image_scanner().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image scanner: {e}")) + })? + }; + + server + .command_executor + .scan_image(&file, range, &image, &image_scanner) + .await?; + + Ok(()) +} + +async fn execute_command_build_and_scan( + server: &mut LSPServerInner, + file: String, + range: Range, +) -> Result<()> { + let (image_scanner, image_builder) = { + let image_scanner = server.component_factory.image_scanner().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image scanner: {e}")) + })?; + let image_builder = server.component_factory.image_builder().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image builder: {e}")) + })?; + + (image_scanner, image_builder) + }; + + server + .command_executor + .build_and_scan_from_file( + &PathBuf::from_str(&file).unwrap(), + range.start.line, + &image_builder, + &image_scanner, + ) + .await?; + + Ok(()) +} diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 141b457..50a33ef 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -1,66 +1,44 @@ -use std::borrow::Cow; -use std::path::PathBuf; -use std::str::FromStr; - use serde_json::Value; +use std::borrow::Cow; use tokio::sync::RwLock; use tower_lsp::LanguageServer; -use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; +use tower_lsp::jsonrpc::{Error, Result}; use tower_lsp::lsp_types::{ - CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, - CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, - DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, Location, - MessageType, Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + CodeActionParams, CodeActionResponse, CodeLens, CodeLensParams, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, + InitializeResult, InitializedParams, Range, }; -use tracing::{debug, info}; -use super::commands::CommandExecutor; -use super::component_factory::{ComponentFactory, Config}; -use super::queries::QueryExecutor; use super::{InMemoryDocumentDatabase, LSPClient}; -use crate::infra::{parse_compose_file, parse_dockerfile}; -mod supported_commands; -use supported_commands::SupportedCommands; +pub mod command_generator; +mod lsp_server_inner; +pub mod supported_commands; +use lsp_server_inner::LSPServerInner; + +pub trait WithContext { + fn with_message(self, message: impl Into>) -> Self; +} + +impl WithContext for Error { + fn with_message(mut self, message: impl Into>) -> Self { + self.message = message.into(); + self + } +} pub struct LSPServer { - command_executor: CommandExecutor, - query_executor: QueryExecutor, - component_factory: RwLock, + inner: RwLock>, } impl LSPServer { pub fn new(client: C) -> LSPServer { - let document_database = InMemoryDocumentDatabase::default(); - LSPServer { - command_executor: CommandExecutor::new(client, document_database.clone()), - query_executor: QueryExecutor::new(document_database.clone()), - component_factory: Default::default(), // to be initialized in the initialize method of the LSP + inner: RwLock::new(LSPServerInner::new(client)), } } } -impl LSPServer -where - C: LSPClient + Send + Sync + 'static, -{ - async fn initialize_component_factory_with(&self, config: &Value) -> Result<()> { - let Ok(config) = serde_json::from_value::(config.clone()) else { - return Err(Error::internal_error() - .with_message(format!("unable to transform json into config: {config}"))); - }; - - debug!("updating with configuration: {config:?}"); - - self.component_factory.write().await.initialize_with(config); - - debug!("updated configuration"); - Ok(()) - } -} - struct CommandInfo { title: String, command: String, @@ -68,301 +46,48 @@ struct CommandInfo { range: Range, } -impl LSPServer -where - C: LSPClient + Send + Sync + 'static, -{ - fn generate_commands_for_uri( - &self, - uri: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let file_uri = uri.as_str(); - - if file_uri.contains("docker-compose.yml") - || file_uri.contains("compose.yml") - || file_uri.contains("docker-compose.yaml") - || file_uri.contains("compose.yaml") - { - self.generate_compose_commands(uri, content) - } else { - self.generate_dockerfile_commands(uri, content) - } - } - - fn generate_compose_commands( - &self, - url: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let mut commands = vec![]; - if let Ok(instructions) = parse_compose_file(content) { - for instruction in instructions { - commands.push( - SupportedCommands::ExecuteBaseImageScan { - location: Location::new(url.clone(), instruction.range), - image: instruction.image_name, - } - .into(), - ); - } - } - commands - } - - fn generate_dockerfile_commands( - &self, - uri: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let mut commands = vec![]; - let instructions = parse_dockerfile(content); - if let Some(last_from_instruction) = instructions - .iter() - .filter(|instruction| instruction.keyword == "FROM") - .next_back() - { - let range = last_from_instruction.range; - commands.push( - SupportedCommands::ExecuteBuildAndScan { - location: Location::new(uri.clone(), range), - } - .into(), - ); - if let Some(image) = last_from_instruction.arguments.first() { - commands.push( - SupportedCommands::ExecuteBaseImageScan { - location: Location::new(uri.clone(), range), - image: image.to_owned(), - } - .into(), - ); - } - } - commands - } -} - #[async_trait::async_trait] impl LanguageServer for LSPServer where C: LSPClient + Send + Sync + 'static, { - async fn initialize(&self, initialize_params: InitializeParams) -> Result { - let Some(config) = initialize_params.initialization_options else { - return Err(Error { - code: ErrorCode::InvalidParams, - message: "expected parameters to configure the LSP, received nothing".into(), - data: None, - }); - }; - - self.initialize_component_factory_with(&config).await?; - - Ok(InitializeResult { - capabilities: ServerCapabilities { - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::FULL, - )), - code_action_provider: Some(CodeActionProviderCapability::Simple(true)), - code_lens_provider: Some(CodeLensOptions { - resolve_provider: Some(false), - }), - execute_command_provider: Some(ExecuteCommandOptions { - commands: SupportedCommands::all_supported_commands_as_string(), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }) + async fn initialize(&self, params: InitializeParams) -> Result { + self.inner.write().await.initialize(params).await } - async fn initialized(&self, _: InitializedParams) { - info!("Initialized"); - self.command_executor - .show_message(MessageType::INFO, "Sysdig LSP initialized") - .await; + async fn initialized(&self, params: InitializedParams) { + self.inner.read().await.initialized(params).await } async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { - let _ = self - .initialize_component_factory_with(¶ms.settings) - .await; + self.inner + .write() + .await + .did_change_configuration(params) + .await } async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.command_executor - .update_document_with_text( - params.text_document.uri.as_str(), - params.text_document.text.as_str(), - ) - .await; + self.inner.read().await.did_open(params).await } async fn did_change(&self, params: DidChangeTextDocumentParams) { - if let Some(change) = params.content_changes.into_iter().next_back() { - self.command_executor - .update_document_with_text(params.text_document.uri.as_str(), &change.text) - .await; - } + self.inner.read().await.did_change(params).await } async fn code_action(&self, params: CodeActionParams) -> Result> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = self.generate_commands_for_uri(¶ms.text_document.uri, &content); - let code_actions: Vec = commands - .into_iter() - .filter(|cmd| cmd.range.start.line == params.range.start.line) - .map(|cmd| { - CodeActionOrCommand::Command(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }) - }) - .collect(); - - Ok(Some(code_actions)) + self.inner.read().await.code_action(params).await } async fn code_lens(&self, params: CodeLensParams) -> Result>> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = self.generate_commands_for_uri(¶ms.text_document.uri, &content); - let code_lenses = commands - .into_iter() - .map(|cmd| CodeLens { - range: cmd.range, - command: Some(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }), - data: None, - }) - .collect(); - - Ok(Some(code_lenses)) + self.inner.read().await.code_lens(params).await } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - let command: SupportedCommands = params.try_into()?; - - let result = match command.clone() { - SupportedCommands::ExecuteBaseImageScan { location, image } => { - execute_command_scan_base_image( - self, - location.uri.to_string(), - location.range, - image, - ) - .await - .map(|_| None) - } - - SupportedCommands::ExecuteBuildAndScan { location } => { - execute_command_build_and_scan(self, location.uri.to_string(), location.range) - .await - .map(|_| None) - } - }; - - match result { - Ok(_) => result, - Err(mut e) => { - self.command_executor - .show_message(MessageType::ERROR, e.to_string().as_str()) - .await; - e.message = format!("error calling command: '{command}': {e}").into(); - Err(e) - } - } + self.inner.write().await.execute_command(params).await } async fn shutdown(&self) -> Result<()> { - Ok(()) - } -} - -async fn execute_command_scan_base_image( - server: &LSPServer, - file: String, - range: Range, - image: String, -) -> Result<()> { - let image_scanner = { - let mut lock = server.component_factory.write().await; - lock.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })? - }; - - server - .command_executor - .scan_image(&file, range, &image, &image_scanner) - .await?; - - Ok(()) -} - -async fn execute_command_build_and_scan( - server: &LSPServer, - file: String, - range: Range, -) -> Result<()> { - let (image_scanner, image_builder) = { - let mut factory = server.component_factory.write().await; - - let image_scanner = factory.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })?; - let image_builder = factory.image_builder().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image builder: {e}")) - })?; - - (image_scanner, image_builder) - }; - - server - .command_executor - .build_and_scan_from_file( - &PathBuf::from_str(&file).unwrap(), - range.start.line, - &image_builder, - &image_scanner, - ) - .await?; - - Ok(()) -} - -pub(super) trait WithContext { - fn with_message(self, message: impl Into>) -> Self; -} - -impl WithContext for Error { - fn with_message(mut self, message: impl Into>) -> Self { - self.message = message.into(); - self + self.inner.read().await.shutdown().await } } diff --git a/src/app/lsp_server/supported_commands.rs b/src/app/lsp_server/supported_commands.rs index 6afaea2..807e656 100644 --- a/src/app/lsp_server/supported_commands.rs +++ b/src/app/lsp_server/supported_commands.rs @@ -1,7 +1,5 @@ use std::fmt::Display; -use super::CommandInfo; -use serde_json::json; use tower_lsp::{ jsonrpc::{self, Error}, lsp_types::{ExecuteCommandParams, Location}, @@ -17,7 +15,7 @@ pub enum SupportedCommands { } impl SupportedCommands { - fn as_string_command(&self) -> String { + pub fn as_string_command(&self) -> String { match self { SupportedCommands::ExecuteBaseImageScan { .. } => CMD_EXECUTE_SCAN, SupportedCommands::ExecuteBuildAndScan { .. } => CMD_BUILD_AND_SCAN, @@ -33,26 +31,6 @@ impl SupportedCommands { } } -impl From for CommandInfo { - fn from(value: SupportedCommands) -> Self { - match &value { - SupportedCommands::ExecuteBaseImageScan { location, image } => CommandInfo { - title: "Scan base image".to_owned(), - command: value.as_string_command(), - arguments: Some(vec![json!(location), json!(image)]), - range: location.range, - }, - - SupportedCommands::ExecuteBuildAndScan { location } => CommandInfo { - title: "Build and scan".to_owned(), - command: value.as_string_command(), - arguments: Some(vec![json!(location)]), - range: location.range, - }, - } - } -} - impl TryFrom for SupportedCommands { type Error = jsonrpc::Error; From 54c8849e7e4ca4f9d2a0c6ba67a240b3e5f0f0e2 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 10:24:31 +0200 Subject: [PATCH 03/11] refactor(app): implement command pattern for lsp actions --- src/app/lsp_interactor.rs | 59 ++++++ src/app/{ => lsp_server}/commands.rs | 277 +++++++++++++------------ src/app/lsp_server/lsp_server_inner.rs | 102 +++------ src/app/lsp_server/mod.rs | 1 + src/app/mod.rs | 3 +- 5 files changed, 242 insertions(+), 200 deletions(-) create mode 100644 src/app/lsp_interactor.rs rename src/app/{ => lsp_server}/commands.rs (55%) diff --git a/src/app/lsp_interactor.rs b/src/app/lsp_interactor.rs new file mode 100644 index 0000000..2123612 --- /dev/null +++ b/src/app/lsp_interactor.rs @@ -0,0 +1,59 @@ +use tower_lsp::{ + jsonrpc::Result, + lsp_types::{Diagnostic, MessageType}, +}; + +use super::{InMemoryDocumentDatabase, LSPClient}; + +pub struct LspInteractor { + client: C, + document_database: InMemoryDocumentDatabase, +} + +impl LspInteractor { + pub fn new(client: C, document_database: InMemoryDocumentDatabase) -> Self { + Self { + client, + document_database, + } + } +} + +impl LspInteractor +where + C: LSPClient, +{ + pub async fn update_document_with_text(&self, uri: &str, text: &str) { + self.document_database.write_document_text(uri, text).await; + self.document_database.remove_diagnostics(uri).await; + let _ = self.publish_all_diagnostics().await; + } + + pub async fn show_message(&self, message_type: MessageType, message: &str) { + self.client.show_message(message_type, message).await; + } + + pub async fn publish_all_diagnostics(&self) -> Result<()> { + let all_diagnostics = self.document_database.all_diagnostics().await; + for (url, diagnostics) in all_diagnostics { + self.client + .publish_diagnostics(&url, diagnostics, None) + .await; + } + Ok(()) + } + + pub async fn read_document_text(&self, uri: &str) -> Option { + self.document_database.read_document_text(uri).await + } + + pub async fn remove_diagnostics(&self, uri: &str) { + self.document_database.remove_diagnostics(uri).await + } + + pub async fn append_document_diagnostics(&self, uri: &str, diagnostics: &[Diagnostic]) { + self.document_database + .append_document_diagnostics(uri, diagnostics) + .await + } +} diff --git a/src/app/commands.rs b/src/app/lsp_server/commands.rs similarity index 55% rename from src/app/commands.rs rename to src/app/lsp_server/commands.rs index 38dae06..ccc5900 100644 --- a/src/app/commands.rs +++ b/src/app/lsp_server/commands.rs @@ -1,103 +1,80 @@ -use std::{ - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; +use tower_lsp::jsonrpc::Result; + +#[async_trait::async_trait] +pub trait LspCommand { + async fn execute(&mut self) -> Result<()>; +} use itertools::Itertools; -use tower_lsp::{ - jsonrpc::{Error, Result}, - lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Position, Range}, -}; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; use crate::{ - domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, - infra::parse_dockerfile, + app::{ImageScanner, LSPClient, LspInteractor}, + domain::scanresult::{scan_result::ScanResult, severity::Severity}, }; -use super::{ - ImageBuilder, ImageScanner, InMemoryDocumentDatabase, LSPClient, lsp_server::WithContext, -}; - -pub struct CommandExecutor { - client: C, - document_database: InMemoryDocumentDatabase, -} +use super::WithContext; -impl CommandExecutor { - pub fn new(client: C, document_database: InMemoryDocumentDatabase) -> Self { - Self { - client, - document_database, - } - } - - fn image_from_line<'a>(&self, line: u32, contents: &'a str) -> Option<&'a str> { - let line_number: usize = line.try_into().ok()?; - let line_that_contains_from = contents.lines().nth(line_number)?; - line_that_contains_from - .strip_prefix("FROM ")? - .split_whitespace() - .next() - } +pub struct ScanBaseImageCommand<'a, C, S> +where + S: ImageScanner, +{ + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, } -impl CommandExecutor +impl<'a, C, S> ScanBaseImageCommand<'a, C, S> where - C: LSPClient, + S: ImageScanner, { - pub async fn update_document_with_text(&self, uri: &str, text: &str) { - self.document_database.write_document_text(uri, text).await; - self.document_database.remove_diagnostics(uri).await; - let _ = self.publish_all_diagnostics().await; - } - - pub async fn show_message(&self, message_type: MessageType, message: &str) { - self.client.show_message(message_type, message).await; - } - - async fn publish_all_diagnostics(&self) -> Result<()> { - let all_diagnostics = self.document_database.all_diagnostics().await; - for (url, diagnostics) in all_diagnostics { - self.client - .publish_diagnostics(&url, diagnostics, None) - .await; + pub fn new( + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, + ) -> Self { + Self { + image_scanner, + interactor, + location, + image, } - Ok(()) } } -impl CommandExecutor +#[async_trait::async_trait] +impl<'a, C, S> LspCommand for ScanBaseImageCommand<'a, C, S> where - C: LSPClient, + C: LSPClient + Sync, + S: ImageScanner + Sync, { - pub async fn scan_image( - &self, - uri: &str, - range: Range, - image_name: &str, - image_scanner: &impl ImageScanner, - ) -> Result<()> { - self.show_message( - MessageType::INFO, - format!("Starting scan of {image_name}...").as_str(), - ) - .await; - - let scan_result = image_scanner + async fn execute(&mut self) -> tower_lsp::jsonrpc::Result<()> { + let image_name = &self.image; + self.interactor + .show_message( + MessageType::INFO, + format!("Starting scan of {image_name}...").as_str(), + ) + .await; + + let scan_result = self + .image_scanner .scan_image(image_name) .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; - self.show_message( - MessageType::INFO, - format!("Finished scan of {image_name}.").as_str(), - ) - .await; + self.interactor + .show_message( + MessageType::INFO, + format!("Finished scan of {image_name}.").as_str(), + ) + .await; let diagnostic = { let mut diagnostic = Diagnostic { - range, + range: self.location.range, severity: Some(DiagnosticSeverity::HINT), message: "No vulnerabilities found.".to_owned(), ..Default::default() @@ -128,80 +105,124 @@ where diagnostic }; - self.document_database.remove_diagnostics(uri).await; - self.document_database + let uri = self.location.uri.as_str(); + self.interactor.remove_diagnostics(uri).await; + self.interactor .append_document_diagnostics(uri, &[diagnostic]) .await; - self.publish_all_diagnostics().await + self.interactor.publish_all_diagnostics().await } +} + +use std::{path::PathBuf, str::FromStr, sync::Arc}; +use tower_lsp::lsp_types::{Position, Range}; + +use crate::{app::ImageBuilder, domain::scanresult::layer::Layer, infra::parse_dockerfile}; + +pub struct BuildAndScanCommand<'a, C, B, S> +where + B: ImageBuilder, + S: ImageScanner, +{ + image_builder: &'a B, + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, +} + +impl<'a, C, B, S> BuildAndScanCommand<'a, C, B, S> +where + B: ImageBuilder, + S: ImageScanner, +{ + pub fn new( + image_builder: &'a B, + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + ) -> Self { + Self { + image_builder, + image_scanner, + interactor, + location, + } + } +} + +#[async_trait::async_trait] +impl<'a, C, B, S> LspCommand for BuildAndScanCommand<'a, C, B, S> +where + C: LSPClient + Sync, + B: ImageBuilder + Sync, + S: ImageScanner + Sync, +{ + async fn execute(&mut self) -> Result<()> { + let uri = self.location.uri.as_str(); + let line = self.location.range.start.line; - pub async fn build_and_scan_from_file( - &self, - uri: &Path, - line: u32, - image_builder: &impl ImageBuilder, - image_scanner: &impl ImageScanner, - ) -> Result<()> { let document_text = self - .document_database - .read_document_text(uri.to_str().unwrap_or_default()) + .interactor + .read_document_text(uri) .await .ok_or_else(|| { - Error::internal_error().with_message("unable to obtain document to scan") + tower_lsp::jsonrpc::Error::internal_error() + .with_message("unable to obtain document to scan") })?; - let uri_without_file_path = uri - .to_str() - .and_then(|s| s.strip_prefix("file://")) - .ok_or_else(|| { - Error::internal_error().with_message("unable to strip prefix file:// from uri") - })?; + let uri_without_file_path = uri.strip_prefix("file://").ok_or_else(|| { + tower_lsp::jsonrpc::Error::internal_error() + .with_message("unable to strip prefix file:// from uri") + })?; - self.show_message( - MessageType::INFO, - format!("Starting build of {uri_without_file_path}...").as_str(), - ) - .await; + self.interactor + .show_message( + MessageType::INFO, + format!("Starting build of {uri_without_file_path}...").as_str(), + ) + .await; - let build_result = image_builder + let build_result = self + .image_builder .build_image(&PathBuf::from_str(uri_without_file_path).unwrap()) .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; - - self.show_message( - MessageType::INFO, - format!( - "Temporal image built '{}', starting scan...", - &build_result.image_name + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; + + self.interactor + .show_message( + MessageType::INFO, + format!( + "Temporal image built '{}', starting scan...", + &build_result.image_name + ) + .as_str(), ) - .as_str(), - ) - .await; + .await; - let scan_result = image_scanner + let scan_result = self + .image_scanner .scan_image(&build_result.image_name) .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; - self.show_message( - MessageType::INFO, - format!("Finished scan of {}.", &build_result.image_name).as_str(), - ) - .await; + self.interactor + .show_message( + MessageType::INFO, + format!("Finished scan of {}.", &build_result.image_name).as_str(), + ) + .await; let diagnostic = diagnostic_for_image(line, &document_text, &scan_result); let diagnostics_per_layer = diagnostics_for_layers(&document_text, &scan_result)?; - self.document_database - .remove_diagnostics(uri.to_str().unwrap()) - .await; - self.document_database - .append_document_diagnostics(uri.to_str().unwrap(), &[diagnostic]) + self.interactor.remove_diagnostics(uri).await; + self.interactor + .append_document_diagnostics(uri, &[diagnostic]) .await; - self.document_database - .append_document_diagnostics(uri.to_str().unwrap(), &diagnostics_per_layer) + self.interactor + .append_document_diagnostics(uri, &diagnostics_per_layer) .await; - self.publish_all_diagnostics().await + self.interactor.publish_all_diagnostics().await } } @@ -309,7 +330,7 @@ fn diagnostic_for_image(line: u32, document_text: &str, scan_result: &ScanResult .iter() .counts_by(|v| v.severity()); diagnostic.message = format!( - "Total vulnerabilities found: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + "Vulnerabilities found: {} Critical, {} High, {} Medium, {} Low, {} Negligible", vulns.get(&Severity::Critical).unwrap_or(&0_usize), vulns.get(&Severity::High).unwrap_or(&0_usize), vulns.get(&Severity::Medium).unwrap_or(&0_usize), diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 13aa47c..59e6738 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -1,6 +1,3 @@ -use std::path::PathBuf; -use std::str::FromStr; - use serde_json::Value; use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; use tower_lsp::lsp_types::{ @@ -8,20 +5,21 @@ use tower_lsp::lsp_types::{ CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; -use super::super::commands::CommandExecutor; +use super::super::LspInteractor; use super::super::component_factory::{ComponentFactory, Config}; use super::super::queries::QueryExecutor; use super::command_generator; +use super::commands::{BuildAndScanCommand, LspCommand, ScanBaseImageCommand}; use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; use super::supported_commands::SupportedCommands; pub struct LSPServerInner { - command_executor: CommandExecutor, + interactor: LspInteractor, query_executor: QueryExecutor, component_factory: ComponentFactory, } @@ -31,7 +29,7 @@ impl LSPServerInner { let document_database = InMemoryDocumentDatabase::default(); LSPServerInner { - command_executor: CommandExecutor::new(client, document_database.clone()), + interactor: LspInteractor::new(client, document_database.clone()), query_executor: QueryExecutor::new(document_database.clone()), component_factory: Default::default(), // to be initialized in the initialize method of the LSP } @@ -96,7 +94,7 @@ where pub async fn initialized(&self, _: InitializedParams) { info!("Initialized"); - self.command_executor + self.interactor .show_message(MessageType::INFO, "Sysdig LSP initialized") .await; } @@ -108,7 +106,7 @@ where } pub async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.command_executor + self.interactor .update_document_with_text( params.text_document.uri.as_str(), params.text_document.text.as_str(), @@ -118,7 +116,7 @@ where pub async fn did_change(&self, params: DidChangeTextDocumentParams) { if let Some(change) = params.content_changes.into_iter().next_back() { - self.command_executor + self.interactor .update_document_with_text(params.text_document.uri.as_str(), &change.text) .await; } @@ -191,27 +189,38 @@ where let result = match command.clone() { SupportedCommands::ExecuteBaseImageScan { location, image } => { - execute_command_scan_base_image( - self, - location.uri.to_string(), - location.range, - image, - ) - .await - .map(|_| None) + let image_scanner = self.component_factory.image_scanner().map_err(|e| { + Error::internal_error() + .with_message(format!("unable to create image scanner: {e}")) + })?; + let mut command = + ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image); + command.execute().await.map(|_| None) } SupportedCommands::ExecuteBuildAndScan { location } => { - execute_command_build_and_scan(self, location.uri.to_string(), location.range) - .await - .map(|_| None) + let image_scanner = self.component_factory.image_scanner().map_err(|e| { + Error::internal_error() + .with_message(format!("unable to create image scanner: {e}")) + })?; + let image_builder = self.component_factory.image_builder().map_err(|e| { + Error::internal_error() + .with_message(format!("unable to create image builder: {e}")) + })?; + let mut command = BuildAndScanCommand::new( + &image_builder, + &image_scanner, + &self.interactor, + location, + ); + command.execute().await.map(|_| None) } }; match result { Ok(_) => result, Err(mut e) => { - self.command_executor + self.interactor .show_message(MessageType::ERROR, e.to_string().as_str()) .await; e.message = format!("error calling command: '{command}': {e}").into(); @@ -224,52 +233,3 @@ where Ok(()) } } - -async fn execute_command_scan_base_image( - server: &mut LSPServerInner, - file: String, - range: Range, - image: String, -) -> Result<()> { - let image_scanner = { - server.component_factory.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })? - }; - - server - .command_executor - .scan_image(&file, range, &image, &image_scanner) - .await?; - - Ok(()) -} - -async fn execute_command_build_and_scan( - server: &mut LSPServerInner, - file: String, - range: Range, -) -> Result<()> { - let (image_scanner, image_builder) = { - let image_scanner = server.component_factory.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })?; - let image_builder = server.component_factory.image_builder().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image builder: {e}")) - })?; - - (image_scanner, image_builder) - }; - - server - .command_executor - .build_and_scan_from_file( - &PathBuf::from_str(&file).unwrap(), - range.start.line, - &image_builder, - &image_scanner, - ) - .await?; - - Ok(()) -} diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 50a33ef..2ac45b1 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -12,6 +12,7 @@ use tower_lsp::lsp_types::{ use super::{InMemoryDocumentDatabase, LSPClient}; pub mod command_generator; +pub mod commands; mod lsp_server_inner; pub mod supported_commands; use lsp_server_inner::LSPServerInner; diff --git a/src/app/mod.rs b/src/app/mod.rs index f2735fd..8fcf247 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,9 +1,9 @@ -mod commands; mod component_factory; mod document_database; mod image_builder; mod image_scanner; mod lsp_client; +mod lsp_interactor; mod lsp_server; mod queries; @@ -11,4 +11,5 @@ pub use document_database::*; pub use image_builder::{ImageBuildError, ImageBuildResult, ImageBuilder}; pub use image_scanner::{ImageScanError, ImageScanner, Vulnerabilities}; pub use lsp_client::LSPClient; +pub use lsp_interactor::LspInteractor; pub use lsp_server::LSPServer; From 2f945af46c4cc7c501b173ab6609154cfb257352 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 10:56:57 +0200 Subject: [PATCH 04/11] refactor(lsp_server): organize commands into modules --- .../build_and_scan.rs} | 123 ++---------------- src/app/lsp_server/commands/mod.rs | 9 ++ .../lsp_server/commands/scan_base_image.rs | 108 +++++++++++++++ src/app/lsp_server/lsp_server_inner.rs | 4 +- 4 files changed, 129 insertions(+), 115 deletions(-) rename src/app/lsp_server/{commands.rs => commands/build_and_scan.rs} (67%) create mode 100644 src/app/lsp_server/commands/mod.rs create mode 100644 src/app/lsp_server/commands/scan_base_image.rs diff --git a/src/app/lsp_server/commands.rs b/src/app/lsp_server/commands/build_and_scan.rs similarity index 67% rename from src/app/lsp_server/commands.rs rename to src/app/lsp_server/commands/build_and_scan.rs index ccc5900..f5ddcb6 100644 --- a/src/app/lsp_server/commands.rs +++ b/src/app/lsp_server/commands/build_and_scan.rs @@ -1,123 +1,18 @@ -use tower_lsp::jsonrpc::Result; - -#[async_trait::async_trait] -pub trait LspCommand { - async fn execute(&mut self) -> Result<()>; -} +use std::{path::PathBuf, str::FromStr, sync::Arc}; use itertools::Itertools; -use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::{ + Diagnostic, DiagnosticSeverity, Location, MessageType, Position, Range, +}; use crate::{ - app::{ImageScanner, LSPClient, LspInteractor}, - domain::scanresult::{scan_result::ScanResult, severity::Severity}, + app::{ImageBuilder, ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, + infra::parse_dockerfile, }; -use super::WithContext; - -pub struct ScanBaseImageCommand<'a, C, S> -where - S: ImageScanner, -{ - image_scanner: &'a S, - interactor: &'a LspInteractor, - location: Location, - image: String, -} - -impl<'a, C, S> ScanBaseImageCommand<'a, C, S> -where - S: ImageScanner, -{ - pub fn new( - image_scanner: &'a S, - interactor: &'a LspInteractor, - location: Location, - image: String, - ) -> Self { - Self { - image_scanner, - interactor, - location, - image, - } - } -} - -#[async_trait::async_trait] -impl<'a, C, S> LspCommand for ScanBaseImageCommand<'a, C, S> -where - C: LSPClient + Sync, - S: ImageScanner + Sync, -{ - async fn execute(&mut self) -> tower_lsp::jsonrpc::Result<()> { - let image_name = &self.image; - self.interactor - .show_message( - MessageType::INFO, - format!("Starting scan of {image_name}...").as_str(), - ) - .await; - - let scan_result = self - .image_scanner - .scan_image(image_name) - .await - .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; - - self.interactor - .show_message( - MessageType::INFO, - format!("Finished scan of {image_name}.").as_str(), - ) - .await; - - let diagnostic = { - let mut diagnostic = Diagnostic { - range: self.location.range, - severity: Some(DiagnosticSeverity::HINT), - message: "No vulnerabilities found.".to_owned(), - ..Default::default() - }; - - if !scan_result.vulnerabilities().is_empty() { - let vulns = scan_result - .vulnerabilities() - .iter() - .counts_by(|v| v.severity()); - diagnostic.message = format!( - "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - image_name, - vulns.get(&Severity::Critical).unwrap_or(&0_usize), - vulns.get(&Severity::High).unwrap_or(&0_usize), - vulns.get(&Severity::Medium).unwrap_or(&0_usize), - vulns.get(&Severity::Low).unwrap_or(&0_usize), - vulns.get(&Severity::Negligible).unwrap_or(&0_usize), - ); - - diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { - DiagnosticSeverity::INFORMATION - } else { - DiagnosticSeverity::ERROR - }); - } - - diagnostic - }; - - let uri = self.location.uri.as_str(); - self.interactor.remove_diagnostics(uri).await; - self.interactor - .append_document_diagnostics(uri, &[diagnostic]) - .await; - self.interactor.publish_all_diagnostics().await - } -} - -use std::{path::PathBuf, str::FromStr, sync::Arc}; -use tower_lsp::lsp_types::{Position, Range}; - -use crate::{app::ImageBuilder, domain::scanresult::layer::Layer, infra::parse_dockerfile}; +use super::LspCommand; pub struct BuildAndScanCommand<'a, C, B, S> where diff --git a/src/app/lsp_server/commands/mod.rs b/src/app/lsp_server/commands/mod.rs new file mode 100644 index 0000000..873c28d --- /dev/null +++ b/src/app/lsp_server/commands/mod.rs @@ -0,0 +1,9 @@ +pub mod build_and_scan; +pub mod scan_base_image; + +use tower_lsp::jsonrpc::Result; + +#[async_trait::async_trait] +pub trait LspCommand { + async fn execute(&mut self) -> Result<()>; +} diff --git a/src/app/lsp_server/commands/scan_base_image.rs b/src/app/lsp_server/commands/scan_base_image.rs new file mode 100644 index 0000000..0663bb6 --- /dev/null +++ b/src/app/lsp_server/commands/scan_base_image.rs @@ -0,0 +1,108 @@ +use itertools::Itertools; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; + +use crate::{ + app::{ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + domain::scanresult::severity::Severity, +}; + +use super::LspCommand; + +pub struct ScanBaseImageCommand<'a, C, S> +where + S: ImageScanner, +{ + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, +} + +impl<'a, C, S> ScanBaseImageCommand<'a, C, S> +where + S: ImageScanner, +{ + pub fn new( + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, + ) -> Self { + Self { + image_scanner, + interactor, + location, + image, + } + } +} + +#[async_trait::async_trait] +impl<'a, C, S> LspCommand for ScanBaseImageCommand<'a, C, S> +where + C: LSPClient + Sync, + S: ImageScanner + Sync, +{ + async fn execute(&mut self) -> tower_lsp::jsonrpc::Result<()> { + let image_name = &self.image; + self.interactor + .show_message( + MessageType::INFO, + format!("Starting scan of {image_name}...").as_str(), + ) + .await; + + let scan_result = self + .image_scanner + .scan_image(image_name) + .await + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; + + self.interactor + .show_message( + MessageType::INFO, + format!("Finished scan of {image_name}.").as_str(), + ) + .await; + + let diagnostic = { + let mut diagnostic = Diagnostic { + range: self.location.range, + severity: Some(DiagnosticSeverity::HINT), + message: "No vulnerabilities found.".to_owned(), + ..Default::default() + }; + + if !scan_result.vulnerabilities().is_empty() { + let vulns = scan_result + .vulnerabilities() + .iter() + .counts_by(|v| v.severity()); + diagnostic.message = format!( + "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + image_name, + vulns.get(&Severity::Critical).unwrap_or(&0_usize), + vulns.get(&Severity::High).unwrap_or(&0_usize), + vulns.get(&Severity::Medium).unwrap_or(&0_usize), + vulns.get(&Severity::Low).unwrap_or(&0_usize), + vulns.get(&Severity::Negligible).unwrap_or(&0_usize), + ); + + diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { + DiagnosticSeverity::INFORMATION + } else { + DiagnosticSeverity::ERROR + }); + } + + diagnostic + }; + + let uri = self.location.uri.as_str(); + self.interactor.remove_diagnostics(uri).await; + self.interactor + .append_document_diagnostics(uri, &[diagnostic]) + .await; + self.interactor.publish_all_diagnostics().await + } +} diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 59e6738..5baa251 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -13,7 +13,9 @@ use super::super::LspInteractor; use super::super::component_factory::{ComponentFactory, Config}; use super::super::queries::QueryExecutor; use super::command_generator; -use super::commands::{BuildAndScanCommand, LspCommand, ScanBaseImageCommand}; +use super::commands::{ + LspCommand, build_and_scan::BuildAndScanCommand, scan_base_image::ScanBaseImageCommand, +}; use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; use super::supported_commands::SupportedCommands; From b922051c676589a1687e2da620b89a7e642b9d44 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 11:09:31 +0200 Subject: [PATCH 05/11] refactor(lsp_server): improve structure and robustness --- src/app/lsp_server/lsp_server_inner.rs | 134 +++++++++++++------------ 1 file changed, 72 insertions(+), 62 deletions(-) diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 5baa251..72dc15d 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -4,8 +4,8 @@ use tower_lsp::lsp_types::{ CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, Location, + MessageType, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; @@ -23,7 +23,7 @@ use super::supported_commands::SupportedCommands; pub struct LSPServerInner { interactor: LspInteractor, query_executor: QueryExecutor, - component_factory: ComponentFactory, + component_factory: Option, } impl LSPServerInner { @@ -33,7 +33,7 @@ impl LSPServerInner { LSPServerInner { interactor: LspInteractor::new(client, document_database.clone()), query_executor: QueryExecutor::new(document_database.clone()), - component_factory: Default::default(), // to be initialized in the initialize method of the LSP + component_factory: None, // to be initialized in the initialize method of the LSP } } } @@ -50,7 +50,9 @@ where debug!("updating with configuration: {config:?}"); - self.component_factory.initialize_with(config); + let mut factory = ComponentFactory::default(); + factory.initialize_with(config); + self.component_factory = Some(factory); debug!("updated configuration"); Ok(()) @@ -61,6 +63,20 @@ impl LSPServerInner where C: LSPClient + Send + Sync + 'static, { + async fn get_commands_for_document( + &self, + uri: &tower_lsp::lsp_types::Url, + ) -> Result> { + let Some(content) = self.query_executor.get_document_text(uri.as_str()).await else { + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {uri}" + ))); + }; + + let commands = command_generator::generate_commands_for_uri(uri, &content); + Ok(commands) + } + pub async fn initialize( &mut self, initialize_params: InitializeParams, @@ -128,19 +144,9 @@ where &self, params: CodeActionParams, ) -> Result> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = - command_generator::generate_commands_for_uri(¶ms.text_document.uri, &content); + let commands = self + .get_commands_for_document(¶ms.text_document.uri) + .await?; let code_actions: Vec = commands .into_iter() .filter(|cmd| cmd.range.start.line == params.range.start.line) @@ -157,19 +163,9 @@ where } pub async fn code_lens(&self, params: CodeLensParams) -> Result>> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = - command_generator::generate_commands_for_uri(¶ms.text_document.uri, &content); + let commands = self + .get_commands_for_document(¶ms.text_document.uri) + .await?; let code_lenses = commands .into_iter() .map(|cmd| CodeLens { @@ -186,49 +182,63 @@ where Ok(Some(code_lenses)) } + fn component_factory_mut(&mut self) -> Result<&mut ComponentFactory> { + self.component_factory + .as_mut() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) + } + + async fn execute_base_image_scan( + &mut self, + location: Location, + image: String, + ) -> Result> { + let image_scanner = self.component_factory_mut()?.image_scanner().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image scanner: {e}")) + })?; + let mut command = + ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image); + command.execute().await.map(|_| None) + } + + async fn execute_build_and_scan(&mut self, location: Location) -> Result> { + let factory = self.component_factory_mut()?; + let image_scanner = factory.image_scanner().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image scanner: {e}")) + })?; + let image_builder = factory.image_builder().map_err(|e| { + Error::internal_error().with_message(format!("unable to create image builder: {e}")) + })?; + let mut command = + BuildAndScanCommand::new(&image_builder, &image_scanner, &self.interactor, location); + command.execute().await.map(|_| None) + } + pub async fn execute_command(&mut self, params: ExecuteCommandParams) -> Result> { let command: SupportedCommands = params.try_into()?; let result = match command.clone() { SupportedCommands::ExecuteBaseImageScan { location, image } => { - let image_scanner = self.component_factory.image_scanner().map_err(|e| { - Error::internal_error() - .with_message(format!("unable to create image scanner: {e}")) - })?; - let mut command = - ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image); - command.execute().await.map(|_| None) + self.execute_base_image_scan(location, image).await } SupportedCommands::ExecuteBuildAndScan { location } => { - let image_scanner = self.component_factory.image_scanner().map_err(|e| { - Error::internal_error() - .with_message(format!("unable to create image scanner: {e}")) - })?; - let image_builder = self.component_factory.image_builder().map_err(|e| { - Error::internal_error() - .with_message(format!("unable to create image builder: {e}")) - })?; - let mut command = BuildAndScanCommand::new( - &image_builder, - &image_scanner, - &self.interactor, - location, - ); - command.execute().await.map(|_| None) + self.execute_build_and_scan(location).await } }; - match result { - Ok(_) => result, - Err(mut e) => { - self.interactor - .show_message(MessageType::ERROR, e.to_string().as_str()) - .await; - e.message = format!("error calling command: '{command}': {e}").into(); - Err(e) - } + if let Err(e) = &result { + self.interactor + .show_message(MessageType::ERROR, e.to_string().as_str()) + .await; + return Err(Error { + code: e.code, + message: format!("error calling command: '{command}': {}", e.message).into(), + data: e.data.clone(), + }); } + + result } pub async fn shutdown(&self) -> Result<()> { From cb51dd63b81b573a0805d268e6bb40c0019de8be Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 11:22:14 +0200 Subject: [PATCH 06/11] refactor(lsp): centralize command execution and error handling --- src/app/lsp_server/lsp_server_inner.rs | 54 ++++++++++++++------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 72dc15d..2f7a87b 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -4,8 +4,8 @@ use tower_lsp::lsp_types::{ CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, Location, - MessageType, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; @@ -190,18 +190,21 @@ where async fn execute_base_image_scan( &mut self, - location: Location, + location: tower_lsp::lsp_types::Location, image: String, - ) -> Result> { + ) -> Result<()> { let image_scanner = self.component_factory_mut()?.image_scanner().map_err(|e| { Error::internal_error().with_message(format!("unable to create image scanner: {e}")) })?; - let mut command = - ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image); - command.execute().await.map(|_| None) + ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image) + .execute() + .await } - async fn execute_build_and_scan(&mut self, location: Location) -> Result> { + async fn execute_build_and_scan( + &mut self, + location: tower_lsp::lsp_types::Location, + ) -> Result<()> { let factory = self.component_factory_mut()?; let image_scanner = factory.image_scanner().map_err(|e| { Error::internal_error().with_message(format!("unable to create image scanner: {e}")) @@ -209,36 +212,39 @@ where let image_builder = factory.image_builder().map_err(|e| { Error::internal_error().with_message(format!("unable to create image builder: {e}")) })?; - let mut command = - BuildAndScanCommand::new(&image_builder, &image_scanner, &self.interactor, location); - command.execute().await.map(|_| None) + BuildAndScanCommand::new(&image_builder, &image_scanner, &self.interactor, location) + .execute() + .await + } + + async fn handle_command_error(&self, command_name: &str, e: Error) -> Error { + self.interactor + .show_message(MessageType::ERROR, e.to_string().as_str()) + .await; + Error { + code: e.code, + message: format!("error calling command: '{command_name}': {}", e.message).into(), + data: e.data, + } } pub async fn execute_command(&mut self, params: ExecuteCommandParams) -> Result> { let command: SupportedCommands = params.try_into()?; + let command_name = command.to_string(); - let result = match command.clone() { + let result = match command { SupportedCommands::ExecuteBaseImageScan { location, image } => { self.execute_base_image_scan(location, image).await } - SupportedCommands::ExecuteBuildAndScan { location } => { self.execute_build_and_scan(location).await } }; - if let Err(e) = &result { - self.interactor - .show_message(MessageType::ERROR, e.to_string().as_str()) - .await; - return Err(Error { - code: e.code, - message: format!("error calling command: '{command}': {}", e.message).into(), - data: e.data.clone(), - }); + match result { + Ok(_) => Ok(None), + Err(e) => Err(self.handle_command_error(&command_name, e).await), } - - result } pub async fn shutdown(&self) -> Result<()> { From e90c42ab44efba26cc664fb6b2dfc74d4d387cc4 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 11:37:24 +0200 Subject: [PATCH 07/11] refactor(lsp-client): change transformation to from --- src/app/lsp_server/command_generator.rs | 26 ++++++++++++++++++++++++- src/app/lsp_server/lsp_server_inner.rs | 23 +++------------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/app/lsp_server/command_generator.rs b/src/app/lsp_server/command_generator.rs index cc7155d..d6fc497 100644 --- a/src/app/lsp_server/command_generator.rs +++ b/src/app/lsp_server/command_generator.rs @@ -1,5 +1,5 @@ use serde_json::{Value, json}; -use tower_lsp::lsp_types::{Location, Range, Url}; +use tower_lsp::lsp_types::{CodeLens, Command, Location, Range, Url}; use crate::app::lsp_server::supported_commands::SupportedCommands; use crate::infra::{parse_compose_file, parse_dockerfile}; @@ -31,6 +31,30 @@ impl From for CommandInfo { } } +impl From for Command { + fn from(value: CommandInfo) -> Self { + Command { + title: value.title, + command: value.command, + arguments: value.arguments, + } + } +} + +impl From for CodeLens { + fn from(value: CommandInfo) -> Self { + CodeLens { + range: value.range, + command: Some(Command { + title: value.title, + command: value.command, + arguments: value.arguments, + }), + data: None, + } + } +} + pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Vec { let file_uri = uri.as_str(); diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 2f7a87b..e41376e 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -2,7 +2,7 @@ use serde_json::Value; use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; use tower_lsp::lsp_types::{ CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, - CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, + CodeLens, CodeLensOptions, CodeLensParams, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, @@ -150,13 +150,7 @@ where let code_actions: Vec = commands .into_iter() .filter(|cmd| cmd.range.start.line == params.range.start.line) - .map(|cmd| { - CodeActionOrCommand::Command(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }) - }) + .map(|cmd| CodeActionOrCommand::Command(cmd.into())) .collect(); Ok(Some(code_actions)) @@ -166,18 +160,7 @@ where let commands = self .get_commands_for_document(¶ms.text_document.uri) .await?; - let code_lenses = commands - .into_iter() - .map(|cmd| CodeLens { - range: cmd.range, - command: Some(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }), - data: None, - }) - .collect(); + let code_lenses = commands.into_iter().map(|cmd| cmd.into()).collect(); Ok(Some(code_lenses)) } From 0f32b30799e735807251f29bbd15dfeb33e7316b Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 11:54:46 +0200 Subject: [PATCH 08/11] refactor(app): improve component factory and LSP server lifecycle --- src/app/component_factory.rs | 97 ++++++++++---------------- src/app/lsp_server/lsp_server_inner.rs | 61 +++++++--------- src/app/lsp_server/mod.rs | 2 +- 3 files changed, 63 insertions(+), 97 deletions(-) diff --git a/src/app/component_factory.rs b/src/app/component_factory.rs index bdf72aa..ebf75c1 100644 --- a/src/app/component_factory.rs +++ b/src/app/component_factory.rs @@ -3,6 +3,7 @@ use std::env::VarError; use bollard::Docker; use serde::Deserialize; use thiserror::Error; +use tower_lsp::jsonrpc::{Error as LspError, ErrorCode}; use crate::infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}; @@ -17,19 +18,14 @@ pub struct SysdigConfig { api_token: Option, } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct ComponentFactory { - config: Option, - - scanner: Option, - builder: Option, + scanner: SysdigImageScanner, + builder: DockerImageBuilder, } #[derive(Error, Debug)] pub enum ComponentFactoryError { - #[error("the configuration has not been provided")] - ConfigurationNotProvided, - #[error("unable to retrieve sysdig api token from env var: {0}")] UnableToRetrieveAPITokenFromEnvVar(#[from] VarError), @@ -38,20 +34,7 @@ pub enum ComponentFactoryError { } impl ComponentFactory { - pub fn initialize_with(&mut self, config: Config) { - self.config.replace(config); - self.scanner.take(); - } - - pub fn image_scanner(&mut self) -> Result { - if self.scanner.is_some() { - return Ok(self.scanner.clone().unwrap()); - } - - let Some(config) = &self.config else { - return Err(ComponentFactoryError::ConfigurationNotProvided); - }; - + pub fn new(config: Config) -> Result { let token = config .sysdig .api_token @@ -59,22 +42,40 @@ impl ComponentFactory { .map(Ok) .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN").map(SysdigAPIToken))?; - let image_scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); + let scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); - self.scanner.replace(image_scanner); - Ok(self.scanner.clone().unwrap()) + let docker_client = Docker::connect_with_local_defaults()?; + let builder = DockerImageBuilder::new(docker_client); + + Ok(Self { scanner, builder }) } - pub fn image_builder(&mut self) -> Result { - if self.builder.is_some() { - return Ok(self.builder.clone().unwrap()); - } + pub fn image_scanner(&self) -> &SysdigImageScanner { + &self.scanner + } - let docker_client = Docker::connect_with_local_defaults()?; - let image_builder = DockerImageBuilder::new(docker_client); + pub fn image_builder(&self) -> &DockerImageBuilder { + &self.builder + } +} - self.builder.replace(image_builder); - Ok(self.builder.clone().unwrap()) +impl From for LspError { + fn from(err: ComponentFactoryError) -> Self { + let (code, message) = match err { + ComponentFactoryError::UnableToRetrieveAPITokenFromEnvVar(e) => ( + ErrorCode::InternalError, + format!("Could not read SECURE_API_TOKEN from environment: {}", e), + ), + ComponentFactoryError::DockerClientError(e) => ( + ErrorCode::InternalError, + format!("Failed to connect to Docker: {}", e), + ), + }; + LspError { + code, + message: message.into(), + data: None, + } } } @@ -83,32 +84,8 @@ mod test { use super::{ComponentFactory, Config}; #[test] - fn it_loads_the_factory_uninit() { - let factory = ComponentFactory::default(); - - assert!(factory.config.is_none()); - } - - #[test] - fn it_fails_to_create_the_scanner_without_config() { - let mut factory = ComponentFactory::default(); - - assert!(factory.image_scanner().is_err()); - } - - #[test] - fn it_creates_a_scanner_after_initializing() { - let mut factory = ComponentFactory::default(); - - factory.initialize_with(Config::default()); - - assert!(factory.image_scanner().is_ok()); - } - - #[test] - fn it_creates_a_builder_without_config() { - let mut factory = ComponentFactory::default(); - - assert!(factory.image_builder().is_ok()); + fn it_creates_a_factory() { + let factory = ComponentFactory::new(Config::default()); + assert!(factory.is_ok()); } } diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index e41376e..a0083b5 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -42,17 +42,16 @@ impl LSPServerInner where C: LSPClient + Send + Sync + 'static, { - async fn initialize_component_factory_with(&mut self, config: &Value) -> Result<()> { - let Ok(config) = serde_json::from_value::(config.clone()) else { - return Err(Error::internal_error() - .with_message(format!("unable to transform json into config: {config}"))); - }; + fn update_component_factory(&mut self, config: &Value) -> Result<()> { + let config = serde_json::from_value::(config.clone()).map_err(|e| { + Error::internal_error() + .with_message(format!("unable to transform json into config: {e}")) + })?; debug!("updating with configuration: {config:?}"); - let mut factory = ComponentFactory::default(); - factory.initialize_with(config); - self.component_factory = Some(factory); + let factory = ComponentFactory::new(config)?; + self.component_factory.replace(factory); debug!("updated configuration"); Ok(()) @@ -89,7 +88,7 @@ where }); }; - self.initialize_component_factory_with(&config).await?; + self.update_component_factory(&config)?; Ok(InitializeResult { capabilities: ServerCapabilities { @@ -118,9 +117,7 @@ where } pub async fn did_change_configuration(&mut self, params: DidChangeConfigurationParams) { - let _ = self - .initialize_component_factory_with(¶ms.settings) - .await; + let _ = self.update_component_factory(¶ms.settings); } pub async fn did_open(&self, params: DidOpenTextDocumentParams) { @@ -165,37 +162,29 @@ where Ok(Some(code_lenses)) } - fn component_factory_mut(&mut self) -> Result<&mut ComponentFactory> { - self.component_factory - .as_mut() - .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) - } - async fn execute_base_image_scan( - &mut self, + &self, location: tower_lsp::lsp_types::Location, image: String, ) -> Result<()> { - let image_scanner = self.component_factory_mut()?.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })?; - ScanBaseImageCommand::new(&image_scanner, &self.interactor, location, image) + let factory = self + .component_factory + .as_ref() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized"))?; + let image_scanner = factory.image_scanner(); + ScanBaseImageCommand::new(image_scanner, &self.interactor, location, image) .execute() .await } - async fn execute_build_and_scan( - &mut self, - location: tower_lsp::lsp_types::Location, - ) -> Result<()> { - let factory = self.component_factory_mut()?; - let image_scanner = factory.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })?; - let image_builder = factory.image_builder().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image builder: {e}")) - })?; - BuildAndScanCommand::new(&image_builder, &image_scanner, &self.interactor, location) + async fn execute_build_and_scan(&self, location: tower_lsp::lsp_types::Location) -> Result<()> { + let factory = self + .component_factory + .as_ref() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized"))?; + let image_scanner = factory.image_scanner(); + let image_builder = factory.image_builder(); + BuildAndScanCommand::new(image_builder, image_scanner, &self.interactor, location) .execute() .await } @@ -211,7 +200,7 @@ where } } - pub async fn execute_command(&mut self, params: ExecuteCommandParams) -> Result> { + pub async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { let command: SupportedCommands = params.try_into()?; let command_name = command.to_string(); diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 2ac45b1..85032d1 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -85,7 +85,7 @@ where } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - self.inner.write().await.execute_command(params).await + self.inner.read().await.execute_command(params).await } async fn shutdown(&self) -> Result<()> { From 9bc8f45b8f0e9c4aa099ddacafdeeb762bfca3c4 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 16:02:45 +0200 Subject: [PATCH 09/11] feat(deps): introduce mockall for testing --- Cargo.lock | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 10 ++++--- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec7ae6e..d4becca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -465,6 +471,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fslock" version = "0.2.1" @@ -1158,6 +1170,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1361,6 +1399,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1554,20 +1618,21 @@ dependencies = [ [[package]] name = "rstest" -version = "0.26.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" dependencies = [ + "futures", "futures-timer", - "futures-util", "rstest_macros", + "rustc_version", ] [[package]] name = "rstest_macros" -version = "0.26.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" dependencies = [ "cfg-if", "glob", @@ -1955,8 +2020,8 @@ dependencies = [ "dirs", "futures", "itertools", - "lazy_static", "marked-yaml", + "mockall", "rand", "regex", "reqwest", @@ -2019,6 +2084,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.16" diff --git a/Cargo.toml b/Cargo.toml index d3ad9cc..6a8698f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,10 @@ tracing = "0.1.41" tracing-subscriber = "0.3.19" [dev-dependencies] -itertools = "0.14.0" -lazy_static = "1.5.0" -rstest = "0.26.1" -tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"] } +rstest = "0.21.0" +serial_test = "3.1.1" tracing-test = "0.2.5" +mockall = "0.13.0" + +[features] +default = [] From b40b80761dec69b111de09613e1bec64b3db6915 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 16:03:16 +0200 Subject: [PATCH 10/11] refactor(lsp): use trait for ComponentFactory --- src/app/component_factory.rs | 61 +- src/app/lsp_server/commands/build_and_scan.rs | 6 +- .../lsp_server/commands/scan_base_image.rs | 6 +- src/app/lsp_server/lsp_server_inner.rs | 71 +- src/app/lsp_server/mod.rs | 14 +- src/app/mod.rs | 2 +- src/infra/component_factory_impl.rs | 31 + src/infra/mod.rs | 2 + src/main.rs | 7 +- tests/common.rs | 129 ++++ tests/general.rs | 645 +++++++++--------- tests/test.rs | 151 ---- 12 files changed, 543 insertions(+), 582 deletions(-) create mode 100644 src/infra/component_factory_impl.rs create mode 100644 tests/common.rs delete mode 100644 tests/test.rs diff --git a/src/app/component_factory.rs b/src/app/component_factory.rs index ebf75c1..4720a72 100644 --- a/src/app/component_factory.rs +++ b/src/app/component_factory.rs @@ -1,27 +1,31 @@ use std::env::VarError; -use bollard::Docker; use serde::Deserialize; use thiserror::Error; use tower_lsp::jsonrpc::{Error as LspError, ErrorCode}; -use crate::infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}; +use super::{ImageBuilder, ImageScanner}; #[derive(Clone, Debug, Default, Deserialize)] pub struct Config { - sysdig: SysdigConfig, + pub sysdig: SysdigConfig, } #[derive(Clone, Debug, Default, Deserialize)] pub struct SysdigConfig { - api_url: String, - api_token: Option, + #[serde(alias = "apiUrl")] + pub api_url: String, + #[serde(alias = "apiToken")] + pub api_token: Option, } -#[derive(Clone)] -pub struct ComponentFactory { - scanner: SysdigImageScanner, - builder: DockerImageBuilder, +pub struct Components { + pub scanner: Box, + pub builder: Box, +} + +pub trait ComponentFactory: Send + Sync { + fn create_components(&self, config: Config) -> Result; } #[derive(Error, Debug)] @@ -30,33 +34,7 @@ pub enum ComponentFactoryError { UnableToRetrieveAPITokenFromEnvVar(#[from] VarError), #[error("docker client error: {0:?}")] - DockerClientError(#[from] bollard::errors::Error), -} - -impl ComponentFactory { - pub fn new(config: Config) -> Result { - let token = config - .sysdig - .api_token - .clone() - .map(Ok) - .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN").map(SysdigAPIToken))?; - - let scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); - - let docker_client = Docker::connect_with_local_defaults()?; - let builder = DockerImageBuilder::new(docker_client); - - Ok(Self { scanner, builder }) - } - - pub fn image_scanner(&self) -> &SysdigImageScanner { - &self.scanner - } - - pub fn image_builder(&self) -> &DockerImageBuilder { - &self.builder - } + DockerClientError(String), } impl From for LspError { @@ -78,14 +56,3 @@ impl From for LspError { } } } - -#[cfg(test)] -mod test { - use super::{ComponentFactory, Config}; - - #[test] - fn it_creates_a_factory() { - let factory = ComponentFactory::new(Config::default()); - assert!(factory.is_ok()); - } -} diff --git a/src/app/lsp_server/commands/build_and_scan.rs b/src/app/lsp_server/commands/build_and_scan.rs index f5ddcb6..fbc7eb1 100644 --- a/src/app/lsp_server/commands/build_and_scan.rs +++ b/src/app/lsp_server/commands/build_and_scan.rs @@ -14,7 +14,7 @@ use crate::{ use super::LspCommand; -pub struct BuildAndScanCommand<'a, C, B, S> +pub struct BuildAndScanCommand<'a, C, B: ?Sized, S: ?Sized> where B: ImageBuilder, S: ImageScanner, @@ -25,7 +25,7 @@ where location: Location, } -impl<'a, C, B, S> BuildAndScanCommand<'a, C, B, S> +impl<'a, C, B: ?Sized, S: ?Sized> BuildAndScanCommand<'a, C, B, S> where B: ImageBuilder, S: ImageScanner, @@ -46,7 +46,7 @@ where } #[async_trait::async_trait] -impl<'a, C, B, S> LspCommand for BuildAndScanCommand<'a, C, B, S> +impl<'a, C, B: ?Sized, S: ?Sized> LspCommand for BuildAndScanCommand<'a, C, B, S> where C: LSPClient + Sync, B: ImageBuilder + Sync, diff --git a/src/app/lsp_server/commands/scan_base_image.rs b/src/app/lsp_server/commands/scan_base_image.rs index 0663bb6..436e5ca 100644 --- a/src/app/lsp_server/commands/scan_base_image.rs +++ b/src/app/lsp_server/commands/scan_base_image.rs @@ -8,7 +8,7 @@ use crate::{ use super::LspCommand; -pub struct ScanBaseImageCommand<'a, C, S> +pub struct ScanBaseImageCommand<'a, C, S: ?Sized> where S: ImageScanner, { @@ -18,7 +18,7 @@ where image: String, } -impl<'a, C, S> ScanBaseImageCommand<'a, C, S> +impl<'a, C, S: ?Sized> ScanBaseImageCommand<'a, C, S> where S: ImageScanner, { @@ -38,7 +38,7 @@ where } #[async_trait::async_trait] -impl<'a, C, S> LspCommand for ScanBaseImageCommand<'a, C, S> +impl<'a, C, S: ?Sized> LspCommand for ScanBaseImageCommand<'a, C, S> where C: LSPClient + Sync, S: ImageScanner + Sync, diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index a0083b5..773e25a 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -9,40 +9,42 @@ use tower_lsp::lsp_types::{ }; use tracing::{debug, info}; -use super::super::LspInteractor; -use super::super::component_factory::{ComponentFactory, Config}; +use super::super::component_factory::{ComponentFactory, Components, Config}; use super::super::queries::QueryExecutor; use super::command_generator; use super::commands::{ LspCommand, build_and_scan::BuildAndScanCommand, scan_base_image::ScanBaseImageCommand, }; use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; +use crate::app::LspInteractor; use super::supported_commands::SupportedCommands; -pub struct LSPServerInner { +pub struct LSPServerInner { interactor: LspInteractor, query_executor: QueryExecutor, - component_factory: Option, + component_factory: F, + components: Option, } -impl LSPServerInner { - pub fn new(client: C) -> LSPServerInner { +impl LSPServerInner { + pub fn new(client: C, component_factory: F) -> LSPServerInner { let document_database = InMemoryDocumentDatabase::default(); LSPServerInner { interactor: LspInteractor::new(client, document_database.clone()), query_executor: QueryExecutor::new(document_database.clone()), - component_factory: None, // to be initialized in the initialize method of the LSP + component_factory, + components: None, } } } -impl LSPServerInner +impl LSPServerInner where C: LSPClient + Send + Sync + 'static, { - fn update_component_factory(&mut self, config: &Value) -> Result<()> { + fn update_components(&mut self, config: &Value) -> Result<()> { let config = serde_json::from_value::(config.clone()).map_err(|e| { Error::internal_error() .with_message(format!("unable to transform json into config: {e}")) @@ -50,15 +52,15 @@ where debug!("updating with configuration: {config:?}"); - let factory = ComponentFactory::new(config)?; - self.component_factory.replace(factory); + let components = self.component_factory.create_components(config)?; + self.components.replace(components); debug!("updated configuration"); Ok(()) } } -impl LSPServerInner +impl LSPServerInner where C: LSPClient + Send + Sync + 'static, { @@ -88,7 +90,7 @@ where }); }; - self.update_component_factory(&config)?; + self.update_components(&config)?; Ok(InitializeResult { capabilities: ServerCapabilities { @@ -117,7 +119,7 @@ where } pub async fn did_change_configuration(&mut self, params: DidChangeConfigurationParams) { - let _ = self.update_component_factory(¶ms.settings); + let _ = self.update_components(¶ms.settings); } pub async fn did_open(&self, params: DidOpenTextDocumentParams) { @@ -162,31 +164,38 @@ where Ok(Some(code_lenses)) } + fn components(&self) -> Result<&Components> { + self.components + .as_ref() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) + } + async fn execute_base_image_scan( &self, location: tower_lsp::lsp_types::Location, image: String, ) -> Result<()> { - let factory = self - .component_factory - .as_ref() - .ok_or_else(|| Error::internal_error().with_message("LSP not initialized"))?; - let image_scanner = factory.image_scanner(); - ScanBaseImageCommand::new(image_scanner, &self.interactor, location, image) - .execute() - .await + let components = self.components()?; + ScanBaseImageCommand::new( + components.scanner.as_ref(), + &self.interactor, + location, + image, + ) + .execute() + .await } async fn execute_build_and_scan(&self, location: tower_lsp::lsp_types::Location) -> Result<()> { - let factory = self - .component_factory - .as_ref() - .ok_or_else(|| Error::internal_error().with_message("LSP not initialized"))?; - let image_scanner = factory.image_scanner(); - let image_builder = factory.image_builder(); - BuildAndScanCommand::new(image_builder, image_scanner, &self.interactor, location) - .execute() - .await + let components = self.components()?; + BuildAndScanCommand::new( + components.builder.as_ref(), + components.scanner.as_ref(), + &self.interactor, + location, + ) + .execute() + .await } async fn handle_command_error(&self, command_name: &str, e: Error) -> Error { diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 85032d1..0d5a883 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -15,6 +15,7 @@ pub mod command_generator; pub mod commands; mod lsp_server_inner; pub mod supported_commands; +use crate::app::component_factory::ComponentFactory; use lsp_server_inner::LSPServerInner; pub trait WithContext { @@ -28,14 +29,14 @@ impl WithContext for Error { } } -pub struct LSPServer { - inner: RwLock>, +pub struct LSPServer { + inner: RwLock>, } -impl LSPServer { - pub fn new(client: C) -> LSPServer { +impl LSPServer { + pub fn new(client: C, component_factory: F) -> LSPServer { LSPServer { - inner: RwLock::new(LSPServerInner::new(client)), + inner: RwLock::new(LSPServerInner::new(client, component_factory)), } } } @@ -48,9 +49,10 @@ struct CommandInfo { } #[async_trait::async_trait] -impl LanguageServer for LSPServer +impl LanguageServer for LSPServer where C: LSPClient + Send + Sync + 'static, + F: ComponentFactory + Send + Sync + 'static, { async fn initialize(&self, params: InitializeParams) -> Result { self.inner.write().await.initialize(params).await diff --git a/src/app/mod.rs b/src/app/mod.rs index 8fcf247..bd5a917 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,4 @@ -mod component_factory; +pub mod component_factory; mod document_database; mod image_builder; mod image_scanner; diff --git a/src/infra/component_factory_impl.rs b/src/infra/component_factory_impl.rs new file mode 100644 index 0000000..0611515 --- /dev/null +++ b/src/infra/component_factory_impl.rs @@ -0,0 +1,31 @@ +use bollard::Docker; + +use crate::{ + app::component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, + infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}, +}; + +pub struct ConcreteComponentFactory; + +impl ComponentFactory for ConcreteComponentFactory { + fn create_components(&self, config: Config) -> Result { + let token = config + .sysdig + .api_token + .clone() + .map(Ok) + .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN")) + .map(SysdigAPIToken)?; + + let scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); + + let docker_client = Docker::connect_with_local_defaults() + .map_err(|e| ComponentFactoryError::DockerClientError(e.to_string()))?; + let builder = DockerImageBuilder::new(docker_client); + + Ok(Components { + scanner: Box::new(scanner), + builder: Box::new(builder), + }) + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index c329eac..a1e0c31 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,3 +1,4 @@ +mod component_factory_impl; mod compose_ast_parser; mod docker_image_builder; mod dockerfile_ast_parser; @@ -7,6 +8,7 @@ mod sysdig_image_scanner_json_scan_result_v1; pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner}; pub mod lsp_logger; +pub use component_factory_impl::ConcreteComponentFactory; pub use compose_ast_parser::{ImageInstruction, parse_compose_file}; pub use docker_image_builder::DockerImageBuilder; pub use dockerfile_ast_parser::{Instruction, parse_dockerfile}; diff --git a/src/main.rs b/src/main.rs index a992dbe..d30d08f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ use clap::Parser; -use sysdig_lsp::{app::LSPServer, infra::lsp_logger::LSPLogger}; +use sysdig_lsp::{ + app::LSPServer, + infra::{ConcreteComponentFactory, lsp_logger::LSPLogger}, +}; use tower_lsp::{LspService, Server}; use tracing_subscriber::layer::SubscriberExt; @@ -21,7 +24,7 @@ async fn main() { tracing::subscriber::set_global_default(subscriber) .expect("setting default subscriber failed"); - LSPServer::new(client) + LSPServer::new(client, ConcreteComponentFactory) }); Server::new(stdin, stdout, messages).serve(service).await; diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..6299fa4 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; +use tokio::sync::Mutex; + +use mockall::mock; +use sysdig_lsp::{ + app::{ + ImageBuildError, ImageBuildResult, ImageBuilder, ImageScanError, ImageScanner, LSPServer, + component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, + }, + domain::scanresult::scan_result::ScanResult, +}; +use tower_lsp::lsp_types::{Diagnostic, MessageType}; + +// --- Contenido de recorder.rs --- +#[derive(Clone)] +pub struct TestClientRecorder { + pub messages: Arc>>, + pub diagnostics: Arc>>>, +} + +impl TestClientRecorder { + pub fn new() -> Self { + Self { + messages: Arc::new(Mutex::new(Vec::new())), + diagnostics: Arc::new(Mutex::new(Vec::new())), + } + } +} + +#[async_trait::async_trait] +impl sysdig_lsp::app::LSPClient for TestClientRecorder { + async fn show_message( + &self, + message_type: MessageType, + message: M, + ) { + self.messages + .lock() + .await + .push((message_type, message.to_string())); + } + + async fn publish_diagnostics( + &self, + _url: &str, + diagnostics: Vec, + _version: Option, + ) { + self.diagnostics.lock().await.push(diagnostics); + } +} + +// --- Contenido de mocks.rs --- +mock! { + pub ImageBuilder {} + #[async_trait::async_trait] + impl ImageBuilder for ImageBuilder { + async fn build_image(&self, containerfile: &std::path::Path) -> Result; + } +} + +mock! { + pub ImageScanner {} + #[async_trait::async_trait] + impl ImageScanner for ImageScanner { + async fn scan_image(&self, image_pull_string: &str) -> Result; + } +} + +// --- Implementaciones de traits para Arc> --- +#[derive(Clone)] +pub struct MockImageBuilderWrapper(pub Arc>); +#[derive(Clone)] +pub struct MockImageScannerWrapper(pub Arc>); + +#[async_trait::async_trait] +impl ImageBuilder for MockImageBuilderWrapper { + async fn build_image( + &self, + containerfile: &std::path::Path, + ) -> Result { + self.0.lock().await.build_image(containerfile).await + } +} + +#[async_trait::async_trait] +impl ImageScanner for MockImageScannerWrapper { + async fn scan_image(&self, image_pull_string: &str) -> Result { + self.0.lock().await.scan_image(image_pull_string).await + } +} + +// --- Estructuras de Setup --- +#[derive(Clone)] +pub struct MockComponentFactory { + pub image_builder: Arc>, + pub image_scanner: Arc>, +} + +impl ComponentFactory for MockComponentFactory { + fn create_components(&self, _config: Config) -> Result { + Ok(Components { + builder: Box::new(MockImageBuilderWrapper(self.image_builder.clone())), + scanner: Box::new(MockImageScannerWrapper(self.image_scanner.clone())), + }) + } +} + +pub struct TestSetup { + pub server: LSPServer, + pub client_recorder: TestClientRecorder, + pub component_factory: MockComponentFactory, +} + +impl TestSetup { + pub fn new() -> Self { + let client_recorder = TestClientRecorder::new(); + let component_factory = MockComponentFactory { + image_builder: Arc::new(Mutex::new(MockImageBuilder::new())), + image_scanner: Arc::new(Mutex::new(MockImageScanner::new())), + }; + let server = LSPServer::new(client_recorder.clone(), component_factory.clone()); + Self { + server, + client_recorder, + component_factory, + } + } +} diff --git a/tests/general.rs b/tests/general.rs index a848541..68c6251 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -1,383 +1,352 @@ +mod common; + +use common::TestSetup; +use rstest::{fixture, rstest}; use serde_json::json; -use tower_lsp::lsp_types::{CodeActionOrCommand, CodeLens, Command, MessageType, Position, Range}; +use std::collections::HashMap; +use sysdig_lsp::domain::scanresult::architecture::Architecture; +use sysdig_lsp::domain::scanresult::operating_system::{Family, OperatingSystem}; +use sysdig_lsp::domain::scanresult::scan_result::ScanResult; +use sysdig_lsp::domain::scanresult::scan_type::ScanType; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::{ + CodeActionContext, CodeActionParams, DiagnosticSeverity, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, + PartialResultParams, Position, Range, TextDocumentIdentifier, TextDocumentItem, Url, + VersionedTextDocumentIdentifier, WorkDoneProgressParams, +}; -mod test; +#[fixture] +async fn initialized_server() -> TestSetup { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { + "apiUrl": "http://localhost:8080", + "api_token": "dummy-token" + } + })), + ..Default::default() + }; + let result = setup.server.initialize(params).await; + assert!(result.is_ok()); + setup +} +#[rstest] +#[awt] #[tokio::test] -async fn when_the_lsp_is_loaded_initializes_correctly() { - let mut client = test::TestClient::new(); - let response = client.initialize_lsp().await; +async fn test_did_change_configuration(#[future] initialized_server: TestSetup) { + let params = DidChangeConfigurationParams { + settings: serde_json::json!({ + "sysdig": { + "apiUrl": "http://localhost:8080", + "api_token": "dummy-token" + } + }), + }; + initialized_server + .server + .did_change_configuration(params) + .await; +} - assert!(response.capabilities.code_action_provider.is_some()); - assert!( - client - .recorder() - .messages_shown() - .await - .contains(&(MessageType::INFO, "Sysdig LSP initialized".to_string())) - ) +#[rstest] +#[awt] +#[tokio::test] +async fn test_did_open(#[future] initialized_server: TestSetup) { + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + "file:///Dockerfile".parse().unwrap(), + "dockerfile".to_string(), + 1, + "FROM alpine".to_string(), + ), + }; + initialized_server.server.did_open(params).await; } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_actions_it_receives_the_available_code_actions() -{ - let mut client = test::TestClient::new_initialized().await; +async fn test_did_change(#[future] initialized_server: TestSetup) { + let params = DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier::new( + "file:///Dockerfile".parse().unwrap(), + 1, + ), + content_changes: vec![], + }; + initialized_server.server.did_change(params).await; +} - client - .open_file_with_contents("Dockerfile", "FROM alpine") - .await; +#[fixture] +fn open_file_url() -> Url { + "file:///Dockerfile".parse().unwrap() +} - let response = client - .request_available_actions_in_line("Dockerfile", 0) +#[fixture] +#[awt] +async fn server_with_open_file( + #[future] initialized_server: TestSetup, + open_file_url: Url, +) -> TestSetup { + initialized_server + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + open_file_url.clone(), + "dockerfile".to_string(), + 1, + "FROM alpine".to_string(), + ), + }) .await; - - assert_eq!( - response.unwrap(), - vec![ - CodeActionOrCommand::Command(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 11} - } - })]) - }), - CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 11} - } - }), - json!("alpine"), - ]) - }) - ] - ); + initialized_server } -#[tokio::test] -async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_contains_multiple_froms_it_only_returns_the_latest() - { - let mut client = test::TestClient::new_initialized().await; +use sysdig_lsp::domain::scanresult::{package_type::PackageType, severity::Severity}; - client - .open_file_with_contents("Dockerfile", "FROM alpine\nFROM ubuntu") - .await; +#[fixture] +fn scan_result() -> ScanResult { + let mut result = ScanResult::new( + ScanType::Docker, + "alpine:latest".to_string(), + "sha256:12345".to_string(), + Some("sha256:67890".to_string()), + OperatingSystem::new(Family::Linux, "alpine:3.18".to_string()), + 123456, + Architecture::Amd64, + HashMap::new(), + chrono::Utc::now(), + ); - let response_for_first_line = client - .request_available_actions_in_line("Dockerfile", 0) - .await; - assert!(response_for_first_line.unwrap().is_empty()); + let layer = result.add_layer( + "sha256:layer1".to_string(), + 0, + Some(1024), + "COPY . .".to_string(), + ); - let response_for_second_line = client - .request_available_actions_in_line("Dockerfile", 1) - .await; + let package1 = result.add_package( + PackageType::Os, + "package1".to_string(), + "1.0.0".to_string(), + "/usr/lib/package1".to_string(), + layer.clone(), + ); - assert_eq!( - response_for_second_line.unwrap(), - vec![ - CodeActionOrCommand::Command(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 1, "character": 0}, - "end": {"line": 1, "character": 11} - } - })]) - }), - CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 1, "character": 0}, - "end": {"line": 1, "character": 11} - } - }), - json!("ubuntu"), - ]) - }) - ] + result.add_package( + PackageType::Os, + "package2".to_string(), + "2.0.0".to_string(), + "/usr/lib/package2".to_string(), + layer, + ); + + let vulnerability = result.add_vulnerability( + "CVE-2021-1234".to_string(), + Severity::High, + chrono::NaiveDate::from_ymd_opt(2021, 1, 1).unwrap(), + None, + false, + Some("1.0.1".to_string()), ); + + package1.add_vulnerability_found(vulnerability); + + result } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_available_code_lens() { - let mut client = test::TestClient::new_initialized().await; +async fn test_code_action(#[future] server_with_open_file: TestSetup, open_file_url: Url) { + let params = CodeActionParams { + text_document: TextDocumentIdentifier::new(open_file_url), + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + context: CodeActionContext::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let result = server_with_open_file + .server + .code_action(params) + .await + .unwrap() + .unwrap(); - // Open a Dockerfile containing a single "FROM" statement. - client - .open_file_with_contents("Dockerfile", "FROM alpine") - .await; + let mut result_json = serde_json::to_value(result).unwrap(); + // Sort by command title to have a deterministic order for comparison + result_json.as_array_mut().unwrap().sort_by(|a, b| { + a["title"] + .as_str() + .unwrap() + .cmp(b["title"].as_str().unwrap()) + }); - // Request code lens on the line with the FROM statement (line 0). - let response = client - .request_available_code_lens_in_file("Dockerfile") - .await; + let expected_json = serde_json::json!([ + { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + } + ], + "command": "sysdig-lsp.execute-build-and-scan", + "title": "Build and scan" + }, + { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + }, + "alpine" + ], + "command": "sysdig-lsp.execute-scan", + "title": "Scan base image" + } + ]); - // Expect a CodeLens with the appropriate command. - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 11)), - command: Some(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 11} - } - })]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 11)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 11} - } - }), - json!("alpine"), - ]) - }), - data: None - } - ] - ); + assert_eq!(result_json, expected_json); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_contains_multiple_froms_it_only_returns_the_latest() - { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents("Dockerfile", "FROM alpine\nFROM ubuntu") - .await; +async fn test_code_lens(#[future] server_with_open_file: TestSetup, open_file_url: Url) { + let params = tower_lsp::lsp_types::CodeLensParams { + text_document: TextDocumentIdentifier::new(open_file_url), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; - let response = client - .request_available_code_lens_in_file("Dockerfile") - .await; + let result = server_with_open_file + .server + .code_lens(params) + .await + .unwrap() + .unwrap(); - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 11)), - command: Some(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!({ - "uri": "file://dockerfile/", + let mut result_json = serde_json::to_value(result).unwrap(); + // Sort by command title to have a deterministic order for comparison + result_json.as_array_mut().unwrap().sort_by(|a, b| { + a["command"]["title"] + .as_str() + .unwrap() + .cmp(b["command"]["title"].as_str().unwrap()) + }); + + let expected_json = serde_json::json!([ + { + "command": { + "arguments": [ + { "range": { - "start": {"line": 1, "character": 0}, - "end": {"line": 1, "character": 11} - } - })]) - }), - data: None + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + } + ], + "command": "sysdig-lsp.execute-build-and-scan", + "title": "Build and scan" }, - CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 11)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://dockerfile/", - "range": { - "start": {"line": 1, "character": 0}, - "end": {"line": 1, "character": 11} - } - }), - json!("ubuntu"), - ]) - }), - data: None + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } } - ] - ); -} - -#[tokio::test] -async fn when_the_client_asks_for_code_lens_in_a_compose_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents( - "docker-compose.yml", - include_str!("fixtures/docker-compose.yml"), - ) - .await; - - let response = client - .request_available_code_lens_in_file("docker-compose.yml") - .await; - - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(2, 11), Position::new(2, 23)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://docker-compose.yml/", - "range": { - "start": {"line": 2, "character": 11}, - "end": {"line": 2, "character": 23} - } - }), - json!("nginx:latest") - ]) - }), - data: None + }, + { + "command": { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + }, + "alpine" + ], + "command": "sysdig-lsp.execute-scan", + "title": "Scan base image" }, - CodeLens { - range: Range::new(Position::new(4, 11), Position::new(4, 22)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://docker-compose.yml/", - "range": { - "start": {"line": 4, "character": 11}, - "end": {"line": 4, "character": 22} - } - }), - json!("postgres:13") - ]) - }), - data: None + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } } - ] - ); + } + ]); + + assert_eq!(result_json, expected_json); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_code_actions_in_a_compose_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents( - "docker-compose.yml", - include_str!("fixtures/docker-compose.yml"), - ) - .await; +async fn test_execute_command( + #[future] server_with_open_file: TestSetup, + open_file_url: Url, + scan_result: ScanResult, +) { + server_with_open_file + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .with(mockall::predicate::eq("alpine")) + .times(1) + .returning(move |_| Ok(scan_result.clone())); - let response = client - .request_available_actions_in_line("docker-compose.yml", 2) - .await; + server_with_open_file + .client_recorder + .diagnostics + .lock() + .await + .clear(); + let params = ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":11,"line":0},"start":{"character": 0,"line":0}},"uri":open_file_url}), + json!("alpine"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }; + let result = server_with_open_file.server.execute_command(params).await; + assert!(result.is_ok()); + + let diagnostics = server_with_open_file + .client_recorder + .diagnostics + .lock() + .await; + assert_eq!(diagnostics.len(), 1); + let diagnostic = &diagnostics[0][0]; assert_eq!( - response.unwrap(), - vec![CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://docker-compose.yml/", - "range": { - "start": {"line": 2, "character": 11}, - "end": {"line": 2, "character": 23} - } - }), - json!("nginx:latest"), - ]) - })] + diagnostic.message, + "Vulnerabilities found for alpine: 0 Critical, 1 High, 0 Medium, 0 Low, 0 Negligible" + ); + assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::INFORMATION)); + assert_eq!( + diagnostic.range, + Range::new(Position::new(0, 0), Position::new(0, 11)) ); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents("compose.yaml", include_str!("fixtures/compose.yaml")) - .await; - - let response = client - .request_available_code_lens_in_file("compose.yaml") - .await; - - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(4, 13), Position::new(4, 25)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://compose.yaml/", - "range": { - "start": {"line": 4, "character": 13}, - "end": {"line": 4, "character": 25} - } - }), - json!("nginx:latest") - ]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(9, 6), Position::new(9, 17)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://compose.yaml/", - "range": { - "start": {"line": 9, "character": 6}, - "end": {"line": 9, "character": 17} - } - }), - json!("postgres:13") - ]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(13, 11), Position::new(13, 21)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!({ - "uri": "file://compose.yaml/", - "range": { - "start": {"line": 13, "character": 11}, - "end": {"line": 13, "character": 21} - } - }), - json!("my-api:1.0") - ]) - }), - data: None - } - ] - ); +async fn test_shutdown(#[future] initialized_server: TestSetup) { + let result = initialized_server.server.shutdown().await; + assert!(result.is_ok()); } diff --git a/tests/test.rs b/tests/test.rs deleted file mode 100644 index d34bd12..0000000 --- a/tests/test.rs +++ /dev/null @@ -1,151 +0,0 @@ -use core::panic; -use std::collections::HashMap; -use std::fmt::Display; -use std::sync::Arc; - -use serde_json::json; -use sysdig_lsp::app::{LSPClient, LSPServer}; -use tokio::sync::Mutex; -use tower_lsp::LanguageServer; -use tower_lsp::lsp_types::{ - CodeActionOrCommand, CodeActionParams, CodeLens, CodeLensParams, Diagnostic, - DidOpenTextDocumentParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Position, Range, TextDocumentIdentifier, TextDocumentItem, Url, -}; - -pub struct TestClient { - server: LSPServer, - recorder: TestClientRecorder, -} - -impl TestClient { - pub fn new() -> TestClient { - let recorder = TestClientRecorder::default(); - let server = LSPServer::new(recorder.clone()); - TestClient { server, recorder } - } - - pub async fn new_initialized() -> TestClient { - let mut client = Self::new(); - client.initialize_lsp().await; - client - } - - pub fn recorder(&self) -> &TestClientRecorder { - &self.recorder - } - - pub async fn initialize_lsp(&mut self) -> InitializeResult { - let result = self - .server - .initialize(InitializeParams { - initialization_options: Some(json!({"sysdig": - { - "api_url": "some_api_url" - } - })), - ..Default::default() - }) - .await - .expect("initialize failed"); - - self.server.initialized(InitializedParams {}).await; - - result - } - - pub async fn open_file_with_contents(&mut self, filename: &str, contents: &str) { - self.server - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: url_from(filename), - text: contents.to_string(), - language_id: "".to_owned(), // unused - version: 0, // unused - }, - }) - .await; - } - - pub async fn request_available_actions_in_line( - &mut self, - filename: &str, - line_number: u32, - ) -> Option> { - self.server - .code_action(CodeActionParams { - text_document: TextDocumentIdentifier::new(url_from(filename)), - range: Range { - start: Position::new(line_number, 0), - end: Position::new(line_number, 0), - }, - context: Default::default(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }) - .await - .unwrap_or_else(|_| { - panic!( - "unable to send code action for filename {filename} in line number {line_number}", - ) - }) - } - - pub async fn request_available_code_lens_in_file( - &mut self, - filename: &str, - ) -> Option> { - self.server - .code_lens(CodeLensParams { - text_document: TextDocumentIdentifier::new(url_from(filename)), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }) - .await - .unwrap_or_else(|_| panic!("unable to send code lens for filename {filename}")) - } -} - -fn url_from(filename: &str) -> Url { - Url::parse(&format!("file://{}", filename)).expect("unable to convert filename &str to Url") -} - -impl Default for TestClient { - fn default() -> Self { - Self::new() - } -} - -#[derive(Default, Clone)] -pub struct TestClientRecorder { - messages_shown: Arc>>, - diagnostics_for_each_file: Arc>>>, -} - -#[async_trait::async_trait] -impl LSPClient for TestClientRecorder { - async fn show_message(&self, message_type: MessageType, message: M) { - self.messages_shown - .lock() - .await - .push((message_type, message.to_string())); - } - - async fn publish_diagnostics( - &self, - url: &str, - diagnostics: Vec, - _other: Option, - ) { - self.diagnostics_for_each_file - .lock() - .await - .insert(url.to_string(), diagnostics); - } -} - -impl TestClientRecorder { - pub async fn messages_shown(&self) -> Vec<(MessageType, String)> { - self.messages_shown.lock().await.clone() - } -} From c06b6bfe9449d6c3fb924a920b741eb0fb67a882 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Thu, 2 Oct 2025 16:10:11 +0200 Subject: [PATCH 11/11] style: derive Default for test helpers --- tests/common.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/common.rs b/tests/common.rs index 6299fa4..b743ea0 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -27,6 +27,12 @@ impl TestClientRecorder { } } +impl Default for TestClientRecorder { + fn default() -> Self { + Self::new() + } +} + #[async_trait::async_trait] impl sysdig_lsp::app::LSPClient for TestClientRecorder { async fn show_message( @@ -127,3 +133,9 @@ impl TestSetup { } } } + +impl Default for TestSetup { + fn default() -> Self { + Self::new() + } +}