ass_editor/extensions/builtin/
auto_complete.rs

1//! Built-in auto-completion extension for ASS/SSA files
2//!
3//! Provides intelligent auto-completion for:
4//! - Section names
5//! - Field names based on current section
6//! - Style names when referenced in events
7//! - Override tags and their parameters
8//! - Color codes and common values
9
10use 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/// Type of completion being provided
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CompletionType {
31    /// Section header completion
32    Section,
33    /// Field name completion
34    Field,
35    /// Field value completion
36    Value,
37    /// Style name reference
38    StyleRef,
39    /// Override tag
40    Tag,
41    /// Tag parameter
42    TagParam,
43    /// Color value
44    Color,
45    /// Time code
46    Time,
47}
48
49/// A single completion suggestion
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CompletionItem {
52    /// Text to insert
53    pub insert_text: String,
54    /// Display label
55    pub label: String,
56    /// Type of completion
57    pub completion_type: CompletionType,
58    /// Description of the item
59    pub description: Option<String>,
60    /// Additional details
61    pub detail: Option<String>,
62    /// Sort priority (lower = higher priority)
63    pub sort_order: u32,
64}
65
66impl CompletionItem {
67    /// Create a new completion item
68    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    /// Set description
80    pub fn with_description(mut self, description: String) -> Self {
81        self.description = Some(description);
82        self
83    }
84
85    /// Set detail
86    pub fn with_detail(mut self, detail: String) -> Self {
87        self.detail = Some(detail);
88        self
89    }
90
91    /// Set sort order
92    pub fn with_sort_order(mut self, order: u32) -> Self {
93        self.sort_order = order;
94        self
95    }
96}
97
98/// Completion context at a position
99#[derive(Debug, Clone)]
100pub struct CompletionContext {
101    /// Current line text
102    pub line: String,
103    /// Position within the line
104    pub column: usize,
105    /// Current section (if any)
106    pub section: Option<String>,
107    /// Whether we're inside an override tag
108    pub in_override_tag: bool,
109    /// Current tag being typed (if any)
110    pub current_tag: Option<String>,
111}
112
113/// Auto-completion extension
114pub struct AutoCompleteExtension {
115    info: ExtensionInfo,
116    state: ExtensionState,
117    /// Known style names from the document
118    style_names: Vec<String>,
119    /// Configuration
120    config: AutoCompleteConfig,
121}
122
123/// Configuration for auto-completion
124#[derive(Debug, Clone)]
125pub struct AutoCompleteConfig {
126    /// Enable field name completion
127    pub complete_fields: bool,
128    /// Enable style reference completion
129    pub complete_styles: bool,
130    /// Enable override tag completion
131    pub complete_tags: bool,
132    /// Enable value completion
133    pub complete_values: bool,
134    /// Maximum suggestions to show
135    pub max_suggestions: usize,
136    /// Minimum characters before triggering
137    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    /// Create a new auto-complete extension
155    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    /// Get completions at a position
174    pub fn get_completions(
175        &mut self,
176        document: &EditorDocument,
177        position: Position,
178    ) -> Result<Vec<CompletionItem>> {
179        // Update style names from document
180        self.update_style_names(document)?;
181
182        // Get completion context
183        let context = self.get_completion_context(document, position)?;
184
185        // Generate completions based on context
186        let mut completions = Vec::new();
187
188        // Section completions
189        if context.line.is_empty() || context.line.starts_with('[') {
190            completions.extend(self.get_section_completions(&context));
191        }
192
193        // Field completions
194        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        // Override tag completions
201        if context.in_override_tag && self.config.complete_tags {
202            completions.extend(self.get_tag_completions(&context));
203        }
204
205        // Style reference completions
206        if self.should_complete_style(&context) && self.config.complete_styles {
207            completions.extend(self.get_style_completions(&context));
208        }
209
210        // Sort and limit completions
211        completions.sort_by_key(|c| c.sort_order);
212        completions.truncate(self.config.max_suggestions);
213
214        Ok(completions)
215    }
216
217    /// Update known style names from document
218    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    /// Get completion context at position
235    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        // Find current line
244        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        // Find current section
254        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        // Check if we're in an override tag
263        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        // Get current tag if in override
269        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    /// Get section header completions
294    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    /// Get field completions for a section
329    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    /// Get override tag completions
390    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            // Look for backslash prefix
444            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    /// Get style name completions
469    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    /// Check if we should complete style names
482    fn should_complete_style(&self, context: &CompletionContext) -> bool {
483        if let Some(ref section) = context.section {
484            if section == "Events" {
485                // Check if we're in the style field of an event
486                let line = context.line.trim_start();
487                if line.starts_with("Dialogue:") || line.starts_with("Comment:") {
488                    // Count commas to determine field
489                    let before_cursor = &context.line[..context.column];
490                    let comma_count = before_cursor.matches(',').count();
491                    // Style is the 4th field (after 3 commas)
492                    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        // Load configuration
515        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                    // Get position from args or use end of document
557                    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                    // Add completion data
574                    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// Include extended tests
726#[cfg(test)]
727#[path = "auto_complete_tests.rs"]
728mod extended_tests;