1use crate::core::{EditorDocument, Position, Result};
11use crate::extensions::{
12 EditorExtension, ExtensionCapability, ExtensionCommand, ExtensionContext, ExtensionInfo,
13 ExtensionResult, ExtensionState, MessageLevel,
14};
15use ass_core::parser::{Script, Section};
16
17#[cfg(not(feature = "std"))]
18use alloc::{
19 collections::BTreeMap as HashMap,
20 format,
21 string::{String, ToString},
22 vec,
23 vec::Vec,
24};
25#[cfg(feature = "std")]
26use std::collections::HashMap;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CompletionType {
31 Section,
33 Field,
35 Value,
37 StyleRef,
39 Tag,
41 TagParam,
43 Color,
45 Time,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CompletionItem {
52 pub insert_text: String,
54 pub label: String,
56 pub completion_type: CompletionType,
58 pub description: Option<String>,
60 pub detail: Option<String>,
62 pub sort_order: u32,
64}
65
66impl CompletionItem {
67 pub fn new(insert_text: String, label: String, completion_type: CompletionType) -> Self {
69 Self {
70 insert_text,
71 label,
72 completion_type,
73 description: None,
74 detail: None,
75 sort_order: 999,
76 }
77 }
78
79 pub fn with_description(mut self, description: String) -> Self {
81 self.description = Some(description);
82 self
83 }
84
85 pub fn with_detail(mut self, detail: String) -> Self {
87 self.detail = Some(detail);
88 self
89 }
90
91 pub fn with_sort_order(mut self, order: u32) -> Self {
93 self.sort_order = order;
94 self
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct CompletionContext {
101 pub line: String,
103 pub column: usize,
105 pub section: Option<String>,
107 pub in_override_tag: bool,
109 pub current_tag: Option<String>,
111}
112
113pub struct AutoCompleteExtension {
115 info: ExtensionInfo,
116 state: ExtensionState,
117 style_names: Vec<String>,
119 config: AutoCompleteConfig,
121}
122
123#[derive(Debug, Clone)]
125pub struct AutoCompleteConfig {
126 pub complete_fields: bool,
128 pub complete_styles: bool,
130 pub complete_tags: bool,
132 pub complete_values: bool,
134 pub max_suggestions: usize,
136 pub min_chars: usize,
138}
139
140impl Default for AutoCompleteConfig {
141 fn default() -> Self {
142 Self {
143 complete_fields: true,
144 complete_styles: true,
145 complete_tags: true,
146 complete_values: true,
147 max_suggestions: 20,
148 min_chars: 1,
149 }
150 }
151}
152
153impl AutoCompleteExtension {
154 pub fn new() -> Self {
156 let info = ExtensionInfo::new(
157 "auto-complete".to_string(),
158 "1.0.0".to_string(),
159 "ASS-RS Team".to_string(),
160 "Built-in auto-completion for ASS/SSA files".to_string(),
161 )
162 .with_capability(ExtensionCapability::CodeCompletion)
163 .with_license("MIT".to_string());
164
165 Self {
166 info,
167 state: ExtensionState::Uninitialized,
168 style_names: Vec::new(),
169 config: AutoCompleteConfig::default(),
170 }
171 }
172
173 pub fn get_completions(
175 &mut self,
176 document: &EditorDocument,
177 position: Position,
178 ) -> Result<Vec<CompletionItem>> {
179 self.update_style_names(document)?;
181
182 let context = self.get_completion_context(document, position)?;
184
185 let mut completions = Vec::new();
187
188 if context.line.is_empty() || context.line.starts_with('[') {
190 completions.extend(self.get_section_completions(&context));
191 }
192
193 if let Some(ref section) = context.section {
195 if !context.in_override_tag && self.config.complete_fields {
196 completions.extend(self.get_field_completions(section, &context));
197 }
198 }
199
200 if context.in_override_tag && self.config.complete_tags {
202 completions.extend(self.get_tag_completions(&context));
203 }
204
205 if self.should_complete_style(&context) && self.config.complete_styles {
207 completions.extend(self.get_style_completions(&context));
208 }
209
210 completions.sort_by_key(|c| c.sort_order);
212 completions.truncate(self.config.max_suggestions);
213
214 Ok(completions)
215 }
216
217 fn update_style_names(&mut self, document: &EditorDocument) -> Result<()> {
219 self.style_names.clear();
220
221 if let Ok(script) = Script::parse(&document.text()) {
222 for section in script.sections() {
223 if let Section::Styles(styles) = section {
224 for style in styles {
225 self.style_names.push(style.name.to_string());
226 }
227 }
228 }
229 }
230
231 Ok(())
232 }
233
234 fn get_completion_context(
236 &self,
237 document: &EditorDocument,
238 position: Position,
239 ) -> Result<CompletionContext> {
240 let content = document.text();
241 let offset = position.offset;
242
243 let line_start = content[..offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
245 let line_end = content[offset..]
246 .find('\n')
247 .map(|p| offset + p)
248 .unwrap_or(content.len());
249
250 let line = content[line_start..line_end].to_string();
251 let column = offset - line_start;
252
253 let mut current_section = None;
255 for line in content[..line_start].lines().rev() {
256 if line.starts_with('[') && line.ends_with(']') {
257 current_section = Some(line[1..line.len() - 1].to_string());
258 break;
259 }
260 }
261
262 let before_cursor = &line[..column.min(line.len())];
264 let in_override_tag = before_cursor
265 .rfind('{')
266 .is_some_and(|open| before_cursor[open..].find('}').is_none());
267
268 let current_tag = if in_override_tag {
270 before_cursor.rfind('{').and_then(|pos| {
271 let tag_text = &before_cursor[pos + 1..];
272 tag_text.rfind('\\').map(|slash| {
273 let tag_start = &tag_text[slash + 1..];
274 tag_start
275 .find(|c: char| !c.is_alphanumeric())
276 .map(|end| tag_start[..end].to_string())
277 .unwrap_or_else(|| tag_start.to_string())
278 })
279 })
280 } else {
281 None
282 };
283
284 Ok(CompletionContext {
285 line,
286 column,
287 section: current_section,
288 in_override_tag,
289 current_tag,
290 })
291 }
292
293 fn get_section_completions(&self, context: &CompletionContext) -> Vec<CompletionItem> {
295 let sections = vec![
296 ("[Script Info]", "Script metadata and properties"),
297 ("[V4+ Styles]", "Style definitions for V4+ format"),
298 ("[V4 Styles]", "Style definitions for V4 format"),
299 ("[Events]", "Dialogue and comment events"),
300 ("[Fonts]", "Embedded font data"),
301 ("[Graphics]", "Embedded graphics data"),
302 ];
303
304 let prefix = if context.line.starts_with('[') {
305 &context.line[1..context.column.min(context.line.len())]
306 } else {
307 ""
308 };
309
310 sections
311 .into_iter()
312 .filter(|(name, _)| {
313 if prefix.is_empty() {
314 true
315 } else {
316 name[1..].to_lowercase().starts_with(&prefix.to_lowercase())
317 }
318 })
319 .enumerate()
320 .map(|(i, (name, desc))| {
321 CompletionItem::new(name.to_string(), name.to_string(), CompletionType::Section)
322 .with_description(desc.to_string())
323 .with_sort_order(i as u32)
324 })
325 .collect()
326 }
327
328 fn get_field_completions(
330 &self,
331 section: &str,
332 context: &CompletionContext,
333 ) -> Vec<CompletionItem> {
334 let fields = match section {
335 "Script Info" => vec![
336 ("Title:", "Script title"),
337 ("Original Script:", "Original author"),
338 ("Original Translation:", "Original translator"),
339 ("Original Editing:", "Original editor"),
340 ("Original Timing:", "Original timer"),
341 ("Synch Point:", "Synchronization point"),
342 ("Script Updated By:", "Last editor"),
343 ("Update Details:", "Update description"),
344 ("ScriptType:", "Script type (usually v4.00+)"),
345 ("Collisions:", "Collision handling (Normal/Reverse)"),
346 ("PlayResX:", "Playback X resolution"),
347 ("PlayResY:", "Playback Y resolution"),
348 ("PlayDepth:", "Color depth"),
349 ("Timer:", "Timer speed percentage"),
350 ("WrapStyle:", "Line wrapping style (0-3)"),
351 (
352 "ScaledBorderAndShadow:",
353 "Scale borders with video (yes/no)",
354 ),
355 ("YCbCr Matrix:", "Color matrix"),
356 ],
357 "V4+ Styles" | "V4 Styles" => vec![
358 ("Format:", "Column format definition"),
359 ("Style:", "Style definition"),
360 ],
361 "Events" => vec![
362 ("Format:", "Column format definition"),
363 ("Dialogue:", "Dialogue event"),
364 ("Comment:", "Comment event"),
365 ("Picture:", "Picture event"),
366 ("Sound:", "Sound event"),
367 ("Movie:", "Movie event"),
368 ("Command:", "Command event"),
369 ],
370 _ => vec![],
371 };
372
373 let prefix = context.line.trim_start();
374
375 fields
376 .into_iter()
377 .filter(|(name, _)| {
378 prefix.is_empty() || name.to_lowercase().starts_with(&prefix.to_lowercase())
379 })
380 .enumerate()
381 .map(|(i, (name, desc))| {
382 CompletionItem::new(name.to_string(), name.to_string(), CompletionType::Field)
383 .with_description(desc.to_string())
384 .with_sort_order(i as u32)
385 })
386 .collect()
387 }
388
389 fn get_tag_completions(&self, context: &CompletionContext) -> Vec<CompletionItem> {
391 let tags = vec![
392 ("\\b", "Bold (0/1 or weight)", "\\b1"),
393 ("\\i", "Italic (0/1)", "\\i1"),
394 ("\\u", "Underline (0/1)", "\\u1"),
395 ("\\s", "Strikeout (0/1)", "\\s1"),
396 ("\\bord", "Border width", "\\bord2"),
397 ("\\shad", "Shadow distance", "\\shad2"),
398 ("\\be", "Blur edges", "\\be1"),
399 ("\\fn", "Font name", "\\fnArial"),
400 ("\\fs", "Font size", "\\fs20"),
401 ("\\fscx", "Font X scale %", "\\fscx100"),
402 ("\\fscy", "Font Y scale %", "\\fscy100"),
403 ("\\fsp", "Font spacing", "\\fsp0"),
404 ("\\frx", "X rotation", "\\frx0"),
405 ("\\fry", "Y rotation", "\\fry0"),
406 ("\\frz", "Z rotation", "\\frz0"),
407 ("\\fr", "Z rotation (legacy)", "\\fr0"),
408 ("\\fax", "X shear", "\\fax0"),
409 ("\\fay", "Y shear", "\\fay0"),
410 ("\\c", "Primary color", "\\c&H0000FF&"),
411 ("\\1c", "Primary color", "\\1c&H0000FF&"),
412 ("\\2c", "Secondary color", "\\2c&H00FF00&"),
413 ("\\3c", "Outline color", "\\3c&HFF0000&"),
414 ("\\4c", "Shadow color", "\\4c&H000000&"),
415 ("\\alpha", "Overall alpha", "\\alpha&H00&"),
416 ("\\1a", "Primary alpha", "\\1a&H00&"),
417 ("\\2a", "Secondary alpha", "\\2a&H00&"),
418 ("\\3a", "Outline alpha", "\\3a&H00&"),
419 ("\\4a", "Shadow alpha", "\\4a&H00&"),
420 ("\\an", "Alignment (numpad)", "\\an5"),
421 ("\\a", "Alignment (legacy)", "\\a2"),
422 ("\\k", "Karaoke duration", "\\k100"),
423 ("\\kf", "Karaoke fill", "\\kf100"),
424 ("\\ko", "Karaoke outline", "\\ko100"),
425 ("\\K", "Karaoke sweep", "\\K100"),
426 ("\\q", "Wrap style", "\\q2"),
427 ("\\r", "Reset to style", "\\r"),
428 ("\\pos", "Position", "\\pos(640,360)"),
429 ("\\move", "Movement", "\\move(0,0,100,100)"),
430 ("\\org", "Rotation origin", "\\org(640,360)"),
431 ("\\fad", "Fade in/out", "\\fad(200,200)"),
432 ("\\fade", "Complex fade", "\\fade(255,0,0,0,1000,2000,3000)"),
433 ("\\t", "Animation", "\\t(\\fs30)"),
434 ("\\clip", "Clipping rectangle", "\\clip(0,0,100,100)"),
435 ("\\iclip", "Inverse clip", "\\iclip(0,0,100,100)"),
436 ("\\p", "Drawing mode", "\\p1"),
437 ("\\pbo", "Baseline offset", "\\pbo0"),
438 ];
439
440 let prefix = if let Some(ref tag) = context.current_tag {
441 tag
442 } else {
443 context.line[..context.column]
445 .rfind('\\')
446 .map(|pos| &context.line[pos + 1..context.column])
447 .unwrap_or("")
448 };
449
450 tags.into_iter()
451 .filter(|(name, _, _)| {
452 if prefix.is_empty() {
453 true
454 } else {
455 name[1..].starts_with(prefix)
456 }
457 })
458 .enumerate()
459 .map(|(i, (name, desc, example))| {
460 CompletionItem::new(example.to_string(), name.to_string(), CompletionType::Tag)
461 .with_description(desc.to_string())
462 .with_detail(example.to_string())
463 .with_sort_order(i as u32)
464 })
465 .collect()
466 }
467
468 fn get_style_completions(&self, _context: &CompletionContext) -> Vec<CompletionItem> {
470 self.style_names
471 .iter()
472 .enumerate()
473 .map(|(i, name)| {
474 CompletionItem::new(name.clone(), name.clone(), CompletionType::StyleRef)
475 .with_description("Style reference".to_string())
476 .with_sort_order(i as u32)
477 })
478 .collect()
479 }
480
481 fn should_complete_style(&self, context: &CompletionContext) -> bool {
483 if let Some(ref section) = context.section {
484 if section == "Events" {
485 let line = context.line.trim_start();
487 if line.starts_with("Dialogue:") || line.starts_with("Comment:") {
488 let before_cursor = &context.line[..context.column];
490 let comma_count = before_cursor.matches(',').count();
491 return comma_count == 3;
493 }
494 }
495 }
496 false
497 }
498}
499
500impl Default for AutoCompleteExtension {
501 fn default() -> Self {
502 Self::new()
503 }
504}
505
506impl EditorExtension for AutoCompleteExtension {
507 fn info(&self) -> &ExtensionInfo {
508 &self.info
509 }
510
511 fn initialize(&mut self, context: &mut dyn ExtensionContext) -> Result<()> {
512 self.state = ExtensionState::Active;
513
514 if let Some(fields) = context.get_config("autocomplete.complete_fields") {
516 self.config.complete_fields = fields == "true";
517 }
518 if let Some(styles) = context.get_config("autocomplete.complete_styles") {
519 self.config.complete_styles = styles == "true";
520 }
521 if let Some(tags) = context.get_config("autocomplete.complete_tags") {
522 self.config.complete_tags = tags == "true";
523 }
524 if let Some(values) = context.get_config("autocomplete.complete_values") {
525 self.config.complete_values = values == "true";
526 }
527 if let Some(max) = context.get_config("autocomplete.max_suggestions") {
528 if let Ok(max_val) = max.parse() {
529 self.config.max_suggestions = max_val;
530 }
531 }
532
533 context.show_message("Auto-completion initialized", MessageLevel::Info)?;
534 Ok(())
535 }
536
537 fn shutdown(&mut self, _context: &mut dyn ExtensionContext) -> Result<()> {
538 self.state = ExtensionState::Shutdown;
539 self.style_names.clear();
540 Ok(())
541 }
542
543 fn state(&self) -> ExtensionState {
544 self.state
545 }
546
547 fn execute_command(
548 &mut self,
549 command_id: &str,
550 args: &HashMap<String, String>,
551 context: &mut dyn ExtensionContext,
552 ) -> Result<ExtensionResult> {
553 match command_id {
554 "autocomplete.trigger" => {
555 if let Some(doc) = context.current_document() {
556 let position = if let Some(offset_str) = args.get("position") {
558 if let Ok(offset) = offset_str.parse() {
559 Position::new(offset)
560 } else {
561 Position::new(doc.len_bytes())
562 }
563 } else {
564 Position::new(doc.len_bytes())
565 };
566
567 let completions = self.get_completions(doc, position)?;
568 let mut result = ExtensionResult::success_with_message(format!(
569 "Found {} completions",
570 completions.len()
571 ));
572
573 for (i, completion) in completions.iter().take(10).enumerate() {
575 result
576 .data
577 .insert(format!("completion_{i}"), completion.insert_text.clone());
578 }
579
580 Ok(result)
581 } else {
582 Ok(ExtensionResult::failure("No active document".to_string()))
583 }
584 }
585 "autocomplete.update_styles" => {
586 if let Some(doc) = context.current_document() {
587 self.update_style_names(doc)?;
588 Ok(ExtensionResult::success_with_message(format!(
589 "Updated {} style names",
590 self.style_names.len()
591 )))
592 } else {
593 Ok(ExtensionResult::failure("No active document".to_string()))
594 }
595 }
596 _ => Ok(ExtensionResult::failure(format!(
597 "Unknown command: {command_id}"
598 ))),
599 }
600 }
601
602 fn commands(&self) -> Vec<ExtensionCommand> {
603 vec![
604 ExtensionCommand::new(
605 "autocomplete.trigger".to_string(),
606 "Trigger Completion".to_string(),
607 "Get completion suggestions at cursor position".to_string(),
608 )
609 .with_category("Completion".to_string()),
610 ExtensionCommand::new(
611 "autocomplete.update_styles".to_string(),
612 "Update Style Names".to_string(),
613 "Update known style names from document".to_string(),
614 )
615 .with_category("Completion".to_string()),
616 ]
617 }
618
619 fn config_schema(&self) -> HashMap<String, String> {
620 let mut schema = HashMap::new();
621 schema.insert(
622 "autocomplete.complete_fields".to_string(),
623 "boolean".to_string(),
624 );
625 schema.insert(
626 "autocomplete.complete_styles".to_string(),
627 "boolean".to_string(),
628 );
629 schema.insert(
630 "autocomplete.complete_tags".to_string(),
631 "boolean".to_string(),
632 );
633 schema.insert(
634 "autocomplete.complete_values".to_string(),
635 "boolean".to_string(),
636 );
637 schema.insert(
638 "autocomplete.max_suggestions".to_string(),
639 "number".to_string(),
640 );
641 schema.insert("autocomplete.min_chars".to_string(), "number".to_string());
642 schema
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 #[cfg(not(feature = "std"))]
650 use alloc::string::ToString;
651
652 #[test]
653 fn test_completion_item() {
654 let item = CompletionItem::new(
655 "\\pos(100,200)".to_string(),
656 "\\pos".to_string(),
657 CompletionType::Tag,
658 )
659 .with_description("Position tag".to_string())
660 .with_sort_order(1);
661
662 assert_eq!(item.insert_text, "\\pos(100,200)");
663 assert_eq!(item.label, "\\pos");
664 assert_eq!(item.sort_order, 1);
665 }
666
667 #[test]
668 fn test_auto_complete_extension_creation() {
669 let ext = AutoCompleteExtension::new();
670 assert_eq!(ext.info().name, "auto-complete");
671 assert!(ext
672 .info()
673 .has_capability(&ExtensionCapability::CodeCompletion));
674 }
675
676 #[test]
677 fn test_section_completions() {
678 let ext = AutoCompleteExtension::new();
679 let context = CompletionContext {
680 line: "[Scr".to_string(),
681 column: 4,
682 section: None,
683 in_override_tag: false,
684 current_tag: None,
685 };
686
687 let completions = ext.get_section_completions(&context);
688 assert!(!completions.is_empty());
689 assert!(completions.iter().any(|c| c.label == "[Script Info]"));
690 }
691
692 #[test]
693 fn test_field_completions() {
694 let ext = AutoCompleteExtension::new();
695 let context = CompletionContext {
696 line: "Ti".to_string(),
697 column: 2,
698 section: Some("Script Info".to_string()),
699 in_override_tag: false,
700 current_tag: None,
701 };
702
703 let completions = ext.get_field_completions("Script Info", &context);
704 assert!(!completions.is_empty());
705 assert!(completions.iter().any(|c| c.label == "Title:"));
706 }
707
708 #[test]
709 fn test_tag_completions() {
710 let ext = AutoCompleteExtension::new();
711 let context = CompletionContext {
712 line: "{\\po".to_string(),
713 column: 4,
714 section: Some("Events".to_string()),
715 in_override_tag: true,
716 current_tag: Some("po".to_string()),
717 };
718
719 let completions = ext.get_tag_completions(&context);
720 assert!(!completions.is_empty());
721 assert!(completions.iter().any(|c| c.label == "\\pos"));
722 }
723}
724
725#[cfg(test)]
727#[path = "auto_complete_tests.rs"]
728mod extended_tests;