Skip to main content

perl_lsp_completion/
completion.rs

1//! Code completion provider for Perl
2//!
3//! This module provides intelligent code completion suggestions based on
4//! context, including variables, functions, keywords, file paths, and more.
5//!
6//! ## Features
7//!
8//! ### Core Completion Types
9//! - **Variables**: Scalar (`$var`), array (`@array`), hash (`%hash`) with scope analysis
10//! - **Functions**: Built-in functions (150+ with signatures) and user-defined subroutines
11//! - **Keywords**: Perl keywords with snippet expansion (`sub`, `if`, `while`, etc.)
12//! - **Packages**: Package member completion with workspace index integration
13//! - **Methods**: Context-aware method completion including DBI methods
14//! - **Test Functions**: Test::More completions in test contexts
15//!
16//! ### File Path Completion (v0.8.7+)
17//! **File completion with comprehensive security:**
18//!
19//! - **Smart Context Detection**: Automatically activates inside quoted string literals (`"path/file"` or `'path/file'`)
20//! - **Path Recognition**: Detects `/` or `\` separators and alphanumeric patterns to identify file paths
21//! - **Security Safeguards**:
22//!   - Path traversal prevention (blocks `../` patterns)
23//!   - Null byte protection and control character filtering
24//!   - Windows reserved name filtering (CON, PRN, AUX, etc.)
25//!   - UTF-8 validation and filename length limits (255 chars)
26//!   - Safe directory canonicalization with fallbacks
27//! - **Performance Optimizations**:
28//!   - Controlled filesystem traversal (max 1 directory level deep)
29//!   - Result limits (50 completions, 200 entries examined)
30//!   - LSP cancellation support for responsive editing
31//! - **File Type Intelligence**:
32//!   - Perl files (`.pl`, `.pm`, `.t`) → "Perl file"
33//!   - Source files (`.rs`, `.js`, `.py`) → Language-specific descriptions
34//!   - Config files (`.json`, `.yaml`, `.toml`) → Format-specific descriptions
35//!   - Generic fallback for unknown extensions
36//! - **Cross-platform**: Handles Unix and Windows path separators consistently
37//!
38//! ## LSP Client Capabilities
39//!
40//! Requires client support for `textDocument/completion` and optional completion
41//! capabilities such as `completionItem.snippetSupport` and
42//! `completionItem.resolveSupport`.
43//!
44//! ## Protocol Compliance
45//!
46//! Implements the LSP completion protocol (`textDocument/completion` and
47//! `completionItem/resolve`) with cancellation handling per the LSP 3.17+ spec.
48//!
49//! ## See also
50//!
51//! - [`CompletionContext`] for request-scoped parsing context
52//! - [`CompletionItem`] for LSP completion payloads
53//! - [`crate::ide::lsp_compat::semantic_tokens`] for shared symbol analysis
54//!
55//! ## Usage Examples
56//!
57//! ### Basic Variable Completion
58//! ```perl
59//! my $count = 42;
60//! my @items = ();
61//! $c<cursor> # Suggests: $count
62//! ```
63//!
64//! ### File Path Completion
65//! ```perl
66//! my $config = "config/app.<cursor>"; # Suggests: config/app.yaml, config/app.json
67//! open my $fh, '<', "src/lib<cursor>"; # Suggests: src/lib.rs, src/lib/
68//! ```
69//!
70//! ### Method Completion
71//! ```perl
72//! my $dbh = DBI->connect(...);
73//! $dbh-><cursor> # Suggests: do, prepare, selectrow_array, etc.
74//! ```
75//!
76//! ## Security Model
77//!
78//! File completion implements comprehensive security measures:
79//! - **Input validation**: Rejects dangerous paths and characters
80//! - **Filesystem isolation**: Only accesses relative paths in safe directories
81//! - **Resource limits**: Prevents excessive filesystem traversal
82//! - **Safe canonicalization**: Handles path resolution with security checks
83//!
84//! ## Performance Characteristics
85//!
86//! - **Variable/function completion**: <1ms typical response
87//! - **File path completion**: <10ms with filesystem traversal limits
88//! - **Cancellation aware**: Respects LSP cancellation for responsiveness
89//! - **Memory efficient**: Uses streaming iteration without loading all results
90
91pub(crate) mod auto_import;
92mod builtins;
93mod context;
94mod file_path;
95mod functions;
96mod items;
97mod keywords;
98mod methods;
99mod packages;
100mod regex_patterns;
101pub(crate) mod scope_distance;
102mod snippets;
103mod sort;
104pub(crate) mod test_more;
105mod variables;
106mod workspace;
107
108// Re-export public types
109pub use self::context::CompletionContext;
110pub use self::items::{CompletionItem, CompletionItemKind};
111pub use self::methods::get_dbi_method_documentation;
112pub use self::test_more::get_test_more_documentation;
113
114use perl_parser_core::ast::Node;
115use perl_parser_core::ast::NodeKind;
116use perl_semantic_analyzer::symbol::{SymbolExtractor, SymbolKind, SymbolTable};
117use perl_workspace_index::workspace_index::WorkspaceIndex;
118use std::collections::{HashMap, HashSet};
119use std::sync::Arc;
120
121/// Maps module_name -> Set of explicitly imported symbol names.
122///
123/// Semantics:
124/// - Entry MISSING: `use Module` with no args (import all of `@EXPORT`) — no filtering.
125/// - Entry with EMPTY set: `use Module qw()` (explicit empty qw import) — nothing in namespace.
126/// - Entry with non-empty set: `use Module qw(a b)` — only those symbols are imported.
127type ImportMap = HashMap<String, HashSet<String>>;
128
129/// Completion provider
130pub struct CompletionProvider {
131    symbol_table: SymbolTable,
132    workspace_index: Option<Arc<WorkspaceIndex>>,
133    import_map: ImportMap,
134}
135
136impl CompletionProvider {
137    /// Create a new completion provider from parsed AST for Perl script analysis
138    ///
139    /// # Arguments
140    ///
141    /// * `ast` - Parsed AST from Perl script content during LSP Parse stage
142    /// * `workspace_index` - Optional workspace-wide symbol index for cross-file completion
143    ///
144    /// # Returns
145    ///
146    /// A configured completion provider ready for Perl parsing workflow analysis
147    ///
148    /// # Examples
149    ///
150    /// ```rust,ignore
151    /// use perl_parser_core::Parser;
152    /// use perl_lsp_completion::CompletionProvider;
153    ///
154    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
155    /// let mut parser = Parser::new("my $var = 42; sub hello { print $var; }");
156    /// let ast = parser.parse()?;
157    /// let provider = CompletionProvider::new_with_index(&ast, None);
158    /// // Provider ready for Perl script completion analysis
159    /// # Ok(())
160    /// # }
161    /// ```
162    /// Arguments: `ast`, `workspace_index`.
163    pub fn new_with_index(ast: &Node, workspace_index: Option<Arc<WorkspaceIndex>>) -> Self {
164        Self::new_with_index_and_source(ast, "", workspace_index)
165    }
166
167    /// Create a new completion provider from parsed AST and source with workspace integration
168    ///
169    /// Constructs a completion provider with full workspace symbol information for
170    /// comprehensive completion suggestions during Perl script editing within the
171    /// LSP workflow. Integrates local AST symbols with workspace-wide indexing.
172    ///
173    /// # Arguments
174    ///
175    /// * `ast` - Parsed AST containing local scope symbols and structure
176    /// * `source` - Original source code for position-based context analysis
177    /// * `workspace_index` - Optional workspace symbol index for cross-file completions
178    ///
179    /// # Returns
180    ///
181    /// A configured completion provider ready for LSP completion requests with
182    /// both local and workspace symbol coverage for Perl script development.
183    ///
184    /// # Examples
185    ///
186    /// ```rust,ignore
187    /// use perl_parser_core::Parser;
188    /// use perl_lsp_completion::CompletionProvider;
189    /// use perl_workspace_index::workspace_index::WorkspaceIndex;
190    /// use std::sync::Arc;
191    ///
192    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
193    /// let script = "package EmailProcessor; sub filter_spam { my $var; }";
194    /// let mut parser = Parser::new(script);
195    /// let ast = parser.parse()?;
196    ///
197    /// let workspace_idx = Arc::new(WorkspaceIndex::new());
198    /// let provider = CompletionProvider::new_with_index_and_source(
199    ///     &ast, script, Some(workspace_idx)
200    /// );
201    /// // Provider ready for cross-file Perl script completions
202    /// # Ok(())
203    /// # }
204    /// ```
205    /// Arguments: `ast`, `source`, `workspace_index`.
206    /// Returns: A configured completion provider.
207    /// Example: `CompletionProvider::new_with_index_and_source(&ast, source, None)`.
208    pub fn new_with_index_and_source(
209        ast: &Node,
210        source: &str,
211        workspace_index: Option<Arc<WorkspaceIndex>>,
212    ) -> Self {
213        let symbol_table = SymbolExtractor::new_with_source(source).extract(ast);
214        let import_map = Self::extract_import_map(ast);
215
216        CompletionProvider { symbol_table, workspace_index, import_map }
217    }
218
219    /// Walk the top-level AST and build an `ImportMap` from `use` statements.
220    ///
221    /// Only uppercase-starting module names are included (skips pragmas like
222    /// `strict`, `warnings`, `feature`, `constant`, `utf8`, `lib`, `parent`, `base`).
223    fn extract_import_map(ast: &Node) -> ImportMap {
224        let mut map: ImportMap = HashMap::new();
225
226        fn collect(node: &Node, map: &mut ImportMap) {
227            match &node.kind {
228                NodeKind::Use { module, args, .. } => {
229                    // Skip pragmas: only process uppercase-starting module names
230                    let first_char: Option<char> = module.chars().next();
231                    if !first_char.is_some_and(|c: char| c.is_ascii_uppercase()) {
232                        return;
233                    }
234
235                    // `use Module` with no args at all — import all of @EXPORT, no filtering
236                    if args.is_empty() {
237                        return;
238                    }
239
240                    let mut symbols: HashSet<String> = HashSet::new();
241                    let mut has_symbol_args = false;
242
243                    for arg in args {
244                        // Skip version numbers (e.g. "1.50" in `use List::Util 1.50 qw(sum)`)
245                        let first_byte = arg.as_bytes().first().copied().unwrap_or(0);
246                        if first_byte.is_ascii_digit() {
247                            continue;
248                        }
249                        // Skip flag args (e.g. "-norequire")
250                        if arg.starts_with('-') {
251                            continue;
252                        }
253                        // Skip hash-ref style args
254                        if arg.starts_with('{') {
255                            continue;
256                        }
257
258                        if arg.starts_with("qw") {
259                            let content = arg
260                                .trim_start_matches("qw")
261                                .trim_start_matches(|c: char| "([{/<|!".contains(c))
262                                .trim_end_matches(|c: char| ")]}/|!>".contains(c));
263                            for word in content.split_whitespace() {
264                                if !word.is_empty() {
265                                    symbols.insert(word.to_string());
266                                    has_symbol_args = true;
267                                }
268                            }
269                        } else {
270                            // Bare string arg (possibly quoted): 'func' or "func"
271                            let cleaned = arg.trim_matches(|c: char| c == '\'' || c == '"');
272                            if !cleaned.is_empty() {
273                                symbols.insert(cleaned.to_string());
274                                has_symbol_args = true;
275                            }
276                        }
277                    }
278
279                    if has_symbol_args {
280                        map.entry(module.clone()).or_default().extend(symbols);
281                    } else {
282                        // Explicit empty import: `use Module qw()`
283                        map.entry(module.clone()).or_default();
284                    }
285                }
286                NodeKind::Program { statements } | NodeKind::Block { statements } => {
287                    for stmt in statements {
288                        collect(stmt, map);
289                    }
290                }
291                _ => {}
292            }
293        }
294
295        collect(ast, &mut map);
296        map
297    }
298
299    /// Create a new completion provider from parsed AST without workspace context
300    ///
301    /// Constructs a basic completion provider using only local scope symbols from
302    /// provided AST. Suitable for simple Perl script editing without cross-file
303    /// dependencies in LSP workflow.
304    ///
305    /// # Arguments
306    ///
307    /// * `ast` - Parsed AST containing local symbols for completion
308    ///
309    /// # Returns
310    ///
311    /// A completion provider configured for local-only completions without
312    /// workspace symbol integration.
313    ///
314    /// # Examples
315    ///
316    /// ```rust,ignore
317    /// use perl_parser_core::Parser;
318    /// use perl_lsp_completion::CompletionProvider;
319    ///
320    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
321    /// let script = "my $email_count = 0; my $";
322    /// let mut parser = Parser::new(script);
323    /// let ast = parser.parse()?;
324    ///
325    /// let provider = CompletionProvider::new(&ast);
326    /// // Provider ready for local variable completions
327    /// # Ok(())
328    /// # }
329    /// ```
330    /// Arguments: `ast`.
331    /// Returns: A completion provider configured for local-only symbols.
332    pub fn new(ast: &Node) -> Self {
333        Self::new_with_index(ast, None)
334    }
335
336    /// Get completions at a given position with optional filepath for enhanced context
337    ///
338    /// Provides completion suggestions based on cursor position within Perl script
339    /// source code. Uses filepath context to enable enhanced completions for test
340    /// files and specific Perl parsing patterns within LSP workflows.
341    ///
342    /// # Arguments
343    ///
344    /// * `source` - Email script source code for analysis
345    /// * `position` - Byte offset cursor position for completion
346    /// * `filepath` - Optional file path for context-aware completion enhancement
347    ///
348    /// # Returns
349    ///
350    /// Vector of completion items sorted by relevance for current context,
351    /// including local variables, functions, and workspace symbols when available.
352    ///
353    /// # Examples
354    ///
355    /// ```rust,ignore
356    /// use perl_parser_core::Parser;
357    /// use perl_lsp_completion::CompletionProvider;
358    ///
359    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
360    /// let script = "my $var = 42; sub hello { print $var; }";
361    /// let mut parser = Parser::new(script);
362    /// let ast = parser.parse()?;
363    ///
364    /// let provider = CompletionProvider::new(&ast);
365    /// let completions = provider.get_completions_with_path(
366    ///     script, script.len(), Some("/path/to/data_processor.pl")
367    /// );
368    /// assert!(!completions.is_empty());
369    /// # Ok(())
370    /// # }
371    /// ```
372    ///
373    /// See also [`Self::get_completions_with_path_cancellable`] for cancellation support
374    /// and [`Self::get_completions`] for simple completions without filepath context.
375    /// Arguments: `source`, `position`, `filepath`.
376    /// Returns: A list of completion items for the current context.
377    /// Example: `provider.get_completions_with_path(source, pos, Some(path))`.
378    pub fn get_completions_with_path(
379        &self,
380        source: &str,
381        position: usize,
382        filepath: Option<&str>,
383    ) -> Vec<CompletionItem> {
384        self.get_completions_with_path_cancellable(source, position, filepath, &|| false)
385    }
386
387    /// Get completions at a given position with cancellation support for responsive editing
388    ///
389    /// Provides completion suggestions with cancellation capability for responsive
390    /// Perl script editing during large workspace operations. Optimized for
391    /// large-scale LSP environments where completion requests may need
392    /// to be interrupted for better user experience.
393    ///
394    /// # Arguments
395    ///
396    /// * `source` - Email script source code for completion analysis
397    /// * `position` - Byte offset cursor position within the source
398    /// * `filepath` - Optional file path for enhanced context detection
399    /// * `is_cancelled` - Cancellation callback for responsive completion
400    ///
401    /// # Returns
402    ///
403    /// Vector of completion items or empty vector if operation was cancelled,
404    /// sorted by relevance for optimal Perl script development experience.
405    ///
406    /// # Performance
407    ///
408    /// - Respects cancellation for operations exceeding typical response times
409    /// - Optimized for large Perl script files in large Perl codebase processing workflows
410    /// - Provides partial results when possible before cancellation
411    ///
412    /// # Examples
413    ///
414    /// ```rust,ignore
415    /// use perl_parser_core::Parser;
416    /// use perl_lsp_completion::CompletionProvider;
417    /// use std::sync::atomic::{AtomicBool, Ordering};
418    /// use std::sync::Arc;
419    ///
420    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
421    /// let script = "package EmailHandler; sub process_emails { }";
422    /// let mut parser = Parser::new(script);
423    /// let ast = parser.parse()?;
424    ///
425    /// let provider = CompletionProvider::new(&ast);
426    /// let cancelled = Arc::new(AtomicBool::new(false));
427    /// let cancel_fn = || cancelled.load(Ordering::Relaxed);
428    ///
429    /// let completions = provider.get_completions_with_path_cancellable(
430    ///     script, script.len(), Some("email_handler.pl"), &cancel_fn
431    /// );
432    /// # Ok(())
433    /// # }
434    /// ```
435    /// Arguments: `source`, `position`, `filepath`, `is_cancelled`.
436    /// Returns: A list of completion items or an empty list when cancelled.
437    /// Example: `provider.get_completions_with_path_cancellable(source, pos, None, &|| false)`.
438    pub fn get_completions_with_path_cancellable(
439        &self,
440        source: &str,
441        position: usize,
442        filepath: Option<&str>,
443        is_cancelled: &dyn Fn() -> bool,
444    ) -> Vec<CompletionItem> {
445        // Input validation
446        if position > source.len() {
447            return vec![];
448        }
449
450        let context = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
451            self.analyze_context(source, position)
452        })) {
453            Ok(mut ctx) => {
454                ctx.in_use_statement = Self::is_use_statement_context(source, position);
455                ctx
456            }
457            Err(_) => {
458                return vec![];
459            }
460        };
461
462        if context.in_comment {
463            return vec![];
464        }
465
466        // Early cancellation check
467        if is_cancelled() {
468            return vec![];
469        }
470
471        // When `-` is a trigger character, only proceed for arrow-operator context.
472        // All other uses of `-` (decrement `--`, subtract `-=`, unary minus, etc.)
473        // must return empty so the editor doesn't flood the user with completions.
474        if context.trigger_character == Some('-')
475            && !(context.prefix.ends_with("->") && context.prefix.len() > 2)
476        {
477            return vec![];
478        }
479
480        let mut completions = Vec::new();
481
482        // After regex close delimiter: offer flag completions.
483        // This check MUST precede the in_regex check because the cursor after
484        // the closing '/' is not itself inside the regex body.
485        if Self::is_in_regex_flags(source, position) {
486            regex_patterns::add_regex_flag_completions(&mut completions, &context, source);
487            return sort::deduplicate_and_sort(completions);
488        }
489
490        // Regex context: suggest regex constructs when inside a regex literal,
491        // but keep sigil-prefixed symbol completions available for interpolated
492        // variables like `/^$fo/`.
493        if context.in_regex && !matches!(context.prefix.chars().next(), Some('$' | '@' | '%')) {
494            regex_patterns::add_regex_completions(&mut completions, &context, source);
495            return sort::deduplicate_and_sort(completions);
496        }
497
498        // Determine what kind of completions to provide based on context
499        // Check for `use Module qw(...)` import list context first
500        if let Some((module_name, qw_prefix)) = Self::detect_use_qw_import_context(source, position)
501        {
502            workspace::add_use_qw_import_completions(
503                &mut completions,
504                &context,
505                &self.workspace_index,
506                &module_name,
507                &qw_prefix,
508            );
509        } else if context.in_use_statement && !context.prefix.starts_with('$') {
510            // Module name completion after `use` or `require`
511            workspace::add_use_module_completions(
512                &mut completions,
513                &context,
514                &self.workspace_index,
515            );
516        } else if self.is_has_options_key_context(source, position) {
517            self.add_has_option_completions(&mut completions, &context);
518        } else if (context.trigger_character == Some('>') || context.trigger_character == Some('-'))
519            && context.prefix.ends_with("->")
520            && context.prefix.len() > 2
521        {
522            // Method completion for both `>` (second char of `->`) and `-` (first char of
523            // `->`) triggers. The prefix length guard ensures we have an actual receiver
524            // (not bare `->` from a non-arrow context like `$x -=`).
525            methods::add_method_completions(&mut completions, &context, source, &self.symbol_table);
526            // Add workspace-indexed methods for the receiver's type
527            workspace::add_workspace_method_completions(
528                &mut completions,
529                &context,
530                source,
531                &self.workspace_index,
532            );
533        } else if context.prefix.starts_with('$') && context.prefix.contains("::") {
534            packages::add_package_completions(&mut completions, &context, &self.workspace_index);
535            if !completions.is_empty() {
536                return completions;
537            }
538            variables::add_variable_completions(
539                &mut completions,
540                &context,
541                SymbolKind::scalar(),
542                &self.symbol_table,
543            );
544            if is_cancelled() {
545                return vec![];
546            }
547            variables::add_special_variables(&mut completions, &context, "$");
548        } else if context.prefix.starts_with('$') {
549            // Scalar variable completion
550            variables::add_variable_completions(
551                &mut completions,
552                &context,
553                SymbolKind::scalar(),
554                &self.symbol_table,
555            );
556            if is_cancelled() {
557                return vec![];
558            }
559            variables::add_special_variables(&mut completions, &context, "$");
560        } else if context.prefix.starts_with('@') && context.prefix.contains("::") {
561            packages::add_package_completions(&mut completions, &context, &self.workspace_index);
562            if !completions.is_empty() {
563                return completions;
564            }
565            variables::add_variable_completions(
566                &mut completions,
567                &context,
568                SymbolKind::array(),
569                &self.symbol_table,
570            );
571            if is_cancelled() {
572                return vec![];
573            }
574            variables::add_special_variables(&mut completions, &context, "@");
575        } else if context.prefix.starts_with('@') {
576            // Array variable completion
577            variables::add_variable_completions(
578                &mut completions,
579                &context,
580                SymbolKind::array(),
581                &self.symbol_table,
582            );
583            if is_cancelled() {
584                return vec![];
585            }
586            variables::add_special_variables(&mut completions, &context, "@");
587        } else if context.prefix.starts_with('%') && context.prefix.contains("::") {
588            packages::add_package_completions(&mut completions, &context, &self.workspace_index);
589            if !completions.is_empty() {
590                return completions;
591            }
592            variables::add_variable_completions(
593                &mut completions,
594                &context,
595                SymbolKind::hash(),
596                &self.symbol_table,
597            );
598            if is_cancelled() {
599                return vec![];
600            }
601            variables::add_special_variables(&mut completions, &context, "%");
602        } else if context.prefix.starts_with('%') {
603            // Hash variable completion
604            variables::add_variable_completions(
605                &mut completions,
606                &context,
607                SymbolKind::hash(),
608                &self.symbol_table,
609            );
610            if is_cancelled() {
611                return vec![];
612            }
613            variables::add_special_variables(&mut completions, &context, "%");
614        } else if context.prefix.starts_with('&') {
615            // Subroutine completion
616            functions::add_function_completions(&mut completions, &context, &self.symbol_table);
617        } else if context.trigger_character == Some(':') && context.prefix.ends_with("::") {
618            // Package member completion
619            packages::add_package_completions(&mut completions, &context, &self.workspace_index);
620        } else if context.in_string {
621            // String interpolation or file path
622            let line_prefix = &source[..context.position];
623            if let Some(start) = line_prefix.rfind(['"', '\'']) {
624                // Find the end of the string to check for dangerous characters
625                // Safety: rfind returns byte offset, use get() for safe access
626                let quote_char = match source.get(start..).and_then(|s| s.chars().next()) {
627                    Some(c) => c,
628                    None => return completions, // Invalid offset, skip file completions
629                };
630                let string_end = source[start + 1..]
631                    .find(quote_char)
632                    .map(|i| start + 1 + i)
633                    .unwrap_or(source.len());
634                let full_string_content = &source[start + 1..string_end];
635
636                // Security check: reject strings with null bytes or other dangerous characters
637                if full_string_content.contains('\0') {
638                    return completions; // Return early without file completions
639                }
640
641                let path_prefix = &line_prefix[start + 1..];
642                // Check if this looks like a file path (contains separators or path-like characters)
643                if path_prefix.contains('/')
644                    || path_prefix.contains('\\')  // Include backslashes for Windows paths
645                    || path_prefix
646                        .chars()
647                        .all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
648                {
649                    let file_context = file_path::FileCompletionContext::new(
650                        path_prefix,
651                        start + 1,
652                        context.position,
653                    );
654                    completions.extend(file_path::complete_file_paths(&file_context, is_cancelled));
655                }
656            }
657        } else {
658            // General completion: keywords, functions, variables
659            let keywords = keywords::keywords();
660            if context.prefix.is_empty() || self.could_be_keyword(&context.prefix, keywords) {
661                keywords::add_keyword_completions(&mut completions, &context, keywords);
662                if is_cancelled() {
663                    return vec![];
664                }
665            }
666
667            let builtins = builtins::create_builtins();
668            if context.prefix.is_empty() || self.could_be_function(&context.prefix, &builtins) {
669                builtins::add_builtin_completions(&mut completions, &context, &builtins);
670                if is_cancelled() {
671                    return vec![];
672                }
673                functions::add_function_completions(&mut completions, &context, &self.symbol_table);
674                if is_cancelled() {
675                    return vec![];
676                }
677            }
678
679            // Add built-in snippet completions
680            snippets::add_snippet_completions(&mut completions, &context);
681            if is_cancelled() {
682                return vec![];
683            }
684
685            // Also suggest variables without sigils in some contexts
686            variables::add_all_variables(&mut completions, &context, &self.symbol_table);
687            if is_cancelled() {
688                return vec![];
689            }
690
691            // Add workspace symbol completions from other files
692            workspace::add_workspace_symbol_completions(
693                &mut completions,
694                &context,
695                &self.workspace_index,
696                &self.import_map,
697            );
698            if is_cancelled() {
699                return vec![];
700            }
701
702            // Add Test::More completions if in test context
703            if self.is_test_context(source, filepath) {
704                test_more::add_test_more_completions(&mut completions, &context);
705            }
706        }
707
708        // Remove duplicates and sort completions by relevance
709        sort::deduplicate_and_sort(completions)
710    }
711
712    /// Get completions at a given position for Perl script development
713    ///
714    /// Provides basic completion suggestions at specified cursor position
715    /// within Perl script source code. This is the primary interface for
716    /// LSP completion requests during Perl parsing workflow development.
717    ///
718    /// # Arguments
719    ///
720    /// * `source` - Email script source code for completion analysis
721    /// * `position` - Byte offset cursor position where completions are requested
722    ///
723    /// # Returns
724    ///
725    /// Vector of completion items including local variables, functions, keywords,
726    /// and built-in Perl constructs relevant to Perl parsing workflows.
727    ///
728    /// # Examples
729    ///
730    /// ```rust,ignore
731    /// use perl_parser_core::Parser;
732    /// use perl_lsp_completion::CompletionProvider;
733    ///
734    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
735    /// let script = "my $email_count = scalar(@emails); $email_c";
736    /// let mut parser = Parser::new(script);
737    /// let ast = parser.parse()?;
738    ///
739    /// let provider = CompletionProvider::new(&ast);
740    /// let completions = provider.get_completions(script, script.len());
741    ///
742    /// // Should include completion for $email_count variable
743    /// assert!(completions.iter().any(|c| c.label.contains("email_count")));
744    /// # Ok(())
745    /// # }
746    /// ```
747    ///
748    /// See also [`Self::get_completions_with_path`] for enhanced context-aware completions.
749    /// Arguments: `source`, `position`.
750    /// Returns: A list of completion items for the current context.
751    /// Example: `provider.get_completions(source, pos)`.
752    pub fn get_completions(&self, source: &str, position: usize) -> Vec<CompletionItem> {
753        self.get_completions_with_path(source, position, None)
754    }
755
756    /// Detect if the cursor is inside `qw(...)` in a `use Module qw(...)` statement.
757    ///
758    /// Returns `Some((module_name, prefix))` when the cursor is inside the import list,
759    /// where `module_name` is the module being imported from and `prefix` is the partial
760    /// symbol the user has typed so far inside the `qw()`.
761    ///
762    /// Returns `None` when not in a `use ... qw()` import context.
763    fn detect_use_qw_import_context(source: &str, position: usize) -> Option<(String, String)> {
764        if !source.is_char_boundary(position) {
765            return None;
766        }
767        let before = &source[..position];
768        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
769        let line = before[line_start..].trim_start();
770
771        // Must start with `use `
772        let rest = line.strip_prefix("use ")?;
773        let rest = rest.trim_start();
774
775        // Extract module name (starts uppercase, contains ::, alphanumeric, _)
776        let mod_end =
777            rest.find(|c: char| !c.is_alphanumeric() && c != ':' && c != '_').unwrap_or(rest.len());
778        if mod_end == 0 {
779            return None;
780        }
781        let module_name = &rest[..mod_end];
782
783        // Module names start with uppercase by convention
784        if !module_name.starts_with(|c: char| c.is_ascii_uppercase()) {
785            return None;
786        }
787
788        let after_module = &rest[mod_end..];
789
790        // Find `qw` followed by a delimiter
791        let qw_pos = after_module.find("qw")?;
792        let after_qw = &after_module[qw_pos + 2..];
793        let after_qw = after_qw.trim_start();
794
795        // qw can use various delimiters: (, [, {, /, |, !, etc.
796        let first_char = after_qw.chars().next()?;
797        let close_delim = match first_char {
798            '(' => ')',
799            '[' => ']',
800            '{' => '}',
801            '<' => '>',
802            other => other, // For symmetric delimiters like / or |
803        };
804
805        let inside_qw = &after_qw[first_char.len_utf8()..];
806
807        // Check we haven't passed the closing delimiter
808        if inside_qw.contains(close_delim) {
809            return None;
810        }
811
812        // Extract the prefix: the last word being typed inside qw()
813        // Words in qw() are whitespace-separated
814        let prefix = inside_qw.rsplit(|c: char| c.is_ascii_whitespace()).next().unwrap_or("");
815
816        Some((module_name.to_string(), prefix.to_string()))
817    }
818
819    /// Check if the cursor is in a `use` or `require` statement context.
820    ///
821    /// Detects patterns like `use Mod`, `use Some::Mo`, `require Mo` etc.
822    /// Returns true when the cursor is positioned where a module name is expected.
823    ///
824    /// Returns false for pragma-like directives (`use constant`, `use lib`, `use if`,
825    /// `use strict`, `use warnings`, etc.) where module-name completion is not useful,
826    /// and for positions past the module name (after `;`, `(`, or `qw`).
827    fn is_use_statement_context(source: &str, position: usize) -> bool {
828        // Guard against slicing at a non-char-boundary
829        if !source.is_char_boundary(position) {
830            return false;
831        }
832        let before = &source[..position];
833        // Find the start of the current line
834        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
835        let line = before[line_start..].trim_start();
836
837        // Check for `use Module` or `require Module` patterns
838        // Must be at the start of a statement (after optional whitespace)
839        if let Some(rest) = line.strip_prefix("use ") {
840            // After `use `, we expect a module name (possibly partial)
841            // But not if we've already moved past the module name (e.g., `use Module qw(`)
842            let rest = rest.trim_start();
843            // If there's a semicolon, version number, or import list, we're past the module name
844            if rest.contains(';') || rest.contains('(') || rest.contains("qw") {
845                return false;
846            }
847            // Skip pragma-like directives where the token after `use` is lowercase
848            // (e.g. `use strict`, `use warnings`, `use constant`, `use lib`, `use if`)
849            // Module names in Perl start with an uppercase letter by convention
850            let first_char = rest.chars().next();
851            // Empty rest means cursor is right after `use ` -- still a valid context
852            // Uppercase first char means a module name is being typed
853            first_char.is_none() || first_char.is_some_and(|c| c.is_ascii_uppercase())
854        } else if let Some(rest) = line.strip_prefix("require ") {
855            let rest = rest.trim_start();
856            !rest.contains(';')
857        } else {
858            false
859        }
860    }
861
862    /// Analyze the context at the cursor position
863    fn analyze_context(&self, source: &str, position: usize) -> CompletionContext {
864        // Find the word being typed
865        // Special handling for method calls: include the -> and the receiver
866        let (word_prefix, prefix_start) = if position >= 2
867            && &source[position.saturating_sub(2)..position] == "->"
868        {
869            // We're right after ->, find the receiver variable or package name.
870            // Include ':' so that qualified package names like `My::Package->` are
871            // captured as a single receiver token rather than truncated at `::`.
872            let receiver_start = source[..position.saturating_sub(2)]
873                .rfind(|c: char| {
874                    !c.is_alphanumeric() && c != '_' && c != '$' && c != '@' && c != '%' && c != ':'
875                })
876                .map(|p| p + 1)
877                .unwrap_or(0);
878            (source[receiver_start..position].to_string(), receiver_start)
879        } else if position >= 1
880            && source.as_bytes()[position - 1] == b'-'
881            && (position < 2 || source.as_bytes()[position - 2] != b'-')
882        {
883            // Cursor is right after a lone `-` (not `--`). This fires when `-` is a
884            // trigger character and the user has typed the first char of `->`.
885            // Build the prefix as receiver + `->` so that downstream method-completion
886            // functions see the same shape as the `>` trigger path.
887            let receiver_start = source[..position.saturating_sub(1)]
888                .rfind(|c: char| {
889                    !c.is_alphanumeric() && c != '_' && c != '$' && c != '@' && c != '%' && c != ':'
890                })
891                .map(|p| p + 1)
892                .unwrap_or(0);
893            let receiver = &source[receiver_start..position - 1];
894            (format!("{receiver}->"), receiver_start)
895        } else {
896            let word_start = source[..position]
897                .rfind(|c: char| {
898                    !c.is_alphanumeric()
899                        && c != '_'
900                        && c != ':'
901                        && c != '$'
902                        && c != '@'
903                        && c != '%'
904                        && c != '&'
905                })
906                .map(|p| p + 1)
907                .unwrap_or(0);
908            (source[word_start..position].to_string(), word_start)
909        };
910
911        // Detect trigger character (trigger chars are ASCII, so byte access is safe)
912        let trigger_character = if position > 0 {
913            let b = source.as_bytes()[position - 1];
914            if b.is_ascii() { Some(b as char) } else { None }
915        } else {
916            None
917        };
918
919        // Simple heuristics for context detection
920        let in_string = self.is_in_string(source, position);
921        let in_regex = Self::is_in_regex(source, position);
922        let in_comment = self.is_in_comment(source, position);
923
924        let mut context = CompletionContext::new(
925            &self.symbol_table,
926            position,
927            trigger_character,
928            in_string,
929            in_regex,
930            in_comment,
931            word_prefix,
932            prefix_start,
933        );
934        context.cursor_scope_id =
935            scope_distance::scope_at_position(&self.symbol_table, source, position);
936        context
937    }
938
939    /// Add file path completions with comprehensive security and performance safeguards
940    #[cfg(not(target_arch = "wasm32"))]
941    #[allow(dead_code)] // Backward compatibility wrapper, may be used by external code
942    fn add_file_completions(
943        &self,
944        completions: &mut Vec<CompletionItem>,
945        context: &CompletionContext,
946    ) {
947        self.add_file_completions_with_cancellation(completions, context, &|| false);
948    }
949
950    /// Add file path completions with comprehensive security and performance safeguards
951    #[cfg(target_arch = "wasm32")]
952    #[allow(dead_code)] // Backward compatibility wrapper, may be used by external code
953    fn add_file_completions(
954        &self,
955        completions: &mut Vec<CompletionItem>,
956        context: &CompletionContext,
957    ) {
958        // File system traversal isn't available on wasm32 targets.
959        let _ = (completions, context);
960    }
961
962    /// Add file path completions with cancellation support
963    ///
964    /// Uses the builder pattern via [`file_path::FilePathCallbacks`] to bundle
965    /// security callbacks, reducing argument count and improving maintainability.
966    #[cfg(not(target_arch = "wasm32"))]
967    fn add_file_completions_with_cancellation(
968        &self,
969        completions: &mut Vec<CompletionItem>,
970        context: &CompletionContext,
971        is_cancelled: &dyn Fn() -> bool,
972    ) {
973        completions.extend(file_path::complete_file_paths(
974            &file_path::FileCompletionContext::new(
975                &context.prefix,
976                context.prefix_start,
977                context.position,
978            ),
979            is_cancelled,
980        ));
981    }
982
983    /// Add file path completions with cancellation support
984    #[cfg(target_arch = "wasm32")]
985    fn add_file_completions_with_cancellation(
986        &self,
987        completions: &mut Vec<CompletionItem>,
988        context: &CompletionContext,
989        _is_cancelled: &dyn Fn() -> bool,
990    ) {
991        // File system traversal isn't available on wasm32 targets.
992        let _ = (completions, context, _is_cancelled);
993    }
994
995    /// Check whether the cursor is inside a Moo/Moose `has (...)` option-key context.
996    fn is_has_options_key_context(&self, source: &str, position: usize) -> bool {
997        if position > source.len() {
998            return false;
999        }
1000
1001        let prefix = &source[..position];
1002        let statement_start = prefix.rfind(';').map(|idx| idx + 1).unwrap_or(0);
1003        let statement = &prefix[statement_start..];
1004
1005        let Some(has_idx) = Self::find_keyword(statement, "has") else {
1006            return false;
1007        };
1008        let after_has = &statement[has_idx + 3..];
1009
1010        let Some(arrow_idx) = after_has.find("=>") else {
1011            return false;
1012        };
1013        let after_arrow = &after_has[arrow_idx + 2..];
1014
1015        let Some(open_idx) = after_arrow.find('(') else {
1016            return false;
1017        };
1018        let options_text = &after_arrow[open_idx + 1..];
1019
1020        // Must still be inside the `(` ... `)` option list.
1021        let mut paren_depth = 1i32;
1022        for ch in options_text.chars() {
1023            if ch == '(' {
1024                paren_depth += 1;
1025            } else if ch == ')' {
1026                paren_depth -= 1;
1027                if paren_depth <= 0 {
1028                    return false;
1029                }
1030            }
1031        }
1032
1033        // Find the current top-level option segment (after last comma).
1034        let mut depth = 1i32;
1035        let mut segment_start = 0usize;
1036        for (idx, ch) in options_text.char_indices() {
1037            if ch == '(' {
1038                depth += 1;
1039            } else if ch == ')' {
1040                depth -= 1;
1041            } else if ch == ',' && depth == 1 {
1042                segment_start = idx + 1;
1043            }
1044        }
1045
1046        let segment = options_text[segment_start..].trim_start();
1047        if segment.is_empty() {
1048            return true;
1049        }
1050
1051        // If `=>` is already present in this segment, we're in value context.
1052        if segment.contains("=>") {
1053            return false;
1054        }
1055
1056        segment
1057            .chars()
1058            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch.is_ascii_whitespace())
1059    }
1060
1061    /// Find a keyword in source text using ASCII identifier boundaries.
1062    fn find_keyword(text: &str, keyword: &str) -> Option<usize> {
1063        let mut start = 0usize;
1064        while let Some(rel_idx) = text[start..].find(keyword) {
1065            let idx = start + rel_idx;
1066            let before = text[..idx].chars().next_back();
1067            let after = text[idx + keyword.len()..].chars().next();
1068
1069            let before_ok = before.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
1070            let after_ok = after.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
1071            if before_ok && after_ok {
1072                return Some(idx);
1073            }
1074
1075            start = idx + keyword.len();
1076        }
1077        None
1078    }
1079
1080    /// Add common Moo/Moose `has` option-key completions.
1081    fn add_has_option_completions(
1082        &self,
1083        completions: &mut Vec<CompletionItem>,
1084        context: &CompletionContext,
1085    ) {
1086        let prefix = context.prefix.trim();
1087        let options = [
1088            ("is", "Accessor mode (`ro`, `rw`, or `rwp`)"),
1089            ("isa", "Type constraint for this attribute"),
1090            ("default", "Default value or builder closure"),
1091            ("required", "Require attribute during construction"),
1092            ("lazy", "Delay default computation until first access"),
1093            ("builder", "Method name used to build the default value"),
1094            ("reader", "Custom reader method name"),
1095            ("writer", "Custom writer method name"),
1096            ("accessor", "Custom combined read/write accessor"),
1097            ("predicate", "Method name to test if attribute is set"),
1098            ("clearer", "Method name to clear attribute value"),
1099            ("handles", "Delegated methods for referenced object"),
1100        ];
1101
1102        for (label, doc) in options {
1103            if prefix.is_empty() || label.starts_with(prefix) {
1104                completions.push(CompletionItem {
1105                    label: label.to_string(),
1106                    kind: CompletionItemKind::Property,
1107                    detail: Some("Moo/Moose option".to_string()),
1108                    documentation: Some(doc.to_string()),
1109                    insert_text: Some(format!("{label} => ")),
1110                    sort_text: Some(format!("0_{label}")),
1111                    filter_text: Some(label.to_string()),
1112                    additional_edits: vec![],
1113                    text_edit_range: Some((context.prefix_start, context.position)),
1114                    commit_characters: None,
1115                });
1116            }
1117        }
1118    }
1119
1120    /// Check if prefix could be a keyword
1121    fn could_be_keyword(&self, prefix: &str, keywords: &[&'static str]) -> bool {
1122        keywords.iter().any(|k| k.starts_with(prefix))
1123    }
1124
1125    /// Check if prefix could be a function
1126    fn could_be_function(
1127        &self,
1128        prefix: &str,
1129        builtins: &std::collections::HashSet<&'static str>,
1130    ) -> bool {
1131        // Check builtins
1132        if builtins.iter().any(|b| b.starts_with(prefix)) {
1133            return true;
1134        }
1135
1136        // Check user-defined functions
1137        for (name, symbols) in &self.symbol_table.symbols {
1138            for symbol in symbols {
1139                if symbol.kind == SymbolKind::Subroutine && name.starts_with(prefix) {
1140                    return true;
1141                }
1142            }
1143        }
1144
1145        false
1146    }
1147
1148    /// Simple heuristic to check if position is in a string
1149    fn is_in_string(&self, source: &str, position: usize) -> bool {
1150        let before = &source[..position];
1151        let single_quotes = before.matches('\'').count();
1152        let double_quotes = before.matches('"').count();
1153
1154        // Very simple: odd number of quotes means we're inside
1155        single_quotes % 2 == 1 || double_quotes % 2 == 1
1156    }
1157
1158    /// Heuristic to check if position is inside a regex literal.
1159    ///
1160    /// Detects the following regex contexts:
1161    /// - Binding operators: `=~ /…/` and `!~ /…/`
1162    /// - Explicit regex operators: `m/…/`, `qr/…/`, `s/…/…/`, `tr/…/…/`, `y/…/…/`
1163    /// - Bare regex after operators/keywords that expect a regex value
1164    fn is_in_regex(source: &str, position: usize) -> bool {
1165        let before = &source[..position];
1166
1167        // Find the last unescaped `/` before the cursor -- that could be the
1168        // opening delimiter of the regex we are inside.
1169        let Some(last_slash) = before.rfind('/') else {
1170            return false;
1171        };
1172
1173        // Check if the slash is preceded by a regex binding operator.
1174        let pre_slash = before[..last_slash].trim_end();
1175        if pre_slash.ends_with("=~") || pre_slash.ends_with("!~") {
1176            return true;
1177        }
1178
1179        // Check for explicit regex operators: m/, qr/, s/, tr/, y/
1180        // We look for the operator keyword immediately before the slash (with
1181        // optional whitespace).
1182        if Self::pre_slash_has_regex_op(pre_slash) {
1183            return true;
1184        }
1185
1186        if matches!(
1187            pre_slash.split_ascii_whitespace().next_back(),
1188            Some("or") | Some("and") | Some("not")
1189        ) {
1190            return true;
1191        }
1192
1193        // Bare `/` after certain tokens that unambiguously start a regex.
1194        if let Some(last_char) = pre_slash.chars().next_back() {
1195            // After `(`, `,`, `=`, `!`, `&&`, `||`, `or`, `and`, `not`, `;`, `{`
1196            // a `/` starts a regex rather than a division.
1197            if matches!(last_char, '(' | ',' | '=' | '!' | '&' | '|' | ';' | '{' | '~') {
1198                return true;
1199            }
1200        }
1201
1202        // If pre_slash is empty, the slash is at position 0 -- that is a regex.
1203        pre_slash.is_empty()
1204    }
1205
1206    /// Return true when the text immediately before a `/` is one of the
1207    /// explicit regex operators (`m`, `qr`, `s`, `tr`, `y`).
1208    fn pre_slash_has_regex_op(pre_slash: &str) -> bool {
1209        let trimmed = pre_slash.trim_end();
1210        for op in &["qr", "m", "s", "tr", "y"] {
1211            if let Some(before_op) = trimmed.strip_suffix(op) {
1212                // The operator must be at a word boundary -- the character
1213                // before it (if any) must not be alphanumeric or `_`.
1214                let boundary_ok = before_op
1215                    .chars()
1216                    .next_back()
1217                    .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
1218                if boundary_ok {
1219                    return true;
1220                }
1221            }
1222        }
1223        false
1224    }
1225
1226    /// Return true when the cursor is positioned in the flag region after a
1227    /// closing regex delimiter — e.g., `$x =~ /foo/|` or `m/foo/i|`.
1228    ///
1229    /// Algorithm:
1230    /// 1. Strip any trailing flag characters from the text before the cursor.
1231    /// 2. The stripped text must end with `/` (the closing delimiter).
1232    /// 3. The text before the closing `/` must look like a regex body:
1233    ///    - For single-delimiter operators (`/…/`, `m/…/`, `qr/…/`): the
1234    ///      character just before the closing `/` must be inside a regex body
1235    ///      per `is_in_regex`.
1236    ///    - For multi-delimiter operators (`s/…/…/`, `tr/…/…/`, `y/…/…/`):
1237    ///      count the number of unescaped `/` chars; an even count (≥2) with
1238    ///      a known operator keyword confirms the closing delimiter.
1239    ///
1240    /// The `is_in_regex_flags` check MUST be dispatched before `is_in_regex`
1241    /// in the completion pipeline, because the cursor after the closing `/` is
1242    /// not itself `in_regex`.
1243    pub(crate) fn is_in_regex_flags(source: &str, position: usize) -> bool {
1244        if position == 0 || position > source.len() {
1245            return false;
1246        }
1247        let before = &source[..position];
1248        let flag_chars: &[char] =
1249            &['g', 'i', 'm', 's', 'x', 'e', 'r', 'a', 'd', 'u', 'p', 'l', 'c'];
1250        let without_flags = before.trim_end_matches(|c: char| flag_chars.contains(&c));
1251        // Must end with the closing delimiter '/'.
1252        if !without_flags.ends_with('/') {
1253            return false;
1254        }
1255        let close_pos = without_flags.len();
1256        if close_pos < 2 {
1257            return false;
1258        }
1259
1260        // Fast path for single-delimiter operators: the position just before the
1261        // closing '/' must be inside a regex body per is_in_regex.
1262        if Self::is_in_regex(source, close_pos - 1) {
1263            return true;
1264        }
1265
1266        // Slow path for multi-delimiter operators (s///, tr///, y///):
1267        // count unescaped '/' chars in `without_flags`. If there are exactly 3
1268        // (i.e., op/pattern/replacement/) and the operator is s/tr/y, we are
1269        // in flags position.
1270        let body = without_flags.trim();
1271        Self::is_multi_delim_regex_at_close(body)
1272    }
1273
1274    /// Return true when `text` looks like `s/…/…/`, `tr/…/…/`, or `y/…/…/`
1275    /// with a complete closing delimiter (three `/` chars for s, tr, y).
1276    fn is_multi_delim_regex_at_close(text: &str) -> bool {
1277        // Identify whether the text starts with a known multi-delimiter operator.
1278        let (op_len, required_slashes) = if text.starts_with("tr/") || text.starts_with("y/") {
1279            let op = if text.starts_with("tr/") { 2 } else { 1 };
1280            (op, 3usize) // tr/search/replacement/ has 3 '/'
1281        } else if text.starts_with("s/") {
1282            (1, 3usize) // s/pattern/replacement/ has 3 '/'
1283        } else {
1284            // Not a multi-delimiter operator we handle — also try with a
1285            // binding operator prefix like `$x =~ s/…/…/`.
1286            let stripped = text
1287                .find("=~")
1288                .map(|p| text[p + 2..].trim_start())
1289                .or_else(|| text.find("!~").map(|p| text[p + 2..].trim_start()));
1290            if let Some(rhs) = stripped {
1291                return Self::is_multi_delim_regex_at_close(rhs);
1292            }
1293            return false;
1294        };
1295        // Count unescaped '/' characters in the operator body.
1296        let body_after_op = &text[op_len..];
1297        let slash_count = Self::count_unescaped_slashes(body_after_op);
1298        slash_count == required_slashes
1299    }
1300
1301    /// Count the number of unescaped `/` characters in `s`.
1302    fn count_unescaped_slashes(s: &str) -> usize {
1303        let mut count = 0usize;
1304        let mut escaped = false;
1305        for ch in s.chars() {
1306            if escaped {
1307                escaped = false;
1308            } else if ch == '\\' {
1309                escaped = true;
1310            } else if ch == '/' {
1311                count += 1;
1312            }
1313        }
1314        count
1315    }
1316
1317    /// Simple heuristic to check if position is in a comment
1318    fn is_in_comment(&self, source: &str, position: usize) -> bool {
1319        let line_start = source[..position].rfind('\n').map(|p| p + 1).unwrap_or(0);
1320        let line = &source[line_start..position];
1321        line.contains('#')
1322    }
1323
1324    /// Check if we're in a test context
1325    fn is_test_context(&self, source: &str, filepath: Option<&str>) -> bool {
1326        // Check if file ends with .t
1327        if let Some(path) = filepath
1328            && path.ends_with(".t")
1329        {
1330            return true;
1331        }
1332
1333        // Check if source contains Test::More or Test2::V0
1334        source.contains("use Test::More") || source.contains("use Test2::V0")
1335    }
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use super::*;
1341    use perl_parser_core::Parser;
1342    use perl_tdd_support::{must, must_some};
1343    use perl_workspace_index::workspace_index::WorkspaceIndex;
1344    use std::sync::Arc;
1345    use url::Url;
1346
1347    #[test]
1348    fn test_variable_completion() {
1349        let code = r#"
1350my $count = 42;
1351my $counter = 0;
1352my @items = ();
1353
1354$c
1355"#;
1356
1357        let mut parser = Parser::new(code);
1358        let ast = must(parser.parse());
1359
1360        let provider = CompletionProvider::new(&ast);
1361        let completions = provider.get_completions(code, code.len() - 1);
1362
1363        assert!(completions.iter().any(|c| c.label == "$count"));
1364        assert!(completions.iter().any(|c| c.label == "$counter"));
1365    }
1366
1367    #[test]
1368    fn test_function_completion() {
1369        let code = r#"
1370sub process_data {
1371    # ...
1372}
1373
1374sub process_items {
1375    # ...
1376}
1377
1378proc
1379"#;
1380
1381        let mut parser = Parser::new(code);
1382        let ast = must(parser.parse());
1383
1384        let provider = CompletionProvider::new(&ast);
1385        let completions = provider.get_completions(code, code.len() - 1);
1386
1387        assert!(completions.iter().any(|c| c.label == "process_data"));
1388        assert!(completions.iter().any(|c| c.label == "process_items"));
1389    }
1390
1391    #[test]
1392    fn test_builtin_completion() {
1393        let code = "pr";
1394
1395        let mut parser = Parser::new(""); // Empty AST
1396        let ast = must(parser.parse());
1397
1398        let provider = CompletionProvider::new(&ast);
1399        let completions = provider.get_completions(code, code.len());
1400
1401        assert!(completions.iter().any(|c| c.label == "print"));
1402        assert!(completions.iter().any(|c| c.label == "printf"));
1403    }
1404
1405    #[test]
1406    fn test_current_package_detection() {
1407        let code = r#"package Foo;
1408my $x = 1;
1409$x
1410"#;
1411
1412        let mut parser = Parser::new(code);
1413        let ast = must(parser.parse());
1414        let provider = CompletionProvider::new(&ast);
1415
1416        // position at end of file
1417        let context = provider.analyze_context(code, code.len());
1418        assert_eq!(context.current_package, "Foo");
1419    }
1420
1421    #[test]
1422    fn test_package_block_detection() {
1423        let code = r#"package Foo {
1424    my $x;
1425    $x;
1426}
1427package Bar;
1428$"#;
1429
1430        let mut parser = Parser::new(code);
1431        let ast = must(parser.parse());
1432        let provider = CompletionProvider::new(&ast);
1433
1434        // Inside Foo block
1435        let pos_foo = must_some(code.find("$x;")) + 2; // position after $x
1436        let ctx_foo = provider.analyze_context(code, pos_foo);
1437        assert_eq!(ctx_foo.current_package, "Foo");
1438
1439        // After block, in Bar package
1440        let pos_bar = code.len();
1441        let ctx_bar = provider.analyze_context(code, pos_bar);
1442        assert_eq!(ctx_bar.current_package, "Bar");
1443    }
1444
1445    #[test]
1446    fn test_incomplete_nested_block_scope_context() {
1447        let code = concat!(
1448            "my $file_var = 0;\n",
1449            "sub process {\n",
1450            "    my $sub_var = 1;\n",
1451            "    if (1) {\n",
1452            "        my $block_var = 2;\n",
1453            "        $"
1454        );
1455
1456        let mut parser = Parser::new(code);
1457        let ast = must(parser.parse());
1458        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1459        let context = provider.analyze_context(code, code.len());
1460
1461        let sub_scope = must_some(
1462            provider
1463                .symbol_table
1464                .symbols
1465                .get("sub_var")
1466                .and_then(|symbols| symbols.first())
1467                .map(|symbol| symbol.scope_id),
1468        );
1469        let block_scope = must_some(
1470            provider
1471                .symbol_table
1472                .symbols
1473                .get("block_var")
1474                .and_then(|symbols| symbols.first())
1475                .map(|symbol| symbol.scope_id),
1476        );
1477
1478        assert_eq!(
1479            context.cursor_scope_id, block_scope,
1480            "expected cursor scope to match block_var scope in incomplete nested block; cursor={:?} sub={:?} block={:?}",
1481            context.cursor_scope_id, sub_scope, block_scope
1482        );
1483    }
1484
1485    #[test]
1486    fn test_incomplete_nested_block_variable_sorting() {
1487        let code = concat!(
1488            "my $file_var = 0;\n",
1489            "sub process {\n",
1490            "    my $sub_var = 1;\n",
1491            "    if (1) {\n",
1492            "        my $block_var = 2;\n",
1493            "        $"
1494        );
1495
1496        let mut parser = Parser::new(code);
1497        let ast = must(parser.parse());
1498        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1499        let completions = provider.get_completions(code, code.len());
1500
1501        let block_item =
1502            must_some(completions.iter().find(|completion| completion.label == "$block_var"));
1503        let sub_item =
1504            must_some(completions.iter().find(|completion| completion.label == "$sub_var"));
1505
1506        assert!(
1507            block_item.sort_text < sub_item.sort_text,
1508            "expected incomplete block variable to outrank parent variable, got block={:?} sub={:?}",
1509            block_item.sort_text,
1510            sub_item.sort_text
1511        );
1512    }
1513
1514    #[test]
1515    fn test_package_member_completion() {
1516        // Create workspace index with a module exporting a function
1517        let index = Arc::new(WorkspaceIndex::new());
1518        let module_uri = must(Url::parse("file:///workspace/MyModule.pm"));
1519        let module_code = r#"package MyModule;
1520our @EXPORT = qw(exported_sub);
1521sub exported_sub { }
1522sub internal_sub { }
15231;
1524"#;
1525        must(index.index_file(module_uri, module_code.to_string()));
1526
1527        // Code that triggers package completion
1528        let code = "use MyModule;\nMyModule::";
1529        let mut parser = Parser::new(code);
1530        let ast = must(parser.parse());
1531
1532        let provider = CompletionProvider::new_with_index(&ast, Some(index));
1533        let completions = provider.get_completions(code, code.len());
1534
1535        assert!(
1536            completions.iter().any(|c| c.label == "exported_sub"),
1537            "should suggest exported_sub"
1538        );
1539        let exported_sub =
1540            must_some(completions.iter().find(|completion| completion.label == "exported_sub"));
1541        let documentation = must_some(exported_sub.documentation.as_deref());
1542        assert!(
1543            documentation.contains("MyModule::exported_sub"),
1544            "expected package member doc to mention qualified symbol, got: {documentation:?}"
1545        );
1546    }
1547
1548    #[test]
1549    fn test_moo_accessor_method_completion() {
1550        let code = r#"
1551package Example::User;
1552use Moo;
1553
1554has 'name' => (is => 'ro', isa => 'Str');
1555
1556sub greet {
1557    my $self = shift;
1558    return $self->name;
1559}
1560"#;
1561
1562        let mut parser = Parser::new(code);
1563        let ast = must(parser.parse());
1564        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1565
1566        let synthesized = provider
1567            .symbol_table
1568            .symbols
1569            .get("name")
1570            .map(|symbols| symbols.iter().any(|symbol| symbol.kind == SymbolKind::Subroutine))
1571            .unwrap_or(false);
1572        assert!(synthesized, "expected synthesized `name` subroutine symbol in symbol table");
1573
1574        let pos = must_some(code.find("$self->name")) + "$self->".len();
1575        let completions = provider.get_completions(code, pos);
1576
1577        assert!(
1578            completions.iter().any(|item| item.label == "name"),
1579            "expected synthesized Moo accessor `name` in method completion"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_moo_accessor_completion_shows_isa_type() {
1585        let code = r#"
1586package Example::User;
1587use Moo;
1588
1589has 'name' => (is => 'ro', isa => 'Str');
1590has 'age'  => (is => 'rw', isa => 'Int');
1591
1592sub greet {
1593    my $self = shift;
1594    $self->
1595}
1596"#;
1597
1598        let mut parser = Parser::new(code);
1599        let ast = must(parser.parse());
1600        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1601
1602        let pos = must_some(code.find("$self->")) + "$self->".len();
1603        let completions = provider.get_completions(code, pos);
1604
1605        // name accessor should appear with isa type in documentation
1606        let name_item = must_some(completions.iter().find(|c| c.label == "name"));
1607        let name_doc = must_some(name_item.documentation.as_deref());
1608        assert!(
1609            name_doc.contains("Str"),
1610            "expected `Str` type in name accessor documentation, got: {name_doc:?}"
1611        );
1612
1613        // age accessor should appear with isa type in documentation
1614        let age_item = must_some(completions.iter().find(|c| c.label == "age"));
1615        let age_doc = must_some(age_item.documentation.as_deref());
1616        assert!(
1617            age_doc.contains("Int"),
1618            "expected `Int` type in age accessor documentation, got: {age_doc:?}"
1619        );
1620
1621        // detail should indicate it's a Moo/Moose accessor, not just "method"
1622        let name_detail = must_some(name_item.detail.as_deref());
1623        assert!(
1624            name_detail.contains("accessor"),
1625            "expected 'accessor' in detail for Moo attribute, got: {name_detail:?}"
1626        );
1627    }
1628
1629    #[test]
1630    fn test_moose_accessor_completion_shows_isa_type() {
1631        let code = r#"
1632package Example::Animal;
1633use Moose;
1634
1635has 'species' => (is => 'ro', isa => 'Str', required => 1);
1636
1637sub describe {
1638    my $self = shift;
1639    $self->
1640}
1641"#;
1642
1643        let mut parser = Parser::new(code);
1644        let ast = must(parser.parse());
1645        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1646
1647        let pos = must_some(code.find("$self->")) + "$self->".len();
1648        let completions = provider.get_completions(code, pos);
1649
1650        let species_item = must_some(completions.iter().find(|c| c.label == "species"));
1651        let species_doc = must_some(species_item.documentation.as_deref());
1652        assert!(
1653            species_doc.contains("Str"),
1654            "expected `Str` type in species accessor documentation, got: {species_doc:?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_moo_has_option_key_completion() {
1660        let code = r#"
1661use Moo;
1662has 'name' => (re
1663"#;
1664
1665        let mut parser = Parser::new(code);
1666        let ast = must(parser.parse());
1667        let provider = CompletionProvider::new_with_index_and_source(&ast, code, None);
1668
1669        let completions = provider.get_completions(code, code.len());
1670
1671        assert!(
1672            completions.iter().any(|item| item.label == "required"),
1673            "expected `required` option completion inside has(...) context"
1674        );
1675        assert!(
1676            completions.iter().any(|item| item.label == "reader"),
1677            "expected `reader` option completion inside has(...) context"
1678        );
1679    }
1680
1681    #[test]
1682    fn test_regex_completion_binding_operator() {
1683        // Cursor right after the opening slash of a regex
1684        let code = r#"my $x = "hello"; $x =~ /"#;
1685
1686        let mut parser = Parser::new(code);
1687        let ast = must(parser.parse());
1688        let provider = CompletionProvider::new(&ast);
1689        let completions = provider.get_completions(code, code.len());
1690
1691        // Should contain regex constructs
1692        assert!(
1693            completions.iter().any(|c| c.label == "\\d"),
1694            "expected \\d regex completion inside =~ /.../"
1695        );
1696        assert!(
1697            completions.iter().any(|c| c.label == "\\w"),
1698            "expected \\w regex completion inside =~ /.../"
1699        );
1700        assert!(
1701            completions.iter().any(|c| c.label == "(?:...)"),
1702            "expected non-capturing group regex completion"
1703        );
1704    }
1705
1706    #[test]
1707    fn test_regex_completion_negated_binding() {
1708        let code = r#"my $x = "test"; $x !~ /"#;
1709
1710        let mut parser = Parser::new(code);
1711        let ast = must(parser.parse());
1712        let provider = CompletionProvider::new(&ast);
1713        let completions = provider.get_completions(code, code.len());
1714
1715        assert!(
1716            completions.iter().any(|c| c.label == "\\d"),
1717            "expected regex completions after !~"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_regex_completion_m_operator() {
1723        let code = "if ($line =~ m/";
1724
1725        let mut parser = Parser::new(code);
1726        let ast = must(parser.parse());
1727        let provider = CompletionProvider::new(&ast);
1728        let completions = provider.get_completions(code, code.len());
1729
1730        assert!(
1731            completions.iter().any(|c| c.label == "\\d"),
1732            "expected regex completions inside m/.../"
1733        );
1734        assert!(
1735            completions.iter().any(|c| c.label == "^"),
1736            "expected anchor completions inside m/.../"
1737        );
1738    }
1739
1740    #[test]
1741    fn test_regex_completion_qr_operator() {
1742        let code = "my $re = qr/";
1743
1744        let mut parser = Parser::new(code);
1745        let ast = must(parser.parse());
1746        let provider = CompletionProvider::new(&ast);
1747        let completions = provider.get_completions(code, code.len());
1748
1749        assert!(
1750            completions.iter().any(|c| c.label == "\\d+"),
1751            "expected common pattern completions inside qr/.../"
1752        );
1753        assert!(
1754            completions.iter().any(|c| c.label == "(?=...)"),
1755            "expected lookahead group completion inside qr/.../"
1756        );
1757    }
1758
1759    #[test]
1760    fn test_regex_completion_s_operator() {
1761        let code = "($line = $input) =~ s/";
1762
1763        let mut parser = Parser::new(code);
1764        let ast = must(parser.parse());
1765        let provider = CompletionProvider::new(&ast);
1766        let completions = provider.get_completions(code, code.len());
1767
1768        assert!(
1769            completions.iter().any(|c| c.label == "\\s+"),
1770            "expected common pattern completions inside s/.../"
1771        );
1772    }
1773
1774    #[test]
1775    fn test_regex_completion_has_all_categories() {
1776        let code = r#"$x =~ /"#;
1777
1778        let mut parser = Parser::new(code);
1779        let ast = must(parser.parse());
1780        let provider = CompletionProvider::new(&ast);
1781        let completions = provider.get_completions(code, code.len());
1782
1783        // Character classes
1784        assert!(completions.iter().any(|c| c.label == "\\d"));
1785        assert!(completions.iter().any(|c| c.label == "\\D"));
1786        assert!(completions.iter().any(|c| c.label == "\\w"));
1787        assert!(completions.iter().any(|c| c.label == "\\W"));
1788        assert!(completions.iter().any(|c| c.label == "\\s"));
1789        assert!(completions.iter().any(|c| c.label == "\\S"));
1790        assert!(completions.iter().any(|c| c.label == "[...]"));
1791        assert!(completions.iter().any(|c| c.label == "[^...]"));
1792
1793        // Anchors
1794        assert!(completions.iter().any(|c| c.label == "^"));
1795        assert!(completions.iter().any(|c| c.label == "$"));
1796        assert!(completions.iter().any(|c| c.label == "\\b"));
1797        assert!(completions.iter().any(|c| c.label == "\\B"));
1798        assert!(completions.iter().any(|c| c.label == "\\A"));
1799        assert!(completions.iter().any(|c| c.label == "\\z"));
1800        assert!(completions.iter().any(|c| c.label == "\\Z"));
1801
1802        // Quantifiers
1803        assert!(completions.iter().any(|c| c.label == "*"));
1804        assert!(completions.iter().any(|c| c.label == "+"));
1805        assert!(completions.iter().any(|c| c.label == "?"));
1806        assert!(completions.iter().any(|c| c.label == "{n}"));
1807        assert!(completions.iter().any(|c| c.label == "{n,}"));
1808        assert!(completions.iter().any(|c| c.label == "{n,m}"));
1809
1810        // Groups
1811        assert!(completions.iter().any(|c| c.label == "(...)"));
1812        assert!(completions.iter().any(|c| c.label == "(?:...)"));
1813        assert!(completions.iter().any(|c| c.label == "(?=...)"));
1814        assert!(completions.iter().any(|c| c.label == "(?!...)"));
1815        assert!(completions.iter().any(|c| c.label == "(?<=...)"));
1816        assert!(completions.iter().any(|c| c.label == "(?<!...)"));
1817
1818        // Common patterns
1819        assert!(completions.iter().any(|c| c.label == "\\d+"));
1820        assert!(completions.iter().any(|c| c.label == "\\w+"));
1821        assert!(completions.iter().any(|c| c.label == "\\s+"));
1822        assert!(completions.iter().any(|c| c.label == ".*?"));
1823        assert!(completions.iter().any(|c| c.label == ".+?"));
1824    }
1825
1826    #[test]
1827    fn test_regex_completion_items_have_correct_kind() {
1828        let code = r#"$x =~ /"#;
1829
1830        let mut parser = Parser::new(code);
1831        let ast = must(parser.parse());
1832        let provider = CompletionProvider::new(&ast);
1833        let completions = provider.get_completions(code, code.len());
1834
1835        for item in &completions {
1836            assert_eq!(
1837                item.kind,
1838                CompletionItemKind::Snippet,
1839                "regex completion '{}' should be Snippet kind",
1840                item.label
1841            );
1842        }
1843    }
1844
1845    #[test]
1846    fn test_regex_completion_items_have_documentation() {
1847        let code = r#"$x =~ /"#;
1848
1849        let mut parser = Parser::new(code);
1850        let ast = must(parser.parse());
1851        let provider = CompletionProvider::new(&ast);
1852        let completions = provider.get_completions(code, code.len());
1853
1854        for item in &completions {
1855            assert!(
1856                item.documentation.is_some(),
1857                "regex completion '{}' should have documentation",
1858                item.label
1859            );
1860            assert!(item.detail.is_some(), "regex completion '{}' should have detail", item.label);
1861        }
1862    }
1863
1864    #[test]
1865    fn test_regex_completion_not_in_normal_context() {
1866        // Outside regex context, should not get regex completions
1867        let code = "my $x = 1;\n";
1868
1869        let mut parser = Parser::new(code);
1870        let ast = must(parser.parse());
1871        let provider = CompletionProvider::new(&ast);
1872        let completions = provider.get_completions(code, code.len());
1873
1874        assert!(
1875            !completions.iter().any(|c| c.label == "\\d"),
1876            "regex completions should NOT appear outside regex context"
1877        );
1878    }
1879
1880    #[test]
1881    fn test_is_in_regex_binding_operator() {
1882        let code = r#"$x =~ /hello"#;
1883        assert!(CompletionProvider::is_in_regex(code, code.len()));
1884    }
1885
1886    #[test]
1887    fn test_is_in_regex_m_operator() {
1888        let code = "m/pattern";
1889        assert!(CompletionProvider::is_in_regex(code, code.len()));
1890    }
1891
1892    #[test]
1893    fn test_is_in_regex_qr_operator() {
1894        let code = "my $re = qr/pattern";
1895        assert!(CompletionProvider::is_in_regex(code, code.len()));
1896    }
1897
1898    #[test]
1899    fn test_is_in_regex_s_operator() {
1900        let code = "$line =~ s/old";
1901        assert!(CompletionProvider::is_in_regex(code, code.len()));
1902    }
1903
1904    #[test]
1905    fn test_is_in_regex_keyword_operator() {
1906        let code = "$x or /pattern";
1907        assert!(CompletionProvider::is_in_regex(code, code.len()));
1908    }
1909
1910    #[test]
1911    fn test_is_not_in_regex_division() {
1912        // Division should NOT be detected as regex
1913        let code = "my $result = $x / $y";
1914        // Position after "$x / $" -- should not be regex because $ precedes /
1915        // but our heuristic checks pre_slash context
1916        assert!(
1917            !CompletionProvider::is_in_regex(code, code.len()),
1918            "division should not be detected as regex context"
1919        );
1920    }
1921
1922    #[test]
1923    fn test_regex_completion_preserves_sigil_completions_in_interpolation() {
1924        // Cursor is inside the regex body at the end of `$fo` — before the
1925        // closing `/`. Variable completions must be offered, not flag completions.
1926        let code = r#"my $foo = 1; my $bar = qr/^$fo/"#;
1927        // Position just before the closing '/'
1928        let pos = code.len() - 1;
1929
1930        let mut parser = Parser::new(code);
1931        let ast = must(parser.parse());
1932        let provider = CompletionProvider::new(&ast);
1933        let completions = provider.get_completions(code, pos);
1934
1935        assert!(
1936            completions.iter().any(|item| item.label == "$foo"),
1937            "expected interpolated regex variables to keep scalar completions"
1938        );
1939    }
1940
1941    #[test]
1942    fn test_regex_completion_replaces_escape_prefix_range() {
1943        let code = r#"$x =~ /\d"#;
1944
1945        let mut parser = Parser::new(code);
1946        let ast = must(parser.parse());
1947        let provider = CompletionProvider::new(&ast);
1948        let completions = provider.get_completions(code, code.len());
1949
1950        let item = must_some(completions.iter().find(|completion| completion.label == r"\d"));
1951        assert_eq!(
1952            item.text_edit_range,
1953            Some((code.len() - r"\d".len(), code.len())),
1954            "expected regex completion to replace the typed escape sequence"
1955        );
1956    }
1957
1958    #[test]
1959    fn test_regex_completion_replaces_group_prefix_range() {
1960        let code = r#"$x =~ /(?: "#;
1961        let code = code.trim_end();
1962
1963        let mut parser = Parser::new(code);
1964        let ast = must(parser.parse());
1965        let provider = CompletionProvider::new(&ast);
1966        let completions = provider.get_completions(code, code.len());
1967
1968        let item = must_some(completions.iter().find(|completion| completion.label == "(?:...)"));
1969        assert_eq!(
1970            item.text_edit_range,
1971            Some((code.len() - "(?:".len(), code.len())),
1972            "expected regex completion to replace the typed group opener"
1973        );
1974    }
1975
1976    #[test]
1977    fn test_detect_use_qw_import_context_basic() {
1978        // Cursor right after opening paren in qw()
1979        let code = "use MyModule qw(";
1980        let result = CompletionProvider::detect_use_qw_import_context(code, code.len());
1981        assert!(result.is_some(), "should detect qw() import context");
1982        let (module, prefix) =
1983            result.as_ref().map(|(m, p)| (m.as_str(), p.as_str())).unwrap_or_default();
1984        assert_eq!(module, "MyModule");
1985        assert_eq!(prefix, "");
1986    }
1987
1988    #[test]
1989    fn test_detect_use_qw_import_context_with_prefix() {
1990        let code = "use File::Basename qw(bas";
1991        let result = CompletionProvider::detect_use_qw_import_context(code, code.len());
1992        assert!(result.is_some(), "should detect qw() import context with prefix");
1993        let (module, prefix) =
1994            result.as_ref().map(|(m, p)| (m.as_str(), p.as_str())).unwrap_or_default();
1995        assert_eq!(module, "File::Basename");
1996        assert_eq!(prefix, "bas");
1997    }
1998
1999    #[test]
2000    fn test_detect_use_qw_import_context_with_existing_imports() {
2001        let code = "use MyModule qw(foo bar ba";
2002        let result = CompletionProvider::detect_use_qw_import_context(code, code.len());
2003        assert!(result.is_some(), "should detect qw() import context after existing imports");
2004        let (module, prefix) =
2005            result.as_ref().map(|(m, p)| (m.as_str(), p.as_str())).unwrap_or_default();
2006        assert_eq!(module, "MyModule");
2007        assert_eq!(prefix, "ba");
2008    }
2009
2010    #[test]
2011    fn test_detect_use_qw_not_after_close() {
2012        // Cursor after the closing paren
2013        let code = "use MyModule qw(foo bar);";
2014        let result = CompletionProvider::detect_use_qw_import_context(code, code.len());
2015        assert!(result.is_none(), "should not detect context after closing paren");
2016    }
2017
2018    #[test]
2019    fn test_detect_use_qw_not_for_pragmas() {
2020        let code = "use strict qw(";
2021        let result = CompletionProvider::detect_use_qw_import_context(code, code.len());
2022        assert!(result.is_none(), "should not detect context for lowercase pragmas");
2023    }
2024
2025    #[test]
2026    fn test_use_qw_import_completion_with_workspace() -> Result<(), Box<dyn std::error::Error>> {
2027        // Create workspace index with a module that has subroutines
2028        let index = Arc::new(WorkspaceIndex::new());
2029        let module_uri = Url::parse("file:///workspace/MyUtils.pm")?;
2030        let module_code = r#"package MyUtils;
2031use Exporter 'import';
2032our @EXPORT_OK = qw(helper_one helper_two);
2033sub helper_one { }
2034sub helper_two { }
2035sub _private_internal { }
20361;
2037"#;
2038        index.index_file(module_uri, module_code.to_string())?;
2039
2040        // Code where user is typing inside qw()
2041        let code = "use MyUtils qw(hel";
2042        let mut parser = Parser::new(code);
2043        let ast = must(parser.parse());
2044
2045        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2046        let completions = provider.get_completions(code, code.len());
2047
2048        assert!(
2049            completions.iter().any(|c| c.label == "helper_one"),
2050            "should suggest helper_one from MyUtils: got {:?}",
2051            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2052        );
2053        assert!(
2054            completions.iter().any(|c| c.label == "helper_two"),
2055            "should suggest helper_two from MyUtils"
2056        );
2057        Ok(())
2058    }
2059
2060    #[test]
2061    fn test_use_qw_import_completion_empty_prefix() -> Result<(), Box<dyn std::error::Error>> {
2062        let index = Arc::new(WorkspaceIndex::new());
2063        let module_uri = Url::parse("file:///workspace/Utils.pm")?;
2064        let module_code = r#"package Utils;
2065sub alpha { }
2066sub beta { }
20671;
2068"#;
2069        index.index_file(module_uri, module_code.to_string())?;
2070
2071        // Empty prefix inside qw()
2072        let code = "use Utils qw(";
2073        let mut parser = Parser::new(code);
2074        let ast = must(parser.parse());
2075
2076        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2077        let completions = provider.get_completions(code, code.len());
2078
2079        assert!(
2080            completions.iter().any(|c| c.label == "alpha"),
2081            "should suggest alpha with empty prefix: got {:?}",
2082            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2083        );
2084        assert!(
2085            completions.iter().any(|c| c.label == "beta"),
2086            "should suggest beta with empty prefix"
2087        );
2088        Ok(())
2089    }
2090
2091    #[test]
2092    fn test_use_qw_import_completion_detail_shows_module() -> Result<(), Box<dyn std::error::Error>>
2093    {
2094        let index = Arc::new(WorkspaceIndex::new());
2095        let module_uri = Url::parse("file:///workspace/MyLib.pm")?;
2096        let module_code = r#"package MyLib;
2097sub do_work { }
20981;
2099"#;
2100        index.index_file(module_uri, module_code.to_string())?;
2101
2102        let code = "use MyLib qw(do";
2103        let mut parser = Parser::new(code);
2104        let ast = must(parser.parse());
2105
2106        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2107        let completions = provider.get_completions(code, code.len());
2108
2109        let do_work = completions.iter().find(|c| c.label == "do_work");
2110        assert!(do_work.is_some(), "should suggest do_work");
2111        let detail = must_some(do_work.and_then(|c| c.detail.as_deref()));
2112        assert!(detail.contains("MyLib"), "detail should mention module name, got: {detail:?}");
2113        Ok(())
2114    }
2115
2116    #[test]
2117    fn test_self_arrow_resolves_workspace_methods() -> Result<(), Box<dyn std::error::Error>> {
2118        // Regression test for issue #2536: $self-> method completion should resolve
2119        // workspace-indexed methods from the current package.
2120        //
2121        // The methods are ONLY in the workspace index (a separate .pm file), not in
2122        // the currently-parsed source. This tests the workspace path specifically:
2123        // `infer_receiver_package` must return `MyService` for `$self->` when
2124        // `context.current_package == "MyService"`.
2125        let index = Arc::new(WorkspaceIndex::new());
2126        let module_uri = Url::parse("file:///workspace/MyService.pm")?;
2127        let module_code = r#"package MyService;
2128sub new { bless {}, shift }
2129sub process_request { }
2130sub validate_input { }
21311;
2132"#;
2133        index.index_file(module_uri, module_code.to_string())?;
2134
2135        // The currently-edited file is in MyService but does NOT define
2136        // process_request or validate_input locally — they are workspace-only.
2137        let code = r#"package MyService;
2138sub run {
2139    my $self = shift;
2140    $self->"#;
2141        let mut parser = Parser::new(code);
2142        let ast = must(parser.parse());
2143
2144        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2145        let completions = provider.get_completions(code, code.len());
2146
2147        assert!(
2148            completions.iter().any(|c| c.label == "process_request"),
2149            "$self-> should suggest process_request from workspace index; got: {:?}",
2150            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2151        );
2152        assert!(
2153            completions.iter().any(|c| c.label == "validate_input"),
2154            "$self-> should suggest validate_input from workspace index"
2155        );
2156        Ok(())
2157    }
2158
2159    #[test]
2160    fn test_this_arrow_resolves_workspace_methods() -> Result<(), Box<dyn std::error::Error>> {
2161        // Same as above but using $this as the invocant variable.
2162        let index = Arc::new(WorkspaceIndex::new());
2163        let module_uri = Url::parse("file:///workspace/MyHandler.pm")?;
2164        let module_code = r#"package MyHandler;
2165sub new { bless {}, shift }
2166sub handle { }
21671;
2168"#;
2169        index.index_file(module_uri, module_code.to_string())?;
2170
2171        // Only `run` is in the edited file; `handle` lives only in the workspace index.
2172        let code = r#"package MyHandler;
2173sub run {
2174    my $this = shift;
2175    $this->"#;
2176        let mut parser = Parser::new(code);
2177        let ast = must(parser.parse());
2178
2179        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2180        let completions = provider.get_completions(code, code.len());
2181
2182        assert!(
2183            completions.iter().any(|c| c.label == "handle"),
2184            "$this-> should suggest handle from workspace index; got: {:?}",
2185            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2186        );
2187        Ok(())
2188    }
2189
2190    #[test]
2191    fn test_self_arrow_in_main_package_does_not_resolve() -> Result<(), Box<dyn std::error::Error>>
2192    {
2193        // Edge case: $self-> in the main package should NOT resolve to any package methods.
2194        // The guard condition `context.current_package != "main"` prevents incorrect
2195        // suggestions when the user is in script-level code.
2196        let index = Arc::new(WorkspaceIndex::new());
2197        let module_uri = Url::parse("file:///workspace/MyLib.pm")?;
2198        let module_code = r#"package MyLib;
2199sub new { bless {}, shift }
2200sub helper { }
22011;
2202"#;
2203        index.index_file(module_uri, module_code.to_string())?;
2204
2205        // Code is at package main (implicit), so $self-> should not resolve
2206        let code = r#"sub run {
2207    my $self = shift;
2208    $self->"#;
2209        let mut parser = Parser::new(code);
2210        let ast = must(parser.parse());
2211
2212        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2213        let completions = provider.get_completions(code, code.len());
2214
2215        // Should NOT suggest MyLib methods just because the variable is named $self
2216        assert!(
2217            !completions.iter().any(|c| c.label == "helper"),
2218            "$self-> in main package should not suggest methods from other packages"
2219        );
2220        Ok(())
2221    }
2222
2223    // -------------------------------------------------------------------------
2224    // Tests for is_use_statement_context and add_use_module_completions
2225    // -------------------------------------------------------------------------
2226
2227    #[test]
2228    fn test_use_statement_context_after_use_keyword() -> Result<(), Box<dyn std::error::Error>> {
2229        // "use " with cursor right after space — empty prefix, should trigger module completion
2230        let index = Arc::new(WorkspaceIndex::new());
2231        let uri = Url::parse("file:///lib/MyApp.pm")?;
2232        index.index_file(uri, "package MyApp;\n1;\n".to_string())?;
2233        let code = "use ";
2234        let mut parser = Parser::new(code);
2235        let ast = must(parser.parse());
2236        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2237        let completions = provider.get_completions(code, code.len());
2238        assert!(
2239            completions.iter().any(|c| c.label == "MyApp" && c.kind == CompletionItemKind::Module),
2240            "use <cursor> should suggest workspace module names; got: {:?}",
2241            completions.iter().map(|c| (&c.label, &c.kind)).collect::<Vec<_>>()
2242        );
2243        Ok(())
2244    }
2245
2246    #[test]
2247    fn test_use_statement_context_with_prefix() -> Result<(), Box<dyn std::error::Error>> {
2248        // "use MyA" — prefix filtering should narrow to MyApp, not OtherLib
2249        let index = Arc::new(WorkspaceIndex::new());
2250        index
2251            .index_file(Url::parse("file:///lib/MyApp.pm")?, "package MyApp;\n1;\n".to_string())?;
2252        index.index_file(
2253            Url::parse("file:///lib/OtherLib.pm")?,
2254            "package OtherLib;\n1;\n".to_string(),
2255        )?;
2256        let code = "use MyA";
2257        let mut parser = Parser::new(code);
2258        let ast = must(parser.parse());
2259        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2260        let completions = provider.get_completions(code, code.len());
2261        assert!(
2262            completions.iter().any(|c| c.label == "MyApp" && c.kind == CompletionItemKind::Module),
2263            "use MyA should suggest MyApp with Module kind"
2264        );
2265        assert!(
2266            !completions.iter().any(|c| c.label == "OtherLib"),
2267            "use MyA should not suggest OtherLib"
2268        );
2269        Ok(())
2270    }
2271
2272    #[test]
2273    fn test_use_statement_skips_pragmas() -> Result<(), Box<dyn std::error::Error>> {
2274        // Lowercase-first token after `use` means pragma — no module completion.
2275        // The index is populated with a Module-kind package so that if the lowercase
2276        // guard in is_use_statement_context were absent the test would fail (not
2277        // vacuously pass due to an empty index).
2278        let index = Arc::new(WorkspaceIndex::new());
2279        index.index_file(
2280            Url::parse("file:///lib/Strict.pm")?,
2281            "package Strict;\n1;\n".to_string(),
2282        )?;
2283        let code = "use strict";
2284        let mut parser = Parser::new(code);
2285        let ast = must(parser.parse());
2286        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2287        let completions = provider.get_completions(code, code.len());
2288        assert!(
2289            !completions.iter().any(|c| c.kind == CompletionItemKind::Module),
2290            "use strict should not trigger module completions; got: {:?}",
2291            completions.iter().map(|c| (&c.label, &c.kind)).collect::<Vec<_>>()
2292        );
2293        Ok(())
2294    }
2295
2296    #[test]
2297    fn test_use_statement_skips_past_module_name_at_qw() -> Result<(), Box<dyn std::error::Error>> {
2298        // Cursor inside qw list should NOT trigger module-name completion.
2299        // The index is populated so the test is non-vacuous: if the qw-dispatch
2300        // branch were removed, add_use_module_completions would fire and the
2301        // Module-kind assertion would fail.
2302        let index = Arc::new(WorkspaceIndex::new());
2303        index.index_file(
2304            Url::parse("file:///lib/Module.pm")?,
2305            "package Module;\nsub foo {}\n1;\n".to_string(),
2306        )?;
2307        let code = "use Module qw(foo";
2308        let mut parser = Parser::new(code);
2309        let ast = must(parser.parse());
2310        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2311        let completions = provider.get_completions(code, code.len());
2312        // This context routes to qw-import completions (Function kind), not module-name
2313        // completions (Module kind).
2314        assert!(
2315            !completions.iter().any(|c| c.kind == CompletionItemKind::Module),
2316            "cursor inside qw() should not get module-name completions; got: {:?}",
2317            completions.iter().map(|c| (&c.label, &c.kind)).collect::<Vec<_>>()
2318        );
2319        Ok(())
2320    }
2321
2322    #[test]
2323    fn test_require_statement_triggers_module_completion() -> Result<(), Box<dyn std::error::Error>>
2324    {
2325        let index = Arc::new(WorkspaceIndex::new());
2326        index
2327            .index_file(Url::parse("file:///lib/Utils.pm")?, "package Utils;\n1;\n".to_string())?;
2328        let code = "require Ut";
2329        let mut parser = Parser::new(code);
2330        let ast = must(parser.parse());
2331        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2332        let completions = provider.get_completions(code, code.len());
2333        assert!(
2334            completions.iter().any(|c| c.label == "Utils" && c.kind == CompletionItemKind::Module),
2335            "require Ut should suggest Utils with Module kind; got: {:?}",
2336            completions.iter().map(|c| (&c.label, &c.kind)).collect::<Vec<_>>()
2337        );
2338        Ok(())
2339    }
2340
2341    #[test]
2342    fn test_use_module_deduplication() -> Result<(), Box<dyn std::error::Error>> {
2343        // Two files declaring the same package should produce one completion, not two
2344        let index = Arc::new(WorkspaceIndex::new());
2345        index
2346            .index_file(Url::parse("file:///lib/MyApp.pm")?, "package MyApp;\n1;\n".to_string())?;
2347        index.index_file(
2348            Url::parse("file:///lib/MyApp2.pm")?,
2349            "package MyApp;\n1;\n".to_string(), // duplicate package name
2350        )?;
2351        let code = "use MyA";
2352        let mut parser = Parser::new(code);
2353        let ast = must(parser.parse());
2354        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2355        let completions = provider.get_completions(code, code.len());
2356        let myapp_count = completions.iter().filter(|c| c.label == "MyApp").count();
2357        assert_eq!(
2358            myapp_count, 1,
2359            "Duplicate package declarations should produce exactly one completion"
2360        );
2361        Ok(())
2362    }
2363
2364    #[test]
2365    fn test_use_module_non_use_context_excluded() -> Result<(), Box<dyn std::error::Error>> {
2366        // Outside a use/require statement, module-priority sort_text should NOT appear.
2367        // add_use_module_completions gates on in_use_statement; its "1_" sort_text
2368        // prefix is the marker we check here.
2369        let index = Arc::new(WorkspaceIndex::new());
2370        index.index_file(
2371            Url::parse("file:///lib/MyApp.pm")?,
2372            "package MyApp;\nsub hello {}\n1;\n".to_string(),
2373        )?;
2374        let code = "my $x = MyA";
2375        let mut parser = Parser::new(code);
2376        let ast = must(parser.parse());
2377        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2378        let completions = provider.get_completions(code, code.len());
2379        // The "1_MyApp" sort_text is only emitted by add_use_module_completions,
2380        // which is guarded by in_use_statement. It must not appear outside that context.
2381        assert!(
2382            !completions.iter().any(|c| c.sort_text.as_deref() == Some("1_MyApp")),
2383            "Module-priority sort_text should only appear in use context"
2384        );
2385        Ok(())
2386    }
2387
2388    #[test]
2389    fn test_use_statement_past_semicolon_excluded() -> Result<(), Box<dyn std::error::Error>> {
2390        // Cursor at the end of `use Module;` — the semicolon guard in
2391        // is_use_statement_context must suppress module-name completions.
2392        // Without the `;` check the cursor would be considered still inside
2393        // the use statement and would show stale module suggestions.
2394        let index = Arc::new(WorkspaceIndex::new());
2395        index.index_file(
2396            Url::parse("file:///lib/Module.pm")?,
2397            "package Module;\n1;\n".to_string(),
2398        )?;
2399        let code = "use Module;";
2400        let mut parser = Parser::new(code);
2401        let ast = must(parser.parse());
2402        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2403        let completions = provider.get_completions(code, code.len());
2404        assert!(
2405            !completions.iter().any(|c| c.kind == CompletionItemKind::Module),
2406            "cursor after `use Module;` should not trigger module-name completions; got: {:?}",
2407            completions.iter().map(|c| (&c.label, &c.kind)).collect::<Vec<_>>()
2408        );
2409        Ok(())
2410    }
2411
2412    // ── Gap 1: Named capture group in regex_patterns ─────────────────────────
2413
2414    #[test]
2415    fn test_regex_named_capture_completion() {
2416        // Cursor inside an empty regex body — named capture should be offered.
2417        let code = r#"$x =~ /"#;
2418        let mut parser = Parser::new(code);
2419        let ast = must(parser.parse());
2420        let provider = CompletionProvider::new(&ast);
2421        let completions = provider.get_completions(code, code.len());
2422        assert!(
2423            completions.iter().any(|c| c.label == "(?<name>...)"),
2424            "expected named capture group in regex completions; got: {:?}",
2425            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2426        );
2427    }
2428
2429    #[test]
2430    fn test_regex_named_capture_prefix_disambig() {
2431        // Typing `(?<` inside a regex → both lookbehind and named capture offered.
2432        let code = r#"$x =~ /(?<"#;
2433        let mut parser = Parser::new(code);
2434        let ast = must(parser.parse());
2435        let provider = CompletionProvider::new(&ast);
2436        let completions = provider.get_completions(code, code.len());
2437        assert!(
2438            completions.iter().any(|c| c.label == "(?<=...)"),
2439            "expected lookbehind when prefix is (?<"
2440        );
2441        assert!(
2442            completions.iter().any(|c| c.label == "(?<name>...)"),
2443            "expected named capture when prefix is (?<"
2444        );
2445    }
2446
2447    #[test]
2448    fn test_regex_named_capture_prefix_lookbehind_only() {
2449        // Typing `(?<=` — only the lookbehind should match (named capture label
2450        // does not start with `(?<=`).
2451        let code = r#"$x =~ /(?<="#;
2452        let mut parser = Parser::new(code);
2453        let ast = must(parser.parse());
2454        let provider = CompletionProvider::new(&ast);
2455        let completions = provider.get_completions(code, code.len());
2456        assert!(
2457            completions.iter().any(|c| c.label == "(?<=...)"),
2458            "expected lookbehind for prefix (?<="
2459        );
2460        assert!(
2461            !completions.iter().any(|c| c.label == "(?<name>...)"),
2462            "named capture should NOT appear for prefix (?<= (label doesn't start with (?<=)"
2463        );
2464    }
2465
2466    // ── Gap 2: is_in_regex_flags heuristic ───────────────────────────────────
2467
2468    #[test]
2469    fn test_is_in_regex_flags_after_close_slash() {
2470        // Cursor immediately after the closing `/` of a regex.
2471        let code = "$x =~ /foo/";
2472        assert!(
2473            CompletionProvider::is_in_regex_flags(code, code.len()),
2474            "cursor right after closing / should be in regex-flags context"
2475        );
2476    }
2477
2478    #[test]
2479    fn test_is_in_regex_flags_after_partial_flag() {
2480        // Cursor after one already-typed flag character.
2481        let code = "m/foo/i";
2482        assert!(
2483            CompletionProvider::is_in_regex_flags(code, code.len()),
2484            "cursor after /i should still be in regex-flags context"
2485        );
2486    }
2487
2488    #[test]
2489    fn test_is_in_regex_flags_s_operator() {
2490        let code = "s/foo/bar/g";
2491        assert!(
2492            CompletionProvider::is_in_regex_flags(code, code.len()),
2493            "s/// with /g flag should be in regex-flags context"
2494        );
2495    }
2496
2497    #[test]
2498    fn test_is_not_in_regex_flags_division() {
2499        // Plain division — must not be treated as regex flags.
2500        let code = "my $x = $a / $b /";
2501        assert!(
2502            !CompletionProvider::is_in_regex_flags(code, code.len()),
2503            "division should not be detected as regex-flags context"
2504        );
2505    }
2506
2507    #[test]
2508    fn test_regex_flag_completions_after_close() {
2509        // Cursor right after closing `/` — should offer all standard flag letters.
2510        let code = "$x =~ /foo/";
2511        let mut parser = Parser::new(code);
2512        let ast = must(parser.parse());
2513        let provider = CompletionProvider::new(&ast);
2514        let completions = provider.get_completions(code, code.len());
2515        let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
2516        // Standard regex flags per Perl documentation
2517        for flag in &["g", "i", "m", "s", "x", "e", "r", "a", "p"] {
2518            assert!(
2519                labels.contains(flag),
2520                "expected standard regex flag '{flag}' in completions; got: {labels:?}"
2521            );
2522        }
2523    }
2524
2525    #[test]
2526    fn test_regex_flag_completions_skip_already_typed() {
2527        // `g` already typed — completions should include `i` but not `g`.
2528        let code = "$x =~ /foo/g";
2529        let mut parser = Parser::new(code);
2530        let ast = must(parser.parse());
2531        let provider = CompletionProvider::new(&ast);
2532        let completions = provider.get_completions(code, code.len());
2533        let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
2534        assert!(!labels.contains(&"g"), "already-typed flag 'g' should be excluded");
2535        assert!(labels.contains(&"i"), "flag 'i' should still be offered");
2536    }
2537
2538    #[test]
2539    fn test_regex_tr_flag_completions() {
2540        // tr/// should offer only c, d, s — not g, i, e.
2541        let code = "tr/a-z/A-Z/";
2542        let mut parser = Parser::new(code);
2543        let ast = must(parser.parse());
2544        let provider = CompletionProvider::new(&ast);
2545        let completions = provider.get_completions(code, code.len());
2546        let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
2547        for flag in &["c", "d", "s"] {
2548            assert!(
2549                labels.contains(flag),
2550                "tr/// flag '{flag}' should be offered; got: {labels:?}"
2551            );
2552        }
2553        for flag in &["g", "i", "e"] {
2554            assert!(!labels.contains(flag), "tr/// should NOT offer '{flag}'; got: {labels:?}");
2555        }
2556    }
2557
2558    #[test]
2559    fn test_regex_tr_binding_operator_flag_completions() {
2560        // `$x =~ tr/.../` should also offer only c, d, s (binding form).
2561        let code = "$x =~ tr/a-z/A-Z/";
2562        let mut parser = Parser::new(code);
2563        let ast = must(parser.parse());
2564        let provider = CompletionProvider::new(&ast);
2565        let completions = provider.get_completions(code, code.len());
2566        let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
2567        for flag in &["c", "d", "s"] {
2568            assert!(
2569                labels.contains(flag),
2570                "tr/// binding flag '{flag}' should be offered; got: {labels:?}"
2571            );
2572        }
2573        assert!(!labels.contains(&"g"), "tr/// should NOT offer 'g'; got: {labels:?}");
2574    }
2575
2576    // ── Gap 3: Statement-level regex operator snippets ───────────────────────
2577
2578    #[test]
2579    fn test_regex_operator_snippets_present() {
2580        let code = "";
2581        let mut parser = Parser::new(code);
2582        let ast = must(parser.parse());
2583        let provider = CompletionProvider::new(&ast);
2584        let completions = provider.get_completions(code, 0);
2585        let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect();
2586        assert!(labels.contains(&"mregex"), "mregex snippet missing; got: {labels:?}");
2587        assert!(labels.contains(&"ssubst"), "ssubst snippet missing; got: {labels:?}");
2588        assert!(labels.contains(&"qrpat"), "qrpat snippet missing; got: {labels:?}");
2589    }
2590
2591    #[test]
2592    fn test_regex_operator_snippet_bodies() {
2593        // Verify the insert_text for each new snippet is syntactically correct.
2594        let code = "mregex";
2595        let mut parser = Parser::new(code);
2596        let ast = must(parser.parse());
2597        let provider = CompletionProvider::new(&ast);
2598        let completions = provider.get_completions(code, code.len());
2599
2600        let mregex = must_some(completions.iter().find(|c| c.label == "mregex"));
2601        let insert = mregex.insert_text.as_deref().unwrap_or_default();
2602        assert!(insert.starts_with("m/"), "mregex body must start with m/; got: {insert:?}");
2603
2604        // Also verify ssubst and qrpat with explicit prefix lookup
2605        let code2 = "ssubst";
2606        let mut parser2 = Parser::new(code2);
2607        let ast2 = must(parser2.parse());
2608        let provider2 = CompletionProvider::new(&ast2);
2609        let completions2 = provider2.get_completions(code2, code2.len());
2610        let ssubst = must_some(completions2.iter().find(|c| c.label == "ssubst"));
2611        let insert2 = ssubst.insert_text.as_deref().unwrap_or_default();
2612        assert!(insert2.starts_with("s/"), "ssubst body must start with s/; got: {insert2:?}");
2613
2614        let code3 = "qrpat";
2615        let mut parser3 = Parser::new(code3);
2616        let ast3 = must(parser3.parse());
2617        let provider3 = CompletionProvider::new(&ast3);
2618        let completions3 = provider3.get_completions(code3, code3.len());
2619        let qrpat = must_some(completions3.iter().find(|c| c.label == "qrpat"));
2620        let insert3 = qrpat.insert_text.as_deref().unwrap_or_default();
2621        assert!(insert3.starts_with("qr/"), "qrpat body must start with qr/; got: {insert3:?}");
2622    }
2623
2624    // ── Dash trigger character tests (#2865) ─────────────────────────────────
2625    // When `-` is a trigger character, context detection must distinguish
2626    // method-call arrows (`->`) from arithmetic/decrement operators.
2627
2628    #[test]
2629    fn test_dash_trigger_fires_method_completion_for_arrow()
2630    -> Result<(), Box<dyn std::error::Error>> {
2631        // `$obj-` (cursor after `-`) — the `-` is the start of `->`, so method
2632        // completions must appear even before the `>` is typed.
2633        // Crucially, the result must be ONLY method completions (Function kind),
2634        // not the entire keyword/snippet list. Without the `-` trigger feature,
2635        // the code returns all completions — this assertion catches that false pass.
2636        let code = r#"package MyService;
2637sub new { bless {}, shift }
2638sub process { }
2639sub validate { }
2640sub run {
2641    my $self = shift;
2642    $self-"#;
2643        let mut parser = Parser::new(code);
2644        let ast = must(parser.parse());
2645        let index = Arc::new(WorkspaceIndex::new());
2646        let module_uri = Url::parse("file:///workspace/MyService.pm")?;
2647        let module_code = "package MyService;\nsub process { }\nsub validate { }\n1;\n";
2648        index.index_file(module_uri, module_code.to_string())?;
2649        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2650        let completions = provider.get_completions(code, code.len());
2651        // Must find method completions from the workspace index
2652        assert!(
2653            completions.iter().any(|c| c.label == "process" || c.label == "validate"),
2654            "dash trigger on `$self-` should produce method completions; got: {:?}",
2655            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2656        );
2657        // Must NOT return the full keyword/snippet dump — only method completions.
2658        // "arrayref", "hashref" are snippets from the generic path; they should not
2659        // appear when the context is a method-call arrow.
2660        assert!(
2661            !completions.iter().any(|c| c.label == "arrayref" || c.label == "hashref"),
2662            "dash trigger on `$self-` must not return generic snippets; got: {:?}",
2663            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2664        );
2665        Ok(())
2666    }
2667
2668    #[test]
2669    fn test_dash_trigger_suppressed_for_subtract_assign() {
2670        // `$x -=` (cursor after `-` in `-=`) — must return NO completions.
2671        let code = "$x -";
2672        let mut parser = Parser::new(code);
2673        let ast = must(parser.parse());
2674        let provider = CompletionProvider::new(&ast);
2675        // Position is at len() which puts cursor right after `-` preceded by space.
2676        let completions = provider.get_completions(code, code.len());
2677        assert!(
2678            completions.is_empty(),
2679            "dash trigger on `$x -` (subtract context) should return no completions; got: {:?}",
2680            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2681        );
2682    }
2683
2684    #[test]
2685    fn test_dash_trigger_suppressed_for_decrement() {
2686        // `$x--` — second `-` is preceded by another `-`, must return NO completions.
2687        // The guard `source[position-2] != b'-'` prevents treating `--` as `->`.
2688        let code = "$x--";
2689        let mut parser = Parser::new(code);
2690        let ast = must(parser.parse());
2691        let provider = CompletionProvider::new(&ast);
2692        // Cursor after the second `-`: preceding char is `-`, not an identifier.
2693        let completions = provider.get_completions(code, code.len());
2694        assert!(
2695            completions.is_empty(),
2696            "dash trigger on `$x--` (decrement context) should return no completions; got: {:?}",
2697            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2698        );
2699    }
2700
2701    #[test]
2702    fn test_dash_trigger_suppressed_for_unary_minus() {
2703        // `my $x = -$y` — unary minus, `-` preceded by space → no completions.
2704        let code = "my $x = -";
2705        let mut parser = Parser::new(code);
2706        let ast = must(parser.parse());
2707        let provider = CompletionProvider::new(&ast);
2708        let completions = provider.get_completions(code, code.len());
2709        assert!(
2710            completions.is_empty(),
2711            "dash trigger on `my $x = -` (unary minus) should return no completions; got: {:?}",
2712            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2713        );
2714    }
2715
2716    #[test]
2717    fn test_dash_trigger_fires_for_hash_deref_arrow() -> Result<(), Box<dyn std::error::Error>> {
2718        // `$hash->{key}` — trigger on `-` in `$hash->`, receiver ends with `h`
2719        // (alphanumeric), should produce method completions (not a generic dump).
2720        let code = r#"package MyService;
2721sub new { bless {}, shift }
2722sub get_data { }
2723sub run {
2724    my $hash = {};
2725    $hash-"#;
2726        let mut parser = Parser::new(code);
2727        let ast = must(parser.parse());
2728        let index = Arc::new(WorkspaceIndex::new());
2729        let module_uri = Url::parse("file:///workspace/MyService.pm")?;
2730        let module_code = "package MyService;\nsub new { }\nsub get_data { }\n1;\n";
2731        index.index_file(module_uri, module_code.to_string())?;
2732        let provider = CompletionProvider::new_with_index(&ast, Some(index));
2733        let completions = provider.get_completions(code, code.len());
2734        assert!(
2735            completions.iter().any(|c| c.label == "get_data" || c.label == "new"),
2736            "dash trigger on `$hash-` should produce completions; got: {:?}",
2737            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2738        );
2739        // Must not return generic snippet dump
2740        assert!(
2741            !completions.iter().any(|c| c.label == "arrayref" || c.label == "hashref"),
2742            "dash trigger on `$hash-` must not return generic snippets; got: {:?}",
2743            completions.iter().map(|c| &c.label).collect::<Vec<_>>()
2744        );
2745        Ok(())
2746    }
2747}