Skip to main content

perl_lsp_completion/completion/
methods.rs

1//! Method completion for Perl
2//!
3//! Provides context-aware method completion including DBI methods.
4
5use super::{auto_import, context::CompletionContext, items::CompletionItem};
6use perl_semantic_analyzer::symbol::{SymbolKind, SymbolTable};
7use std::collections::HashSet;
8
9/// Extract the receiver module name from the completion prefix for static calls.
10///
11/// For `LWP::UserAgent->ge` the prefix is `LWP::UserAgent->ge` and we extract
12/// `LWP::UserAgent`.  Returns `None` when the receiver is a variable (`$obj->`)
13/// or when the prefix has no `->`.
14fn static_receiver_module(prefix: &str) -> Option<&str> {
15    let arrow = prefix.rfind("->")?;
16    let receiver = prefix[..arrow].trim();
17    // Static receivers start with an uppercase ASCII letter and contain no sigil.
18    if !receiver.starts_with('$')
19        && !receiver.starts_with('@')
20        && !receiver.starts_with('%')
21        && receiver.chars().next().is_some_and(|c| c.is_ascii_uppercase())
22    {
23        Some(receiver)
24    } else {
25        None
26    }
27}
28
29/// DBI database handle methods
30pub const DBI_DB_METHODS: &[(&str, &str)] = &[
31    ("do", "Execute a single SQL statement"),
32    ("prepare", "Prepare a SQL statement"),
33    ("prepare_cached", "Prepare and cache a SQL statement"),
34    ("selectrow_array", "Execute and fetch a single row as array"),
35    ("selectrow_arrayref", "Execute and fetch a single row as arrayref"),
36    ("selectrow_hashref", "Execute and fetch a single row as hashref"),
37    ("selectall_arrayref", "Execute and fetch all rows as arrayref"),
38    ("selectall_hashref", "Execute and fetch all rows as hashref"),
39    ("begin_work", "Begin a database transaction"),
40    ("commit", "Commit the current transaction"),
41    ("rollback", "Rollback the current transaction"),
42    ("disconnect", "Disconnect from the database"),
43    ("last_insert_id", "Get the last inserted row ID"),
44    ("quote", "Quote a string for SQL"),
45    ("quote_identifier", "Quote an identifier for SQL"),
46    ("ping", "Check if database connection is alive"),
47];
48
49/// DBI statement handle methods
50pub const DBI_ST_METHODS: &[(&str, &str)] = &[
51    ("bind_param", "Bind a parameter to the statement"),
52    ("bind_param_inout", "Bind an in/out parameter"),
53    ("execute", "Execute the prepared statement"),
54    ("fetch", "Fetch the next row as arrayref"),
55    ("fetchrow_array", "Fetch the next row as array"),
56    ("fetchrow_arrayref", "Fetch the next row as arrayref"),
57    ("fetchrow_hashref", "Fetch the next row as hashref"),
58    ("fetchall_arrayref", "Fetch all remaining rows as arrayref"),
59    ("fetchall_hashref", "Fetch all remaining rows as hashref of hashrefs"),
60    ("finish", "Finish the statement handle"),
61    ("rows", "Get the number of rows affected"),
62];
63
64/// Parameter signatures for DBI database-handle methods.
65///
66/// Each entry is `(name, signature, description)`.
67pub const DBI_DB_METHOD_SIGS: &[(&str, &str, &str)] = &[
68    ("do", "do($statement, \\@attr?, @bind_values?)", "Execute a single SQL statement"),
69    ("prepare", "prepare($statement, \\@attr?)", "Prepare a SQL statement for execution"),
70    (
71        "prepare_cached",
72        "prepare_cached($statement, \\@attr?, $if_active?)",
73        "Prepare and cache a SQL statement",
74    ),
75    (
76        "selectrow_array",
77        "selectrow_array($statement, \\@attr?, @bind)",
78        "Execute and return first row as list",
79    ),
80    (
81        "selectrow_arrayref",
82        "selectrow_arrayref($statement, \\@attr?, @bind)",
83        "Execute and return first row as arrayref",
84    ),
85    (
86        "selectrow_hashref",
87        "selectrow_hashref($statement, \\@attr?, @bind)",
88        "Execute and return first row as hashref",
89    ),
90    (
91        "selectall_arrayref",
92        "selectall_arrayref($statement, \\@attr?, @bind)",
93        "Execute and return all rows as arrayref",
94    ),
95    (
96        "selectall_hashref",
97        "selectall_hashref($statement, $key_field, \\@attr?, @bind)",
98        "Execute and return all rows as hashref",
99    ),
100    ("begin_work", "begin_work()", "Begin a database transaction"),
101    ("commit", "commit()", "Commit the current transaction"),
102    ("rollback", "rollback()", "Rollback the current transaction"),
103    ("disconnect", "disconnect()", "Disconnect from the database"),
104    (
105        "last_insert_id",
106        "last_insert_id($catalog, $schema, $table, $field, \\@attr?)",
107        "Get the last inserted row ID",
108    ),
109    ("quote", "quote($value, $data_type?)", "Quote a string value for use in SQL"),
110    ("quote_identifier", "quote_identifier($name)", "Quote an identifier for SQL"),
111    ("ping", "ping()", "Check if the database connection is still alive"),
112];
113
114/// Parameter signatures for DBI statement-handle methods.
115///
116/// Each entry is `(name, signature, description)`.
117pub const DBI_ST_METHOD_SIGS: &[(&str, &str, &str)] = &[
118    (
119        "bind_param",
120        "bind_param($param_num, $bind_value, \\@attr?)",
121        "Bind a value to a placeholder",
122    ),
123    (
124        "bind_param_inout",
125        "bind_param_inout($param_num, \\$bind_value, $max_len)",
126        "Bind an in/out parameter",
127    ),
128    ("execute", "execute(@bind_values?)", "Execute the prepared statement"),
129    ("fetch", "fetch()", "Fetch the next row as arrayref (alias for fetchrow_arrayref)"),
130    ("fetchrow_array", "fetchrow_array()", "Fetch the next row as a list"),
131    ("fetchrow_arrayref", "fetchrow_arrayref()", "Fetch the next row as an arrayref"),
132    ("fetchrow_hashref", "fetchrow_hashref($name?)", "Fetch the next row as a hashref"),
133    (
134        "fetchall_arrayref",
135        "fetchall_arrayref($slice?, $max_rows?)",
136        "Fetch all remaining rows as arrayref",
137    ),
138    (
139        "fetchall_hashref",
140        "fetchall_hashref($key_field)",
141        "Fetch all remaining rows as hashref of hashrefs",
142    ),
143    ("finish", "finish()", "Indicate no more rows will be fetched"),
144    ("rows", "rows()", "Return the number of rows affected or returned"),
145];
146
147/// Look up DBI method documentation by receiver hint and method name.
148///
149/// `receiver_hint` is the variable name or token before `->` (e.g. `"$dbh"`, `"$sth"`).
150/// Returns `(signature, description)` or `None` if not a known DBI method.
151///
152/// When the receiver is ambiguous, database-handle methods take priority.
153pub fn get_dbi_method_documentation(
154    receiver_hint: &str,
155    method_name: &str,
156) -> Option<(&'static str, &'static str)> {
157    let is_db = receiver_hint.ends_with("dbh")
158        || receiver_hint.contains("DBI")
159        || receiver_hint.contains("connect");
160    let is_st = receiver_hint.ends_with("sth");
161
162    let table: &[(&str, &str, &str)] = if is_db {
163        DBI_DB_METHOD_SIGS
164    } else if is_st {
165        DBI_ST_METHOD_SIGS
166    } else {
167        // Unknown receiver — check db table first, then st table
168        if let Some(entry) = DBI_DB_METHOD_SIGS.iter().find(|(n, _, _)| *n == method_name) {
169            return Some((entry.1, entry.2));
170        }
171        DBI_ST_METHOD_SIGS
172    };
173
174    table.iter().find(|(n, _, _)| *n == method_name).map(|(_, sig, desc)| (*sig, *desc))
175}
176
177/// Infer receiver type from context (for DBI method completion)
178pub fn infer_receiver_type(context: &CompletionContext, source: &str) -> Option<String> {
179    // Look backwards from the position to find the receiver
180    let prefix = context.prefix.trim_end_matches("->");
181
182    // Simple heuristics for DBI types based on variable name
183    if prefix.ends_with("$dbh") {
184        return Some("DBI::db".to_string());
185    }
186    if prefix.ends_with("$sth") {
187        return Some("DBI::st".to_string());
188    }
189
190    // Look at the broader context - check if variable was assigned from DBI->connect
191    if let Some(var_pos) = source.rfind(prefix) {
192        // Look backwards for assignment
193        let before_var = &source[..var_pos];
194        if let Some(assign_pos) = before_var.rfind('=') {
195            let assignment = &source[assign_pos..var_pos + prefix.len()];
196
197            // Check if this looks like DBI->connect result
198            if assignment.contains("DBI") && assignment.contains("connect") {
199                return Some("DBI::db".to_string());
200            }
201
202            // Check if this looks like prepare/prepare_cached result
203            if assignment.contains("prepare") {
204                return Some("DBI::st".to_string());
205            }
206        }
207    }
208
209    None
210}
211
212/// Build rich documentation for a Moo/Moose accessor from its symbol attributes.
213///
214/// Attributes are stored as `key=value` strings (e.g. `"is=ro"`, `"isa=Str"`).
215/// This function formats them into a human-readable documentation string that
216/// surfaces the type constraint and access mode prominently.
217fn moo_accessor_documentation(name: &str, attributes: &[String]) -> String {
218    let mut isa_value: Option<&str> = None;
219    let mut is_value: Option<&str> = None;
220    let mut extra_parts: Vec<&str> = Vec::new();
221
222    for attr in attributes {
223        if let Some((key, value)) = attr.split_once('=') {
224            match key {
225                "isa" => isa_value = Some(value),
226                "is" => is_value = Some(value),
227                _ => extra_parts.push(attr),
228            }
229        }
230    }
231
232    let mut doc = format!("Moo/Moose accessor `{name}`");
233
234    if let Some(isa) = isa_value {
235        doc.push_str(&format!("\n\n**Type**: `{isa}`"));
236    }
237    if let Some(is) = is_value {
238        let mode = match is {
239            "ro" => "read-only",
240            "rw" => "read-write",
241            "rwp" => "read-write private",
242            "lazy" => "lazy",
243            other => other,
244        };
245        doc.push_str(&format!("\n\n**Access**: {mode}"));
246    }
247    if !extra_parts.is_empty() {
248        doc.push_str(&format!("\n\n**Options**: {}", extra_parts.join(", ")));
249    }
250
251    doc
252}
253
254/// Add method completions
255pub fn add_method_completions(
256    completions: &mut Vec<CompletionItem>,
257    context: &CompletionContext,
258    source: &str,
259    symbol_table: &SymbolTable,
260) {
261    let mut seen: HashSet<&str> = HashSet::new();
262
263    // Prefer discovered in-file methods first (including synthesized framework accessors).
264    let method_prefix = context.prefix.rsplit("->").next().unwrap_or(&context.prefix);
265    for (name, symbols) in &symbol_table.symbols {
266        let is_callable = symbols
267            .iter()
268            .any(|symbol| matches!(symbol.kind, SymbolKind::Subroutine | SymbolKind::Method));
269        if !is_callable {
270            continue;
271        }
272
273        if !method_prefix.is_empty() && !name.starts_with(method_prefix) {
274            continue;
275        }
276
277        // Check if this is a synthesized Moo/Moose accessor (declaration == "has")
278        let callable_symbol = symbols
279            .iter()
280            .find(|symbol| matches!(symbol.kind, SymbolKind::Subroutine | SymbolKind::Method));
281
282        let is_moo_accessor =
283            callable_symbol.and_then(|s| s.declaration.as_deref()).is_some_and(|d| d == "has");
284
285        let (detail, documentation) = if is_moo_accessor {
286            let attrs = callable_symbol.map(|s| s.attributes.as_slice()).unwrap_or(&[]);
287            ("Moo/Moose accessor".to_string(), Some(moo_accessor_documentation(name, attrs)))
288        } else {
289            let doc = symbols.iter().find_map(|symbol| symbol.documentation.clone());
290            ("method".to_string(), doc)
291        };
292
293        if seen.insert(name.as_str()) {
294            completions.push(CompletionItem {
295                label: name.clone(),
296                kind: crate::completion::items::CompletionItemKind::Function,
297                detail: Some(detail),
298                documentation,
299                insert_text: Some(format!("{}()", name)),
300                sort_text: Some(format!("1_{}", name)),
301                filter_text: Some(name.clone()),
302                additional_edits: vec![],
303                text_edit_range: Some((context.prefix_start, context.position)),
304                commit_characters: None,
305            });
306        }
307    }
308
309    // Try to infer the receiver type from context
310    let receiver_type = infer_receiver_type(context, source);
311
312    // Determine module for auto-import:
313    // - For static calls like `LWP::UserAgent->` use the prefix receiver.
314    // - For DBI-inferred types use "DBI".
315    let import_module: Option<&str> =
316        static_receiver_module(&context.prefix).or(match receiver_type.as_deref() {
317            Some("DBI::db") | Some("DBI::st") => Some("DBI"),
318            _ => None,
319        });
320
321    // Build an auto-import edit once so all items in this batch share it.
322    let auto_import_edit =
323        import_module.and_then(|m| auto_import::build_auto_import_edit(source, m));
324
325    // Choose methods based on inferred type
326    let methods: Vec<(&str, &str)> = match receiver_type.as_deref() {
327        Some("DBI::db") => DBI_DB_METHODS.to_vec(),
328        Some("DBI::st") => DBI_ST_METHODS.to_vec(),
329        _ => {
330            // Default common object methods
331            vec![
332                ("new", "Constructor"),
333                ("isa", "Check if object is of given class"),
334                ("can", "Check if object can call method"),
335                ("DOES", "Check if object does role"),
336                ("VERSION", "Get version"),
337            ]
338        }
339    };
340
341    for (method, desc) in methods {
342        if seen.insert(method) {
343            let additional_edits =
344                auto_import_edit.as_ref().map(|e| vec![e.clone()]).unwrap_or_default();
345            completions.push(CompletionItem {
346                label: method.to_string(),
347                kind: crate::completion::items::CompletionItemKind::Function,
348                detail: Some("method".to_string()),
349                documentation: Some(desc.to_string()),
350                insert_text: Some(format!("{}()", method)),
351                sort_text: Some(format!("2_{}", method)),
352                filter_text: Some(method.to_string()),
353                additional_edits,
354                text_edit_range: Some((context.prefix_start, context.position)),
355                commit_characters: None,
356            });
357        }
358    }
359
360    // If we have a DBI type, also add common methods at lower priority
361    if receiver_type.as_deref() == Some("DBI::db") || receiver_type.as_deref() == Some("DBI::st") {
362        for (method, desc) in [
363            ("isa", "Check if object is of given class"),
364            ("can", "Check if object can call method"),
365        ] {
366            if seen.insert(method) {
367                let additional_edits =
368                    auto_import_edit.as_ref().map(|e| vec![e.clone()]).unwrap_or_default();
369                completions.push(CompletionItem {
370                    label: method.to_string(),
371                    kind: crate::completion::items::CompletionItemKind::Function,
372                    detail: Some("method".to_string()),
373                    documentation: Some(desc.to_string()),
374                    insert_text: Some(format!("{}()", method)),
375                    sort_text: Some(format!("9_{}", method)), // Lower priority
376                    filter_text: Some(method.to_string()),
377                    additional_edits,
378                    text_edit_range: Some((context.prefix_start, context.position)),
379                    commit_characters: None,
380                });
381            }
382        }
383    }
384}