Skip to main content

perl_lsp_inline_completion/
lib.rs

1//! Inline completions provider with deterministic rules and AI backend support.
2//!
3//! This crate provides context-aware inline completions that appear as
4//! ghost text. Deterministic completions are based on patterns; AI-powered
5//! suggestions use the `InlineCompletionBackend` trait for pluggable providers.
6
7use perl_position_tracking::utf16_line_col_to_offset;
8use serde::{Deserialize, Serialize};
9
10const MAX_INLINE_COMPLETION_ITEMS: usize = 5;
11
12/// Prepared context for inline completion suggestions and future AI handoff.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct PreparedInlineCompletionContext {
16    /// Prefix on the current line up to the request position.
17    pub prefix: String,
18    /// Full current line with trailing newline removed.
19    pub current_line: String,
20    /// Closest previous non-empty line, if any.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub previous_non_empty_line: Option<String>,
23    /// Nearest enclosing subroutine name, if one can be inferred.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub current_function: Option<String>,
26    /// Nearest package declaration before the cursor, if any.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub current_package: Option<String>,
29    /// Nearby variables, ordered from closest to farthest.
30    pub variables: Vec<String>,
31    /// Imported modules or pragmas visible before the cursor.
32    pub imports: Vec<String>,
33}
34
35/// Inline completion item (LSP 3.18 preview)
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct InlineCompletionItem {
39    /// The text to be inserted.
40    pub insert_text: String,
41    /// The text to be used for filtering.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub filter_text: Option<String>,
44    /// The range to be replaced by the completion.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub range: Option<lsp_types::Range>,
47    /// An optional command to be executed after the completion is inserted.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub command: Option<lsp_types::Command>,
50}
51
52/// Inline completion list (LSP 3.18 preview)
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct InlineCompletionList {
56    /// The inline completion items.
57    pub items: Vec<InlineCompletionItem>,
58}
59
60// ── AI backend interface ─────────────────────────────────────────────────────
61
62/// Error type for backend operations.
63#[derive(Debug)]
64pub enum BackendError {
65    /// Network or IO error.
66    Transport(String),
67    /// Authentication failure (bad key, expired token).
68    Auth(String),
69    /// Provider returned an error response.
70    Provider(String),
71    /// Request timed out.
72    Timeout,
73    /// Rate limit exceeded.
74    RateLimited,
75    /// Request was cancelled.
76    Cancelled,
77}
78
79impl std::fmt::Display for BackendError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Transport(msg) => write!(f, "transport error: {}", msg),
83            Self::Auth(msg) => write!(f, "auth error: {}", msg),
84            Self::Provider(msg) => write!(f, "provider error: {}", msg),
85            Self::Timeout => write!(f, "request timed out"),
86            Self::RateLimited => write!(f, "rate limit exceeded"),
87            Self::Cancelled => write!(f, "request cancelled"),
88        }
89    }
90}
91
92impl std::error::Error for BackendError {}
93
94/// Request payload sent to an AI completion backend.
95#[derive(Debug, Clone)]
96pub struct BackendRequest {
97    /// Prepared context from the current buffer.
98    pub context: PreparedInlineCompletionContext,
99    /// Maximum tokens to generate.
100    pub max_output_tokens: u32,
101    /// Timeout in milliseconds.
102    pub timeout_ms: u64,
103}
104
105/// A chunk emitted by a streaming backend.
106#[derive(Debug, Clone)]
107pub struct StreamChunk {
108    /// Cumulative candidate text so far (NOT a delta).
109    pub text: String,
110    /// Whether this is the final chunk.
111    pub is_final: bool,
112}
113
114/// Control signal returned by the stream sink callback.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum StreamControl {
117    /// Continue receiving chunks.
118    Continue,
119    /// Stop the stream early.
120    Stop,
121}
122
123/// Trait for AI inline completion backends.
124///
125/// Implementations provide streaming token generation. The default `complete()`
126/// method buffers the stream into a one-shot result, so backends only need to
127/// implement `stream()`.
128///
129/// The trait is sync and callback-based to keep this crate dependency-light
130/// and runtime-agnostic. Network I/O happens in the provider crate.
131pub trait InlineCompletionBackend: Send + Sync {
132    /// One-shot completion: returns the final candidate texts.
133    ///
134    /// Default implementation buffers the stream.
135    fn complete(&self, req: &BackendRequest) -> Result<Vec<String>, BackendError> {
136        let mut final_text = String::new();
137        self.stream(req, &mut |chunk| {
138            final_text = chunk.text.clone();
139            if chunk.is_final { StreamControl::Stop } else { StreamControl::Continue }
140        })?;
141        Ok(if final_text.is_empty() { vec![] } else { vec![final_text] })
142    }
143
144    /// Stream completion chunks to a callback sink.
145    ///
146    /// Each `StreamChunk.text` is **cumulative** — the full candidate so far,
147    /// not a delta. The sink returns `StreamControl::Stop` to cancel early.
148    fn stream(
149        &self,
150        req: &BackendRequest,
151        sink: &mut dyn FnMut(StreamChunk) -> StreamControl,
152    ) -> Result<(), BackendError>;
153}
154
155#[derive(Debug)]
156struct RankedCompletionItem {
157    priority: u8,
158    order: usize,
159    item: InlineCompletionItem,
160}
161
162/// A provider for inline completions.
163pub struct InlineCompletionProvider;
164
165impl Default for InlineCompletionProvider {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl InlineCompletionProvider {
172    /// Creates a new `InlineCompletionProvider`.
173    pub fn new() -> Self {
174        Self
175    }
176
177    /// Get inline completions for the given context
178    pub fn get_inline_completions(
179        &self,
180        text: &str,
181        line: u32,
182        character: u32,
183    ) -> InlineCompletionList {
184        if let Some(context) = self.prepare_context(text, line, character) {
185            let items = self.get_completions_for_context(&context);
186            return InlineCompletionList { items };
187        }
188
189        InlineCompletionList { items: vec![] }
190    }
191
192    /// Prepare surrounding code context for deterministic suggestions and
193    /// future LLM-backed inline completion.
194    pub fn prepare_context(
195        &self,
196        text: &str,
197        line: u32,
198        character: u32,
199    ) -> Option<PreparedInlineCompletionContext> {
200        let line_context = self.line_context_at_position(text, line, character)?;
201        let lines = self.normalized_lines(text);
202        let line_index = usize::try_from(line).ok()?;
203        let (current_function, function_start_line) =
204            self.current_function_context(&lines, line_index);
205        let visible_text = self.visible_text_until_cursor(&lines, line_index, line_context.prefix);
206        let variable_scan_text = self.visible_text_since_line(
207            &lines,
208            function_start_line.unwrap_or(0),
209            line_index,
210            line_context.prefix,
211        );
212
213        Some(PreparedInlineCompletionContext {
214            prefix: line_context.prefix.to_string(),
215            current_line: line_context.current_line.to_string(),
216            previous_non_empty_line: self
217                .previous_non_empty_line(&lines, line_index)
218                .map(str::to_string),
219            current_function,
220            current_package: self.current_package(&lines, line_index),
221            variables: self.collect_variables(&variable_scan_text),
222            imports: self.collect_imports(&visible_text),
223        })
224    }
225
226    fn line_context_at_position<'a>(
227        &self,
228        text: &'a str,
229        line: u32,
230        character: u32,
231    ) -> Option<LineContext<'a>> {
232        let lines = self.normalized_lines(text);
233        let line_index = usize::try_from(line).ok()?;
234        let current_line = *lines.get(line_index)?;
235        let prefix_end = utf16_line_col_to_offset(current_line, 0, character);
236
237        Some(LineContext { prefix: &current_line[..prefix_end], current_line })
238    }
239
240    fn normalized_lines<'a>(&self, text: &'a str) -> Vec<&'a str> {
241        if text.is_empty() {
242            return vec![""];
243        }
244
245        text.split('\n').map(|line| line.strip_suffix('\r').unwrap_or(line)).collect()
246    }
247
248    fn get_completions_for_context(
249        &self,
250        context: &PreparedInlineCompletionContext,
251    ) -> Vec<InlineCompletionItem> {
252        let prefix = context.prefix.as_str();
253        let full_line = context.current_line.as_str();
254        let mut items = Vec::<RankedCompletionItem>::new();
255        let mut sequence = 0usize;
256
257        let mut push_item = |priority: u8, item: InlineCompletionItem| {
258            items.push(RankedCompletionItem { priority, order: sequence, item });
259            sequence += 1;
260        };
261
262        // Rule 1: After `->` suggest `new()`
263        if prefix.ends_with("->") {
264            push_item(
265                0,
266                InlineCompletionItem {
267                    insert_text: "new()".into(),
268                    filter_text: Some("new".into()),
269                    range: None,
270                    command: None,
271                },
272            );
273        }
274
275        // Rule 2: After `use ` suggest common pragmas
276        if prefix.trim_end() == "use" || prefix.ends_with("use ") {
277            // Suggest strict first as it's most common
278            push_item(
279                0,
280                InlineCompletionItem {
281                    insert_text: "strict;".into(),
282                    filter_text: Some("strict".into()),
283                    range: None,
284                    command: None,
285                },
286            );
287
288            push_item(
289                1,
290                InlineCompletionItem {
291                    insert_text: "warnings;".into(),
292                    filter_text: Some("warnings".into()),
293                    range: None,
294                    command: None,
295                },
296            );
297
298            push_item(
299                2,
300                InlineCompletionItem {
301                    insert_text: "feature ':5.36';".into(),
302                    filter_text: Some("feature".into()),
303                    range: None,
304                    command: None,
305                },
306            );
307        }
308
309        // Rule 3: After `sub <name>` without `{`, suggest smart body based on name pattern
310        if let Some(sub_name) = self.match_sub_declaration(prefix) {
311            if !full_line.contains('{') {
312                let body = self.generate_smart_body(&sub_name);
313                push_item(
314                    0,
315                    InlineCompletionItem {
316                        insert_text: format!(" {{\n{}\n}}", body),
317                        filter_text: Some("{".into()),
318                        range: None,
319                        command: None,
320                    },
321                );
322            }
323        }
324
325        // Rule 4: After `my $` suggest common variable patterns
326        if prefix.ends_with("my $") {
327            push_item(
328                0,
329                InlineCompletionItem {
330                    insert_text: "self = shift;".into(),
331                    filter_text: Some("self".into()),
332                    range: None,
333                    command: None,
334                },
335            );
336        }
337
338        // Rule 5: After `package ` suggest common suffix patterns
339        if prefix.ends_with("package ") {
340            push_item(
341                0,
342                InlineCompletionItem {
343                    insert_text: "MyPackage;\n\nuse strict;\nuse warnings;".into(),
344                    filter_text: Some("MyPackage".into()),
345                    range: None,
346                    command: None,
347                },
348            );
349        }
350
351        // Rule 6: After `bless ` suggest common patterns
352        if prefix.ends_with("bless ") {
353            push_item(
354                0,
355                InlineCompletionItem {
356                    insert_text: "$self, $class;".into(),
357                    filter_text: Some("$self".into()),
358                    range: None,
359                    command: None,
360                },
361            );
362        }
363
364        // Rule 7: After `return ` in constructor context
365        if prefix.ends_with("return ") {
366            if let Some(variable) = self.preferred_return_variable(context) {
367                push_item(
368                    0,
369                    InlineCompletionItem {
370                        insert_text: format!("{variable};"),
371                        filter_text: Some(variable),
372                        range: None,
373                        command: None,
374                    },
375                );
376            } else if self.is_in_constructor_context(context.current_function.as_deref(), prefix) {
377                push_item(
378                    1,
379                    InlineCompletionItem {
380                        insert_text: "$self;".into(),
381                        filter_text: Some("$self".into()),
382                        range: None,
383                        command: None,
384                    },
385                );
386            }
387        }
388
389        // Rule 8: Complete common loops
390        if prefix.ends_with("for ") {
391            push_item(
392                0,
393                InlineCompletionItem {
394                    insert_text: "my $item (@items) {\n    \n}".into(),
395                    filter_text: Some("my".into()),
396                    range: None,
397                    command: None,
398                },
399            );
400        }
401
402        if prefix.ends_with("foreach ") {
403            push_item(
404                0,
405                InlineCompletionItem {
406                    insert_text: "my $item (@items) {\n    \n}".into(),
407                    filter_text: Some("my".into()),
408                    range: None,
409                    command: None,
410                },
411            );
412        }
413
414        // Rule 9: Complete common test patterns
415        if prefix.ends_with("ok(") {
416            push_item(
417                0,
418                InlineCompletionItem {
419                    insert_text: "$result, 'test description');".into(),
420                    filter_text: Some("$result".into()),
421                    range: None,
422                    command: None,
423                },
424            );
425        }
426
427        if prefix.ends_with("is(") {
428            push_item(
429                0,
430                InlineCompletionItem {
431                    insert_text: "$got, $expected, 'test description');".into(),
432                    filter_text: Some("$got".into()),
433                    range: None,
434                    command: None,
435                },
436            );
437        }
438
439        // Rule 10: Complete shebang
440        if prefix == "#!" || prefix == "#!/" {
441            push_item(
442                0,
443                InlineCompletionItem {
444                    insert_text: "/usr/bin/env perl".into(),
445                    filter_text: Some("perl".into()),
446                    range: None,
447                    command: None,
448                },
449            );
450        }
451
452        self.add_contextual_fallbacks(context, &mut items, &mut sequence);
453        self.normalize_items(items)
454    }
455
456    /// Check if we're after a sub declaration without body
457    fn match_sub_declaration(&self, prefix: &str) -> Option<String> {
458        // Match "sub name" pattern
459        if let Some(idx) = prefix.rfind("sub ") {
460            let after_sub = &prefix[idx + 4..];
461            // Check if we have a name and no opening brace
462            if !after_sub.is_empty() && !after_sub.contains('{') && !after_sub.contains('(') {
463                // Extract just the sub name
464                let name = after_sub.trim();
465                if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
466                    return Some(name.to_string());
467                }
468            }
469        }
470        None
471    }
472
473    /// Check if we're in a constructor context (sub new or BUILD)
474    fn is_in_constructor_context(&self, current_function: Option<&str>, prefix: &str) -> bool {
475        matches!(current_function, Some("new" | "BUILD"))
476            || prefix.contains("sub new")
477            || prefix.contains("sub BUILD")
478    }
479
480    /// Generate a smart subroutine body based on naming patterns
481    ///
482    /// Detects common Perl subroutine naming conventions and generates
483    /// appropriate body templates:
484    /// - `new`, `BUILD` -> constructor pattern
485    /// - `get_*` -> getter pattern
486    /// - `set_*` -> setter pattern
487    /// - `is_*`, `has_*`, `can_*` -> boolean accessor pattern
488    /// - `_*` -> private method placeholder
489    /// - default -> simple method template
490    fn generate_smart_body(&self, sub_name: &str) -> String {
491        // Constructor patterns
492        if sub_name == "new" || sub_name == "BUILD" {
493            return "    my $class = shift;\n    my $self = bless {}, $class;\n    return $self;"
494                .to_string();
495        }
496
497        // Getter pattern: get_something or something_getter
498        if let Some(field) = sub_name.strip_prefix("get_") {
499            // Remove "get_" prefix
500            return format!("    my $self = shift;\n    return $self->{{{}}};", field);
501        }
502
503        // Setter pattern: set_something or something_setter
504        if let Some(field) = sub_name.strip_prefix("set_") {
505            // Remove "set_" prefix
506            return format!(
507                "    my ($self, $value) = @_;\n    $self->{{{}}} = $value;\n    return $self;",
508                field
509            );
510        }
511
512        // Boolean accessor patterns: is_*, has_*, can_*
513        if sub_name.starts_with("is_")
514            || sub_name.starts_with("has_")
515            || sub_name.starts_with("can_")
516        {
517            let prefix_len = if sub_name.starts_with("is_") { 3 } else { 4 };
518            let field = &sub_name[prefix_len..];
519            return format!("    my $self = shift;\n    return $self->{{{}}} ? 1 : 0;", field);
520        }
521
522        // Private method placeholder
523        if sub_name.starts_with('_') {
524            return "    my $self = shift;\n    ...".to_string();
525        }
526
527        // Default: simple method with shift
528        "    my $self = shift;\n    ...".to_string()
529    }
530
531    fn current_function_context(
532        &self,
533        lines: &[&str],
534        line_index: usize,
535    ) -> (Option<String>, Option<usize>) {
536        lines.iter().take(line_index + 1).enumerate().fold(
537            (None, None),
538            |mut state, (idx, line)| {
539                if let Some(name) = self.parse_sub_name(line) {
540                    state = (Some(name), Some(idx));
541                }
542                state
543            },
544        )
545    }
546
547    fn current_package(&self, lines: &[&str], line_index: usize) -> Option<String> {
548        lines
549            .iter()
550            .take(line_index + 1)
551            .filter_map(|line| self.parse_package_name(line))
552            .next_back()
553    }
554
555    fn previous_non_empty_line<'a>(
556        &self,
557        lines: &'a [&'a str],
558        line_index: usize,
559    ) -> Option<&'a str> {
560        lines
561            .get(..line_index)
562            .and_then(|slice| slice.iter().rev().find(|line| !line.trim().is_empty()).copied())
563    }
564
565    fn visible_text_until_cursor(&self, lines: &[&str], line_index: usize, prefix: &str) -> String {
566        self.visible_text_since_line(lines, 0, line_index, prefix)
567    }
568
569    fn visible_text_since_line(
570        &self,
571        lines: &[&str],
572        start_line: usize,
573        line_index: usize,
574        prefix: &str,
575    ) -> String {
576        let mut visible_text = String::new();
577
578        for (idx, line) in
579            lines.iter().enumerate().skip(start_line).take(line_index.saturating_sub(start_line))
580        {
581            if idx > start_line {
582                visible_text.push('\n');
583            }
584            visible_text.push_str(line);
585        }
586
587        if line_index > start_line || !visible_text.is_empty() {
588            visible_text.push('\n');
589        }
590        visible_text.push_str(prefix);
591        visible_text
592    }
593
594    fn collect_imports(&self, visible_text: &str) -> Vec<String> {
595        let mut imports = Vec::new();
596
597        for line in visible_text.lines() {
598            if let Some(import_name) = self.parse_use_name(line) {
599                self.push_unique(&mut imports, import_name);
600            }
601        }
602
603        imports
604    }
605
606    fn collect_variables(&self, visible_text: &str) -> Vec<String> {
607        let mut matches = Vec::new();
608        let bytes = visible_text.as_bytes();
609        let mut index = 0usize;
610
611        while index < bytes.len() {
612            let byte = bytes[index];
613            if byte == b'$' || byte == b'@' || byte == b'%' {
614                let start = index;
615                index += 1;
616
617                if index >= bytes.len() {
618                    break;
619                }
620
621                let first = bytes[index] as char;
622                if !(first.is_ascii_alphabetic() || first == '_') {
623                    continue;
624                }
625
626                index += 1;
627                while index < bytes.len() {
628                    let next = bytes[index] as char;
629                    if next.is_ascii_alphanumeric() || next == '_' {
630                        index += 1;
631                    } else {
632                        break;
633                    }
634                }
635
636                matches.push(visible_text[start..index].to_string());
637                continue;
638            }
639
640            index += 1;
641        }
642
643        let mut variables = Vec::new();
644        for variable in matches.into_iter().rev() {
645            self.push_unique(&mut variables, variable);
646            if variables.len() >= 8 {
647                break;
648            }
649        }
650
651        variables
652    }
653
654    fn parse_use_name(&self, line: &str) -> Option<String> {
655        let trimmed = line.trim_start();
656        let rest = trimmed.strip_prefix("use ")?;
657        let name: String = rest
658            .chars()
659            .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_'))
660            .collect();
661
662        (!name.is_empty()).then_some(name)
663    }
664
665    fn parse_sub_name(&self, line: &str) -> Option<String> {
666        let trimmed = line.trim_start();
667        let rest = trimmed.strip_prefix("sub ")?;
668        let name: String = rest
669            .chars()
670            .skip_while(|ch| ch.is_whitespace())
671            .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
672            .collect();
673
674        (!name.is_empty()).then_some(name)
675    }
676
677    fn parse_package_name(&self, line: &str) -> Option<String> {
678        let trimmed = line.trim_start();
679        let rest = trimmed.strip_prefix("package ")?;
680        let name: String = rest
681            .chars()
682            .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_'))
683            .collect();
684
685        (!name.is_empty()).then_some(name)
686    }
687
688    fn add_contextual_fallbacks(
689        &self,
690        context: &PreparedInlineCompletionContext,
691        items: &mut Vec<RankedCompletionItem>,
692        sequence: &mut usize,
693    ) {
694        let prefix = context.prefix.trim();
695        let comment_context = context
696            .previous_non_empty_line
697            .as_deref()
698            .map(|line| line.trim_start().starts_with('#'))
699            .unwrap_or(false);
700
701        if context.current_line.is_empty()
702            && context.current_function.is_none()
703            && context.imports.is_empty()
704            && context.variables.is_empty()
705        {
706            items.push(RankedCompletionItem {
707                priority: 8,
708                order: *sequence,
709                item: InlineCompletionItem {
710                    insert_text: "#!/usr/bin/env perl\nuse strict;\nuse warnings;\n\n".into(),
711                    filter_text: Some("perl".into()),
712                    range: None,
713                    command: None,
714                },
715            });
716            *sequence += 1;
717            items.push(RankedCompletionItem {
718                priority: 9,
719                order: *sequence,
720                item: InlineCompletionItem {
721                    insert_text: "use strict;\nuse warnings;\n\n".into(),
722                    filter_text: Some("strict".into()),
723                    range: None,
724                    command: None,
725                },
726            });
727            *sequence += 1;
728        }
729
730        if prefix.is_empty() {
731            if let Some(variable) = self.preferred_return_variable(context) {
732                items.push(RankedCompletionItem {
733                    priority: 0,
734                    order: *sequence,
735                    item: InlineCompletionItem {
736                        insert_text: format!("return {variable};"),
737                        filter_text: Some(variable),
738                        range: None,
739                        command: None,
740                    },
741                });
742                *sequence += 1;
743            }
744
745            if self.imports_include(context, "Test::More")
746                || self.imports_include(context, "Test2::V0")
747            {
748                items.push(RankedCompletionItem {
749                    priority: 1,
750                    order: *sequence,
751                    item: InlineCompletionItem {
752                        insert_text: "done_testing();".into(),
753                        filter_text: Some("done_testing".into()),
754                        range: None,
755                        command: None,
756                    },
757                });
758                *sequence += 1;
759            }
760
761            if comment_context && let Some(variable) = self.preferred_assignment_variable(context) {
762                items.push(RankedCompletionItem {
763                    priority: 2,
764                    order: *sequence,
765                    item: InlineCompletionItem {
766                        insert_text: format!("my {variable} = shift;"),
767                        filter_text: Some(variable),
768                        range: None,
769                        command: None,
770                    },
771                });
772                *sequence += 1;
773            }
774        }
775    }
776
777    fn normalize_items(&self, mut items: Vec<RankedCompletionItem>) -> Vec<InlineCompletionItem> {
778        items.sort_by(|left, right| {
779            left.priority.cmp(&right.priority).then_with(|| left.order.cmp(&right.order))
780        });
781
782        let mut deduped = Vec::new();
783        let mut seen = Vec::<String>::new();
784        for candidate in items.into_iter() {
785            if seen.iter().any(|existing| existing == &candidate.item.insert_text) {
786                continue;
787            }
788
789            seen.push(candidate.item.insert_text.clone());
790            deduped.push(candidate.item);
791            if deduped.len() >= MAX_INLINE_COMPLETION_ITEMS {
792                break;
793            }
794        }
795
796        deduped
797    }
798
799    fn preferred_return_variable(
800        &self,
801        context: &PreparedInlineCompletionContext,
802    ) -> Option<String> {
803        context
804            .variables
805            .iter()
806            .find(|variable| variable.as_str() == "$self")
807            .cloned()
808            .or_else(|| context.variables.first().cloned())
809    }
810
811    fn preferred_assignment_variable(
812        &self,
813        context: &PreparedInlineCompletionContext,
814    ) -> Option<String> {
815        context
816            .variables
817            .iter()
818            .find(|variable| variable.starts_with('$') && variable.as_str() != "$self")
819            .cloned()
820    }
821
822    fn imports_include(&self, context: &PreparedInlineCompletionContext, expected: &str) -> bool {
823        context.imports.iter().any(|import_name| import_name == expected)
824    }
825
826    fn push_unique(&self, values: &mut Vec<String>, value: String) {
827        if values.iter().any(|existing| existing == &value) {
828            return;
829        }
830        values.push(value);
831    }
832}
833
834struct LineContext<'a> {
835    prefix: &'a str,
836    current_line: &'a str,
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_after_arrow() {
845        let provider = InlineCompletionProvider::new();
846        let completions = provider.get_inline_completions("$obj->", 0, 6);
847        assert!(!completions.items.is_empty());
848        assert_eq!(completions.items[0].insert_text, "new()");
849    }
850
851    #[test]
852    fn test_after_use() {
853        let provider = InlineCompletionProvider::new();
854        let completions = provider.get_inline_completions("use ", 0, 4);
855        assert!(!completions.items.is_empty());
856        assert!(completions.items.iter().any(|i| i.insert_text == "strict;"));
857    }
858
859    #[test]
860    fn test_after_sub() {
861        let provider = InlineCompletionProvider::new();
862        let completions = provider.get_inline_completions("sub hello", 0, 9);
863        assert!(!completions.items.is_empty());
864        // Default method generates simple template with shift
865        assert!(completions.items[0].insert_text.contains("my $self = shift"));
866    }
867
868    #[test]
869    fn test_sub_new_constructor() {
870        let provider = InlineCompletionProvider::new();
871        let completions = provider.get_inline_completions("sub new", 0, 7);
872        assert!(!completions.items.is_empty());
873        // Constructor generates bless pattern
874        assert!(completions.items[0].insert_text.contains("bless"));
875        assert!(completions.items[0].insert_text.contains("my $class = shift"));
876    }
877
878    #[test]
879    fn test_sub_getter() {
880        let provider = InlineCompletionProvider::new();
881        let completions = provider.get_inline_completions("sub get_name", 0, 12);
882        assert!(!completions.items.is_empty());
883        // Getter generates accessor pattern
884        assert!(completions.items[0].insert_text.contains("return $self->{name}"));
885    }
886
887    #[test]
888    fn test_sub_setter() {
889        let provider = InlineCompletionProvider::new();
890        let completions = provider.get_inline_completions("sub set_name", 0, 12);
891        assert!(!completions.items.is_empty());
892        // Setter generates mutator pattern
893        assert!(completions.items[0].insert_text.contains("$self->{name} = $value"));
894    }
895
896    #[test]
897    fn test_sub_is_predicate() {
898        let provider = InlineCompletionProvider::new();
899        let completions = provider.get_inline_completions("sub is_active", 0, 13);
900        assert!(!completions.items.is_empty());
901        // Boolean accessor returns 1/0
902        assert!(completions.items[0].insert_text.contains("? 1 : 0"));
903    }
904
905    #[test]
906    fn test_sub_has_predicate() {
907        let provider = InlineCompletionProvider::new();
908        let completions = provider.get_inline_completions("sub has_items", 0, 13);
909        assert!(!completions.items.is_empty());
910        // Boolean accessor returns 1/0
911        assert!(completions.items[0].insert_text.contains("? 1 : 0"));
912    }
913
914    #[test]
915    fn test_no_completion_when_brace_exists() {
916        let provider = InlineCompletionProvider::new();
917        let completions = provider.get_inline_completions("sub hello {", 0, 9);
918        // Should not suggest brace when one exists
919        assert!(completions.items.is_empty() || !completions.items[0].insert_text.contains('{'));
920    }
921
922    #[test]
923    fn test_shebang_completion() {
924        let provider = InlineCompletionProvider::new();
925        let completions = provider.get_inline_completions("#!/", 0, 3);
926        assert!(!completions.items.is_empty());
927        assert_eq!(completions.items[0].insert_text, "/usr/bin/env perl");
928    }
929
930    #[test]
931    fn test_after_arrow_with_unicode_prefix_uses_utf16_position() {
932        let provider = InlineCompletionProvider::new();
933        let source = "my $emoji = \"😀\"; my $obj = Package->";
934        let character = source.encode_utf16().count() as u32;
935        let completions = provider.get_inline_completions(source, 0, character);
936
937        assert!(!completions.items.is_empty());
938        assert_eq!(completions.items[0].insert_text, "new()");
939    }
940
941    #[test]
942    fn test_prepare_context_collects_function_variables_and_imports()
943    -> Result<(), Box<dyn std::error::Error>> {
944        let provider = InlineCompletionProvider::new();
945        let source = "use Test::More;\npackage Demo;\n\nsub helper {\n    my $result = 1;\n    my $status = $result;\n    \n}\n";
946        let line = 6;
947        let character = 4;
948        let context =
949            provider.prepare_context(source, line, character).ok_or("expected prepared context")?;
950
951        assert_eq!(context.current_function.as_deref(), Some("helper"));
952        assert_eq!(context.current_package.as_deref(), Some("Demo"));
953        assert_eq!(context.previous_non_empty_line.as_deref(), Some("    my $status = $result;"));
954        assert!(context.imports.iter().any(|import_name| import_name == "Test::More"));
955        assert!(context.variables.iter().any(|variable| variable == "$status"));
956        assert!(context.variables.iter().any(|variable| variable == "$result"));
957        Ok(())
958    }
959
960    #[test]
961    fn test_empty_file_gets_scaffold_suggestions() {
962        let provider = InlineCompletionProvider::new();
963        let completions = provider.get_inline_completions("", 0, 0);
964
965        assert!(!completions.items.is_empty());
966        assert!(completions.items.iter().any(|item| item.insert_text.contains("use strict;")));
967    }
968
969    #[test]
970    fn test_blank_line_in_function_prefers_nearby_variable() {
971        let provider = InlineCompletionProvider::new();
972        let source = "sub helper {\n    my $result = compute();\n    \n}\n";
973        let completions = provider.get_inline_completions(source, 2, 4);
974
975        assert!(!completions.items.is_empty());
976        assert!(completions.items.iter().any(|item| item.insert_text == "return $result;"));
977    }
978
979    #[test]
980    fn test_blank_line_after_comment_still_has_contextual_suggestions() {
981        let provider = InlineCompletionProvider::new();
982        let source = "use Test::More;\n\nsub helper {\n    my $result = 1;\n    # explain next step\n    \n}\n";
983        let completions = provider.get_inline_completions(source, 5, 4);
984
985        assert!(!completions.items.is_empty());
986        assert!(completions.items.iter().any(|item| item.insert_text == "return $result;"));
987        assert!(completions.items.iter().any(|item| item.insert_text == "done_testing();"));
988    }
989
990    #[test]
991    fn test_normalize_items_orders_deduplicates_and_limits() {
992        let provider = InlineCompletionProvider::new();
993        let items = vec![
994            RankedCompletionItem {
995                priority: 2,
996                order: 0,
997                item: InlineCompletionItem {
998                    insert_text: "late".into(),
999                    filter_text: None,
1000                    range: None,
1001                    command: None,
1002                },
1003            },
1004            RankedCompletionItem {
1005                priority: 0,
1006                order: 1,
1007                item: InlineCompletionItem {
1008                    insert_text: "first".into(),
1009                    filter_text: None,
1010                    range: None,
1011                    command: None,
1012                },
1013            },
1014            RankedCompletionItem {
1015                priority: 0,
1016                order: 2,
1017                item: InlineCompletionItem {
1018                    insert_text: "first".into(),
1019                    filter_text: Some("duplicate".into()),
1020                    range: None,
1021                    command: None,
1022                },
1023            },
1024            RankedCompletionItem {
1025                priority: 1,
1026                order: 3,
1027                item: InlineCompletionItem {
1028                    insert_text: "second".into(),
1029                    filter_text: None,
1030                    range: None,
1031                    command: None,
1032                },
1033            },
1034            RankedCompletionItem {
1035                priority: 3,
1036                order: 4,
1037                item: InlineCompletionItem {
1038                    insert_text: "third".into(),
1039                    filter_text: None,
1040                    range: None,
1041                    command: None,
1042                },
1043            },
1044            RankedCompletionItem {
1045                priority: 4,
1046                order: 5,
1047                item: InlineCompletionItem {
1048                    insert_text: "fourth".into(),
1049                    filter_text: None,
1050                    range: None,
1051                    command: None,
1052                },
1053            },
1054            RankedCompletionItem {
1055                priority: 5,
1056                order: 6,
1057                item: InlineCompletionItem {
1058                    insert_text: "fifth".into(),
1059                    filter_text: None,
1060                    range: None,
1061                    command: None,
1062                },
1063            },
1064        ];
1065
1066        let normalized = provider.normalize_items(items);
1067
1068        assert_eq!(normalized.len(), MAX_INLINE_COMPLETION_ITEMS);
1069        assert_eq!(normalized[0].insert_text, "first");
1070        assert_eq!(normalized[1].insert_text, "second");
1071        assert_eq!(normalized[2].insert_text, "late");
1072        assert_eq!(normalized[3].insert_text, "third");
1073        assert_eq!(normalized[4].insert_text, "fourth");
1074    }
1075}