Skip to content

Commit c8de783

Browse files
committed
feat: add code actions and code lens for docker-compose
1 parent 2ff22cb commit c8de783

File tree

5 files changed

+243
-80
lines changed

5 files changed

+243
-80
lines changed

src/app/lsp_server.rs

Lines changed: 115 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use super::commands::CommandExecutor;
1919
use super::component_factory::{ComponentFactory, Config};
2020
use super::queries::QueryExecutor;
2121
use super::{InMemoryDocumentDatabase, LSPClient};
22+
use crate::infra::parse_compose_file;
2223

2324
pub struct LSPServer<C> {
2425
command_executor: CommandExecutor<C>,
@@ -83,6 +84,94 @@ impl TryFrom<&str> for SupportedCommands {
8384
}
8485
}
8586

87+
struct CommandInfo {
88+
title: String,
89+
command: String,
90+
arguments: Option<Vec<Value>>,
91+
range: Range,
92+
}
93+
94+
impl<C> LSPServer<C>
95+
where
96+
C: LSPClient + Send + Sync + 'static,
97+
{
98+
fn generate_commands_for_uri(
99+
&self,
100+
uri: &tower_lsp::lsp_types::Url,
101+
content: &str,
102+
) -> Vec<CommandInfo> {
103+
let file_uri = uri.as_str();
104+
105+
if file_uri.contains("docker-compose.yml")
106+
|| file_uri.contains("compose.yml")
107+
|| file_uri.contains("docker-compose.yaml")
108+
|| file_uri.contains("compose.yaml")
109+
{
110+
self.generate_compose_commands(uri, content)
111+
} else {
112+
self.generate_dockerfile_commands(uri, content)
113+
}
114+
}
115+
116+
fn generate_compose_commands(
117+
&self,
118+
uri: &tower_lsp::lsp_types::Url,
119+
content: &str,
120+
) -> Vec<CommandInfo> {
121+
let mut commands = vec![];
122+
if let Ok(instructions) = parse_compose_file(content) {
123+
for instruction in instructions {
124+
commands.push(CommandInfo {
125+
title: "Scan base image".to_string(),
126+
command: SupportedCommands::ExecuteBaseImageScan.to_string(),
127+
arguments: Some(vec![json!(uri), json!(instruction.range.start.line)]),
128+
range: instruction.range,
129+
});
130+
}
131+
}
132+
commands
133+
}
134+
135+
fn generate_dockerfile_commands(
136+
&self,
137+
uri: &tower_lsp::lsp_types::Url,
138+
content: &str,
139+
) -> Vec<CommandInfo> {
140+
let mut commands = vec![];
141+
if let Some(last_line_starting_with_from_statement) = content
142+
.lines()
143+
.enumerate()
144+
.filter(|(_, line)| line.trim_start().starts_with("FROM "))
145+
.map(|(line_num, _)| line_num)
146+
.last()
147+
{
148+
let range = Range::new(
149+
Position::new(last_line_starting_with_from_statement as u32, 0),
150+
Position::new(last_line_starting_with_from_statement as u32, 0),
151+
);
152+
commands.push(CommandInfo {
153+
title: "Build and scan".to_string(),
154+
command: SupportedCommands::ExecuteBuildAndScan.to_string(),
155+
arguments: Some(vec![
156+
json!(uri),
157+
json!(last_line_starting_with_from_statement),
158+
]),
159+
range,
160+
});
161+
commands.push(CommandInfo {
162+
title: "Scan base image".to_string(),
163+
command: SupportedCommands::ExecuteBaseImageScan.to_string(),
164+
arguments: Some(vec![
165+
json!(uri),
166+
json!(last_line_starting_with_from_statement),
167+
]),
168+
range,
169+
});
170+
}
171+
commands
172+
}
173+
}
174+
86175
#[async_trait::async_trait]
87176
impl<C> LanguageServer for LSPServer<C>
88177
where
@@ -152,8 +241,6 @@ where
152241
}
153242

154243
async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
155-
let mut code_actions = vec![];
156-
157244
let Some(content) = self
158245
.query_executor
159246
.get_document_text(params.text_document.uri.as_str())
@@ -165,48 +252,23 @@ where
165252
)));
166253
};
167254

168-
let Some(last_line_starting_with_from_statement) = content
169-
.lines()
170-
.enumerate()
171-
.filter(|(_, line)| line.trim_start().starts_with("FROM "))
172-
.map(|(line_num, _)| line_num)
173-
.last()
174-
else {
175-
return Ok(None);
176-
};
177-
178-
let Ok(line_selected_as_usize) = usize::try_from(params.range.start.line) else {
179-
return Err(Error::internal_error().with_message(format!(
180-
"unable to parse u32 as usize: {}",
181-
params.range.start.line
182-
)));
183-
};
184-
185-
if last_line_starting_with_from_statement == line_selected_as_usize {
186-
code_actions.push(CodeActionOrCommand::Command(Command {
187-
title: "Build and scan".to_string(),
188-
command: SupportedCommands::ExecuteBuildAndScan.to_string(),
189-
arguments: Some(vec![
190-
json!(params.text_document.uri),
191-
json!(line_selected_as_usize),
192-
]),
193-
}));
194-
code_actions.push(CodeActionOrCommand::Command(Command {
195-
title: "Scan base image".to_string(),
196-
command: SupportedCommands::ExecuteBaseImageScan.to_string(),
197-
arguments: Some(vec![
198-
json!(params.text_document.uri),
199-
json!(line_selected_as_usize),
200-
]),
201-
}));
202-
}
255+
let commands = self.generate_commands_for_uri(&params.text_document.uri, &content);
256+
let code_actions: Vec<CodeActionOrCommand> = commands
257+
.into_iter()
258+
.filter(|cmd| cmd.range.start.line == params.range.start.line)
259+
.map(|cmd| {
260+
CodeActionOrCommand::Command(Command {
261+
title: cmd.title,
262+
command: cmd.command,
263+
arguments: cmd.arguments,
264+
})
265+
})
266+
.collect();
203267

204268
Ok(Some(code_actions))
205269
}
206270

207271
async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
208-
let mut code_lens = vec![];
209-
210272
let Some(content) = self
211273
.query_executor
212274
.get_document_text(params.text_document.uri.as_str())
@@ -218,48 +280,21 @@ where
218280
)));
219281
};
220282

221-
let Some(last_line_starting_with_from_statement) = content
222-
.lines()
223-
.enumerate()
224-
.filter(|(_, line)| line.trim_start().starts_with("FROM "))
225-
.map(|(line_num, _)| line_num)
226-
.last()
227-
else {
228-
return Ok(None);
229-
};
230-
231-
code_lens.push(CodeLens {
232-
range: Range::new(
233-
Position::new(last_line_starting_with_from_statement as u32, 0),
234-
Position::new(last_line_starting_with_from_statement as u32, 0),
235-
),
236-
command: Some(Command {
237-
title: "Build and scan".to_string(),
238-
command: SupportedCommands::ExecuteBuildAndScan.to_string(),
239-
arguments: Some(vec![
240-
json!(params.text_document.uri),
241-
json!(last_line_starting_with_from_statement),
242-
]),
243-
}),
244-
data: None,
245-
});
246-
code_lens.push(CodeLens {
247-
range: Range::new(
248-
Position::new(last_line_starting_with_from_statement as u32, 0),
249-
Position::new(last_line_starting_with_from_statement as u32, 0),
250-
),
251-
command: Some(Command {
252-
title: "Scan base image".to_string(),
253-
command: SupportedCommands::ExecuteBaseImageScan.to_string(),
254-
arguments: Some(vec![
255-
json!(params.text_document.uri),
256-
json!(last_line_starting_with_from_statement),
257-
]),
258-
}),
259-
data: None,
260-
});
283+
let commands = self.generate_commands_for_uri(&params.text_document.uri, &content);
284+
let code_lenses = commands
285+
.into_iter()
286+
.map(|cmd| CodeLens {
287+
range: cmd.range,
288+
command: Some(Command {
289+
title: cmd.title,
290+
command: cmd.command,
291+
arguments: cmd.arguments,
292+
}),
293+
data: None,
294+
})
295+
.collect();
261296

262-
Ok(Some(code_lens))
297+
Ok(Some(code_lenses))
263298
}
264299

265300
async fn execute_command(&self, params: ExecuteCommandParams) -> Result<Option<Value>> {

src/infra/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ mod sysdig_image_scanner_result;
77

88
pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner};
99
pub mod lsp_logger;
10+
pub use compose_ast_parser::{ImageInstruction, parse_compose_file};
1011
pub use docker_image_builder::DockerImageBuilder;
1112
pub use dockerfile_ast_parser::{Instruction, parse_dockerfile};

tests/fixtures/compose.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Test compose file with various YAML features
2+
services:
3+
# Web service using a quoted key for the image
4+
web:
5+
"image": nginx:latest # Inline comment
6+
7+
# Database service using a literal block scalar for the image
8+
db:
9+
image: |
10+
postgres:13
11+
12+
# Another service for good measure
13+
api:
14+
image: my-api:1.0

tests/fixtures/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
services:
2+
web:
3+
image: nginx:latest
4+
db:
5+
image: postgres:13

tests/general.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,111 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont
159159
]
160160
);
161161
}
162+
163+
#[tokio::test]
164+
async fn when_the_client_asks_for_code_lens_in_a_compose_file_it_receives_them() {
165+
let mut client = test::TestClient::new_initialized().await;
166+
client
167+
.open_file_with_contents(
168+
"docker-compose.yml",
169+
include_str!("fixtures/docker-compose.yml"),
170+
)
171+
.await;
172+
173+
let response = client
174+
.request_available_code_lens_in_file("docker-compose.yml")
175+
.await;
176+
177+
assert_eq!(
178+
response.unwrap(),
179+
vec![
180+
CodeLens {
181+
range: Range::new(Position::new(2, 11), Position::new(2, 23)),
182+
command: Some(Command {
183+
title: "Scan base image".to_string(),
184+
command: "sysdig-lsp.execute-scan".to_string(),
185+
arguments: Some(vec![json!("file://docker-compose.yml/"), json!(2)])
186+
}),
187+
data: None
188+
},
189+
CodeLens {
190+
range: Range::new(Position::new(4, 11), Position::new(4, 22)),
191+
command: Some(Command {
192+
title: "Scan base image".to_string(),
193+
command: "sysdig-lsp.execute-scan".to_string(),
194+
arguments: Some(vec![json!("file://docker-compose.yml/"), json!(4)])
195+
}),
196+
data: None
197+
}
198+
]
199+
);
200+
}
201+
202+
#[tokio::test]
203+
async fn when_the_client_asks_for_code_actions_in_a_compose_file_it_receives_them() {
204+
let mut client = test::TestClient::new_initialized().await;
205+
client
206+
.open_file_with_contents(
207+
"docker-compose.yml",
208+
include_str!("fixtures/docker-compose.yml"),
209+
)
210+
.await;
211+
212+
let response = client
213+
.request_available_actions_in_line("docker-compose.yml", 2)
214+
.await;
215+
216+
assert_eq!(
217+
response.unwrap(),
218+
vec![CodeActionOrCommand::Command(Command {
219+
title: "Scan base image".to_string(),
220+
command: "sysdig-lsp.execute-scan".to_string(),
221+
arguments: Some(vec![json!("file://docker-compose.yml/"), json!(2)])
222+
})]
223+
);
224+
}
225+
226+
#[tokio::test]
227+
async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_receives_them() {
228+
let mut client = test::TestClient::new_initialized().await;
229+
client
230+
.open_file_with_contents("compose.yaml", include_str!("fixtures/compose.yaml"))
231+
.await;
232+
233+
let response = client
234+
.request_available_code_lens_in_file("compose.yaml")
235+
.await;
236+
237+
assert_eq!(
238+
response.unwrap(),
239+
vec![
240+
CodeLens {
241+
range: Range::new(Position::new(4, 13), Position::new(4, 25)),
242+
command: Some(Command {
243+
title: "Scan base image".to_string(),
244+
command: "sysdig-lsp.execute-scan".to_string(),
245+
arguments: Some(vec![json!("file://compose.yaml/"), json!(4)])
246+
}),
247+
data: None
248+
},
249+
CodeLens {
250+
range: Range::new(Position::new(9, 6), Position::new(9, 17)),
251+
command: Some(Command {
252+
title: "Scan base image".to_string(),
253+
command: "sysdig-lsp.execute-scan".to_string(),
254+
arguments: Some(vec![json!("file://compose.yaml/"), json!(9)])
255+
}),
256+
data: None
257+
},
258+
CodeLens {
259+
range: Range::new(Position::new(13, 11), Position::new(13, 21)),
260+
command: Some(Command {
261+
title: "Scan base image".to_string(),
262+
command: "sysdig-lsp.execute-scan".to_string(),
263+
arguments: Some(vec![json!("file://compose.yaml/"), json!(13)])
264+
}),
265+
data: None
266+
}
267+
]
268+
);
269+
}

0 commit comments

Comments
 (0)