1mod extension_slash_command;
2mod slash_command_registry;
3mod slash_command_working_set;
4
5pub use crate::extension_slash_command::*;
6pub use crate::slash_command_registry::*;
7pub use crate::slash_command_working_set::*;
8use anyhow::Result;
9use futures::StreamExt;
10use futures::stream::{self, BoxStream};
11use gpui::{App, SharedString, Task, WeakEntity, Window};
12use language::CodeLabelBuilder;
13use language::HighlightId;
14use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
15pub use language_model::Role;
16use serde::{Deserialize, Serialize};
17use std::{
18 ops::Range,
19 sync::{Arc, atomic::AtomicBool},
20};
21use ui::ActiveTheme;
22use workspace::{Workspace, ui::IconName};
23
24pub fn init(cx: &mut App) {
25 SlashCommandRegistry::default_global(cx);
26 extension_slash_command::init(cx);
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum AfterCompletion {
31 /// Run the command
32 Run,
33 /// Continue composing the current argument, doesn't add a space
34 Compose,
35 /// Continue the command composition, adds a space
36 Continue,
37}
38
39impl From<bool> for AfterCompletion {
40 fn from(value: bool) -> Self {
41 if value {
42 AfterCompletion::Run
43 } else {
44 AfterCompletion::Continue
45 }
46 }
47}
48
49impl AfterCompletion {
50 pub const fn run(&self) -> bool {
51 match self {
52 AfterCompletion::Run => true,
53 AfterCompletion::Compose | AfterCompletion::Continue => false,
54 }
55 }
56}
57
58#[derive(Debug)]
59pub struct ArgumentCompletion {
60 /// The label to display for this completion.
61 pub label: CodeLabel,
62 /// The new text that should be inserted into the command when this completion is accepted.
63 pub new_text: String,
64 /// Whether the command should be run when accepting this completion.
65 pub after_completion: AfterCompletion,
66 /// Whether to replace the all arguments, or whether to treat this as an independent argument.
67 pub replace_previous_arguments: bool,
68}
69
70pub type SlashCommandResult = Result<BoxStream<'static, Result<SlashCommandEvent>>>;
71
72pub trait SlashCommand: 'static + Send + Sync {
73 fn name(&self) -> String;
74 fn icon(&self) -> IconName {
75 IconName::Slash
76 }
77 fn label(&self, _cx: &App) -> CodeLabel {
78 CodeLabel::plain(self.name(), None)
79 }
80 fn description(&self) -> String;
81 fn menu_text(&self) -> String;
82 fn complete_argument(
83 self: Arc<Self>,
84 arguments: &[String],
85 cancel: Arc<AtomicBool>,
86 workspace: Option<WeakEntity<Workspace>>,
87 window: &mut Window,
88 cx: &mut App,
89 ) -> Task<Result<Vec<ArgumentCompletion>>>;
90 fn requires_argument(&self) -> bool;
91 fn accepts_arguments(&self) -> bool {
92 self.requires_argument()
93 }
94 fn run(
95 self: Arc<Self>,
96 arguments: &[String],
97 context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
98 context_buffer: BufferSnapshot,
99 workspace: WeakEntity<Workspace>,
100 // TODO: We're just using the `LspAdapterDelegate` here because that is
101 // what the extension API is already expecting.
102 //
103 // It may be that `LspAdapterDelegate` needs a more general name, or
104 // perhaps another kind of delegate is needed here.
105 delegate: Option<Arc<dyn LspAdapterDelegate>>,
106 window: &mut Window,
107 cx: &mut App,
108 ) -> Task<SlashCommandResult>;
109}
110
111#[derive(Debug, PartialEq)]
112pub enum SlashCommandContent {
113 Text {
114 text: String,
115 run_commands_in_text: bool,
116 },
117}
118
119impl<'a> From<&'a str> for SlashCommandContent {
120 fn from(text: &'a str) -> Self {
121 Self::Text {
122 text: text.into(),
123 run_commands_in_text: false,
124 }
125 }
126}
127
128#[derive(Debug, PartialEq)]
129pub enum SlashCommandEvent {
130 StartMessage {
131 role: Role,
132 merge_same_roles: bool,
133 },
134 StartSection {
135 icon: IconName,
136 label: SharedString,
137 metadata: Option<serde_json::Value>,
138 },
139 Content(SlashCommandContent),
140 EndSection,
141}
142
143#[derive(Debug, Default, PartialEq, Clone)]
144pub struct SlashCommandOutput {
145 pub text: String,
146 pub sections: Vec<SlashCommandOutputSection<usize>>,
147 pub run_commands_in_text: bool,
148}
149
150impl SlashCommandOutput {
151 pub fn ensure_valid_section_ranges(&mut self) {
152 for section in &mut self.sections {
153 section.range.start = section.range.start.min(self.text.len());
154 section.range.end = section.range.end.min(self.text.len());
155 while !self.text.is_char_boundary(section.range.start) {
156 section.range.start -= 1;
157 }
158 while !self.text.is_char_boundary(section.range.end) {
159 section.range.end += 1;
160 }
161 }
162 }
163
164 /// Returns this [`SlashCommandOutput`] as a stream of [`SlashCommandEvent`]s.
165 pub fn into_event_stream(mut self) -> BoxStream<'static, Result<SlashCommandEvent>> {
166 self.ensure_valid_section_ranges();
167
168 let mut events = Vec::new();
169
170 let mut section_endpoints = Vec::new();
171 for section in self.sections {
172 section_endpoints.push((
173 section.range.start,
174 SlashCommandEvent::StartSection {
175 icon: section.icon,
176 label: section.label,
177 metadata: section.metadata,
178 },
179 ));
180 section_endpoints.push((section.range.end, SlashCommandEvent::EndSection));
181 }
182 section_endpoints.sort_by_key(|(offset, _)| *offset);
183
184 let mut content_offset = 0;
185 for (endpoint_offset, endpoint) in section_endpoints {
186 if content_offset < endpoint_offset {
187 events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
188 text: self.text[content_offset..endpoint_offset].to_string(),
189 run_commands_in_text: self.run_commands_in_text,
190 })));
191 content_offset = endpoint_offset;
192 }
193
194 events.push(Ok(endpoint));
195 }
196
197 if content_offset < self.text.len() {
198 events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
199 text: self.text[content_offset..].to_string(),
200 run_commands_in_text: self.run_commands_in_text,
201 })));
202 }
203
204 stream::iter(events).boxed()
205 }
206
207 pub async fn from_event_stream(
208 mut events: BoxStream<'static, Result<SlashCommandEvent>>,
209 ) -> Result<SlashCommandOutput> {
210 let mut output = SlashCommandOutput::default();
211 let mut section_stack = Vec::new();
212
213 while let Some(event) = events.next().await {
214 match event? {
215 SlashCommandEvent::StartSection {
216 icon,
217 label,
218 metadata,
219 } => {
220 let start = output.text.len();
221 section_stack.push(SlashCommandOutputSection {
222 range: start..start,
223 icon,
224 label,
225 metadata,
226 });
227 }
228 SlashCommandEvent::Content(SlashCommandContent::Text {
229 text,
230 run_commands_in_text,
231 }) => {
232 output.text.push_str(&text);
233 output.run_commands_in_text = run_commands_in_text;
234
235 if let Some(section) = section_stack.last_mut() {
236 section.range.end = output.text.len();
237 }
238 }
239 SlashCommandEvent::EndSection => {
240 if let Some(section) = section_stack.pop() {
241 output.sections.push(section);
242 }
243 }
244 SlashCommandEvent::StartMessage { .. } => {}
245 }
246 }
247
248 while let Some(section) = section_stack.pop() {
249 output.sections.push(section);
250 }
251
252 Ok(output)
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
257pub struct SlashCommandOutputSection<T> {
258 pub range: Range<T>,
259 pub icon: IconName,
260 pub label: SharedString,
261 pub metadata: Option<serde_json::Value>,
262}
263
264impl SlashCommandOutputSection<language::Anchor> {
265 pub fn is_valid(&self, buffer: &language::TextBuffer) -> bool {
266 self.range.start.is_valid(buffer) && !self.range.to_offset(buffer).is_empty()
267 }
268}
269
270pub struct SlashCommandLine {
271 /// The range within the line containing the command name.
272 pub name: Range<usize>,
273 /// Ranges within the line containing the command arguments.
274 pub arguments: Vec<Range<usize>>,
275}
276
277impl SlashCommandLine {
278 pub fn parse(line: &str) -> Option<Self> {
279 let mut call: Option<Self> = None;
280 let mut ix = 0;
281 for c in line.chars() {
282 let next_ix = ix + c.len_utf8();
283 if let Some(call) = &mut call {
284 // The command arguments start at the first non-whitespace character
285 // after the command name, and continue until the end of the line.
286 if let Some(argument) = call.arguments.last_mut() {
287 if c.is_whitespace() {
288 if (*argument).is_empty() {
289 argument.start = next_ix;
290 argument.end = next_ix;
291 } else {
292 argument.end = ix;
293 call.arguments.push(next_ix..next_ix);
294 }
295 } else {
296 argument.end = next_ix;
297 }
298 }
299 // The command name ends at the first whitespace character.
300 else if !call.name.is_empty() {
301 if c.is_whitespace() {
302 call.arguments = vec![next_ix..next_ix];
303 } else {
304 call.name.end = next_ix;
305 }
306 }
307 // The command name must begin with a letter.
308 else if c.is_alphabetic() {
309 call.name.end = next_ix;
310 } else {
311 return None;
312 }
313 }
314 // Commands start with a slash.
315 else if c == '/' {
316 call = Some(SlashCommandLine {
317 name: next_ix..next_ix,
318 arguments: Vec::new(),
319 });
320 }
321 // The line can't contain anything before the slash except for whitespace.
322 else if !c.is_whitespace() {
323 return None;
324 }
325 ix = next_ix;
326 }
327 call
328 }
329}
330
331pub fn create_label_for_command(command_name: &str, arguments: &[&str], cx: &App) -> CodeLabel {
332 let mut label = CodeLabelBuilder::default();
333 label.push_str(command_name, None);
334 label.respan_filter_range(None);
335 label.push_str(" ", None);
336 label.push_str(
337 &arguments.join(" "),
338 cx.theme().syntax().highlight_id("comment").map(HighlightId),
339 );
340 label.build()
341}
342
343#[cfg(test)]
344mod tests {
345 use pretty_assertions::assert_eq;
346 use serde_json::json;
347
348 use super::*;
349
350 #[gpui::test]
351 async fn test_slash_command_output_to_events_round_trip() {
352 // Test basic output consisting of a single section.
353 {
354 let text = "Hello, world!".to_string();
355 let range = 0..text.len();
356 let output = SlashCommandOutput {
357 text,
358 sections: vec![SlashCommandOutputSection {
359 range,
360 icon: IconName::Code,
361 label: "Section 1".into(),
362 metadata: None,
363 }],
364 run_commands_in_text: false,
365 };
366
367 let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
368 let events = events
369 .into_iter()
370 .filter_map(|event| event.ok())
371 .collect::<Vec<_>>();
372
373 assert_eq!(
374 events,
375 vec![
376 SlashCommandEvent::StartSection {
377 icon: IconName::Code,
378 label: "Section 1".into(),
379 metadata: None
380 },
381 SlashCommandEvent::Content(SlashCommandContent::Text {
382 text: "Hello, world!".into(),
383 run_commands_in_text: false
384 }),
385 SlashCommandEvent::EndSection
386 ]
387 );
388
389 let new_output =
390 SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
391 .await
392 .unwrap();
393
394 assert_eq!(new_output, output);
395 }
396
397 // Test output where the sections do not comprise all of the text.
398 {
399 let text = "Apple\nCucumber\nBanana\n".to_string();
400 let output = SlashCommandOutput {
401 text,
402 sections: vec![
403 SlashCommandOutputSection {
404 range: 0..6,
405 icon: IconName::Check,
406 label: "Fruit".into(),
407 metadata: None,
408 },
409 SlashCommandOutputSection {
410 range: 15..22,
411 icon: IconName::Check,
412 label: "Fruit".into(),
413 metadata: None,
414 },
415 ],
416 run_commands_in_text: false,
417 };
418
419 let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
420 let events = events
421 .into_iter()
422 .filter_map(|event| event.ok())
423 .collect::<Vec<_>>();
424
425 assert_eq!(
426 events,
427 vec![
428 SlashCommandEvent::StartSection {
429 icon: IconName::Check,
430 label: "Fruit".into(),
431 metadata: None
432 },
433 SlashCommandEvent::Content(SlashCommandContent::Text {
434 text: "Apple\n".into(),
435 run_commands_in_text: false
436 }),
437 SlashCommandEvent::EndSection,
438 SlashCommandEvent::Content(SlashCommandContent::Text {
439 text: "Cucumber\n".into(),
440 run_commands_in_text: false
441 }),
442 SlashCommandEvent::StartSection {
443 icon: IconName::Check,
444 label: "Fruit".into(),
445 metadata: None
446 },
447 SlashCommandEvent::Content(SlashCommandContent::Text {
448 text: "Banana\n".into(),
449 run_commands_in_text: false
450 }),
451 SlashCommandEvent::EndSection
452 ]
453 );
454
455 let new_output =
456 SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
457 .await
458 .unwrap();
459
460 assert_eq!(new_output, output);
461 }
462
463 // Test output consisting of multiple sections.
464 {
465 let text = "Line 1\nLine 2\nLine 3\nLine 4\n".to_string();
466 let output = SlashCommandOutput {
467 text,
468 sections: vec![
469 SlashCommandOutputSection {
470 range: 0..6,
471 icon: IconName::FileCode,
472 label: "Section 1".into(),
473 metadata: Some(json!({ "a": true })),
474 },
475 SlashCommandOutputSection {
476 range: 7..13,
477 icon: IconName::FileDoc,
478 label: "Section 2".into(),
479 metadata: Some(json!({ "b": true })),
480 },
481 SlashCommandOutputSection {
482 range: 14..20,
483 icon: IconName::FileGit,
484 label: "Section 3".into(),
485 metadata: Some(json!({ "c": true })),
486 },
487 SlashCommandOutputSection {
488 range: 21..27,
489 icon: IconName::FileToml,
490 label: "Section 4".into(),
491 metadata: Some(json!({ "d": true })),
492 },
493 ],
494 run_commands_in_text: false,
495 };
496
497 let events = output.clone().into_event_stream().collect::<Vec<_>>().await;
498 let events = events
499 .into_iter()
500 .filter_map(|event| event.ok())
501 .collect::<Vec<_>>();
502
503 assert_eq!(
504 events,
505 vec![
506 SlashCommandEvent::StartSection {
507 icon: IconName::FileCode,
508 label: "Section 1".into(),
509 metadata: Some(json!({ "a": true }))
510 },
511 SlashCommandEvent::Content(SlashCommandContent::Text {
512 text: "Line 1".into(),
513 run_commands_in_text: false
514 }),
515 SlashCommandEvent::EndSection,
516 SlashCommandEvent::Content(SlashCommandContent::Text {
517 text: "\n".into(),
518 run_commands_in_text: false
519 }),
520 SlashCommandEvent::StartSection {
521 icon: IconName::FileDoc,
522 label: "Section 2".into(),
523 metadata: Some(json!({ "b": true }))
524 },
525 SlashCommandEvent::Content(SlashCommandContent::Text {
526 text: "Line 2".into(),
527 run_commands_in_text: false
528 }),
529 SlashCommandEvent::EndSection,
530 SlashCommandEvent::Content(SlashCommandContent::Text {
531 text: "\n".into(),
532 run_commands_in_text: false
533 }),
534 SlashCommandEvent::StartSection {
535 icon: IconName::FileGit,
536 label: "Section 3".into(),
537 metadata: Some(json!({ "c": true }))
538 },
539 SlashCommandEvent::Content(SlashCommandContent::Text {
540 text: "Line 3".into(),
541 run_commands_in_text: false
542 }),
543 SlashCommandEvent::EndSection,
544 SlashCommandEvent::Content(SlashCommandContent::Text {
545 text: "\n".into(),
546 run_commands_in_text: false
547 }),
548 SlashCommandEvent::StartSection {
549 icon: IconName::FileToml,
550 label: "Section 4".into(),
551 metadata: Some(json!({ "d": true }))
552 },
553 SlashCommandEvent::Content(SlashCommandContent::Text {
554 text: "Line 4".into(),
555 run_commands_in_text: false
556 }),
557 SlashCommandEvent::EndSection,
558 SlashCommandEvent::Content(SlashCommandContent::Text {
559 text: "\n".into(),
560 run_commands_in_text: false
561 }),
562 ]
563 );
564
565 let new_output =
566 SlashCommandOutput::from_event_stream(output.clone().into_event_stream())
567 .await
568 .unwrap();
569
570 assert_eq!(new_output, output);
571 }
572 }
573}