Skip to main content

solidity_language_server/
hover.rs

1use serde_json::Value;
2use std::collections::HashMap;
3use tower_lsp::lsp_types::{
4    Documentation, Hover, HoverContents, MarkupContent, MarkupKind, ParameterInformation,
5    ParameterLabel, Position, SignatureHelp, SignatureInformation, Url,
6};
7
8use crate::gas::{self, GasIndex};
9use crate::goto::{CHILD_KEYS, cache_ids, pos_to_bytes};
10use crate::inlay_hints::HintIndex;
11use crate::references::{byte_to_decl_via_external_refs, byte_to_id};
12use crate::types::{EventSelector, FuncSelector, MethodId, NodeId, Selector};
13
14// ── DocIndex — pre-built userdoc/devdoc lookup ─────────────────────────────
15
16/// Merged documentation from solc userdoc + devdoc for a single declaration.
17#[derive(Debug, Clone, Default)]
18pub struct DocEntry {
19    /// `@notice` from userdoc.
20    pub notice: Option<String>,
21    /// `@dev` / `details` from devdoc.
22    pub details: Option<String>,
23    /// `@param` descriptions from devdoc, keyed by parameter name.
24    pub params: Vec<(String, String)>,
25    /// `@return` descriptions from devdoc, keyed by return name.
26    pub returns: Vec<(String, String)>,
27    /// `@title` from devdoc (contract-level only).
28    pub title: Option<String>,
29    /// `@author` from devdoc (contract-level only).
30    pub author: Option<String>,
31}
32
33/// Key for looking up documentation in the [`DocIndex`].
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum DocKey {
36    /// 4-byte selector for functions, public variables, and errors.
37    Func(FuncSelector),
38    /// 32-byte topic hash for events.
39    Event(EventSelector),
40    /// Contract-level docs, keyed by `"path:Name"`.
41    Contract(String),
42    /// State variable docs, keyed by `"path:ContractName:varName"`.
43    StateVar(String),
44    /// Fallback for methods without a selector (shouldn't happen, but safe).
45    Method(String),
46}
47
48/// Pre-built documentation index from solc contract output.
49///
50/// Keyed by [`DocKey`] for type-safe lookup from AST nodes.
51pub type DocIndex = HashMap<DocKey, DocEntry>;
52
53/// Build a documentation index from normalized AST output.
54///
55/// Iterates over `contracts[path][name]` and merges userdoc + devdoc
56/// into `DocEntry` values keyed for fast lookup from AST nodes.
57pub fn build_doc_index(ast_data: &Value) -> DocIndex {
58    let mut index = DocIndex::new();
59
60    let contracts = match ast_data.get("contracts").and_then(|c| c.as_object()) {
61        Some(c) => c,
62        None => return index,
63    };
64
65    for (path, names) in contracts {
66        let names_obj = match names.as_object() {
67            Some(n) => n,
68            None => continue,
69        };
70
71        for (name, contract) in names_obj {
72            let userdoc = contract.get("userdoc");
73            let devdoc = contract.get("devdoc");
74            let method_ids = contract
75                .get("evm")
76                .and_then(|e| e.get("methodIdentifiers"))
77                .and_then(|m| m.as_object());
78
79            // Build canonical_sig → selector for userdoc/devdoc key lookups
80            let sig_to_selector: HashMap<&str, &str> = method_ids
81                .map(|mi| {
82                    mi.iter()
83                        .filter_map(|(sig, sel)| sel.as_str().map(|s| (sig.as_str(), s)))
84                        .collect()
85                })
86                .unwrap_or_default();
87
88            // ── Contract-level docs ──
89            let mut contract_entry = DocEntry::default();
90            if let Some(ud) = userdoc {
91                contract_entry.notice = ud
92                    .get("notice")
93                    .and_then(|v| v.as_str())
94                    .map(|s| s.to_string());
95            }
96            if let Some(dd) = devdoc {
97                contract_entry.title = dd
98                    .get("title")
99                    .and_then(|v| v.as_str())
100                    .map(|s| s.to_string());
101                contract_entry.details = dd
102                    .get("details")
103                    .and_then(|v| v.as_str())
104                    .map(|s| s.to_string());
105                contract_entry.author = dd
106                    .get("author")
107                    .and_then(|v| v.as_str())
108                    .map(|s| s.to_string());
109            }
110            if contract_entry.notice.is_some()
111                || contract_entry.title.is_some()
112                || contract_entry.details.is_some()
113            {
114                let key = DocKey::Contract(format!("{path}:{name}"));
115                index.insert(key, contract_entry);
116            }
117
118            // ── Method docs (functions + public state variable getters) ──
119            let ud_methods = userdoc
120                .and_then(|u| u.get("methods"))
121                .and_then(|m| m.as_object());
122            let dd_methods = devdoc
123                .and_then(|d| d.get("methods"))
124                .and_then(|m| m.as_object());
125
126            // Collect all canonical sigs from both userdoc and devdoc methods
127            let mut all_sigs: Vec<&str> = Vec::new();
128            if let Some(um) = ud_methods {
129                all_sigs.extend(um.keys().map(|k| k.as_str()));
130            }
131            if let Some(dm) = dd_methods {
132                for k in dm.keys() {
133                    if !all_sigs.contains(&k.as_str()) {
134                        all_sigs.push(k.as_str());
135                    }
136                }
137            }
138
139            for sig in &all_sigs {
140                let mut entry = DocEntry::default();
141
142                // userdoc notice
143                if let Some(um) = ud_methods
144                    && let Some(method) = um.get(*sig)
145                {
146                    entry.notice = method
147                        .get("notice")
148                        .and_then(|v| v.as_str())
149                        .map(|s| s.to_string());
150                }
151
152                // devdoc details + params + returns
153                if let Some(dm) = dd_methods
154                    && let Some(method) = dm.get(*sig)
155                {
156                    entry.details = method
157                        .get("details")
158                        .and_then(|v| v.as_str())
159                        .map(|s| s.to_string());
160
161                    if let Some(params) = method.get("params").and_then(|p| p.as_object()) {
162                        for (pname, pdesc) in params {
163                            if let Some(desc) = pdesc.as_str() {
164                                entry.params.push((pname.clone(), desc.to_string()));
165                            }
166                        }
167                    }
168
169                    if let Some(returns) = method.get("returns").and_then(|r| r.as_object()) {
170                        for (rname, rdesc) in returns {
171                            if let Some(desc) = rdesc.as_str() {
172                                entry.returns.push((rname.clone(), desc.to_string()));
173                            }
174                        }
175                    }
176                }
177
178                if entry.notice.is_none()
179                    && entry.details.is_none()
180                    && entry.params.is_empty()
181                    && entry.returns.is_empty()
182                {
183                    continue;
184                }
185
186                // Key by selector (for AST node matching)
187                if let Some(selector) = sig_to_selector.get(sig) {
188                    let key = DocKey::Func(FuncSelector::new(*selector));
189                    index.insert(key, entry);
190                } else {
191                    // No selector (shouldn't happen for methods, but be safe)
192                    // Key by function name for fallback matching
193                    let fn_name = sig.split('(').next().unwrap_or(sig);
194                    let key = DocKey::Method(format!("{path}:{name}:{fn_name}"));
195                    index.insert(key, entry);
196                }
197            }
198
199            // ── Error docs ──
200            let ud_errors = userdoc
201                .and_then(|u| u.get("errors"))
202                .and_then(|e| e.as_object());
203            let dd_errors = devdoc
204                .and_then(|d| d.get("errors"))
205                .and_then(|e| e.as_object());
206
207            let mut all_error_sigs: Vec<&str> = Vec::new();
208            if let Some(ue) = ud_errors {
209                all_error_sigs.extend(ue.keys().map(|k| k.as_str()));
210            }
211            if let Some(de) = dd_errors {
212                for k in de.keys() {
213                    if !all_error_sigs.contains(&k.as_str()) {
214                        all_error_sigs.push(k.as_str());
215                    }
216                }
217            }
218
219            for sig in &all_error_sigs {
220                let mut entry = DocEntry::default();
221
222                // userdoc: errors are arrays of { notice }
223                if let Some(ue) = ud_errors
224                    && let Some(arr) = ue.get(*sig).and_then(|v| v.as_array())
225                    && let Some(first) = arr.first()
226                {
227                    entry.notice = first
228                        .get("notice")
229                        .and_then(|v| v.as_str())
230                        .map(|s| s.to_string());
231                }
232
233                // devdoc: errors are also arrays
234                if let Some(de) = dd_errors
235                    && let Some(arr) = de.get(*sig).and_then(|v| v.as_array())
236                    && let Some(first) = arr.first()
237                {
238                    entry.details = first
239                        .get("details")
240                        .and_then(|v| v.as_str())
241                        .map(|s| s.to_string());
242                    if let Some(params) = first.get("params").and_then(|p| p.as_object()) {
243                        for (pname, pdesc) in params {
244                            if let Some(desc) = pdesc.as_str() {
245                                entry.params.push((pname.clone(), desc.to_string()));
246                            }
247                        }
248                    }
249                }
250
251                if entry.notice.is_none() && entry.details.is_none() && entry.params.is_empty() {
252                    continue;
253                }
254
255                // Compute 4-byte error selector from the canonical signature
256                // errorSelector = keccak256(sig)[0..4]
257                let selector = FuncSelector::new(compute_selector(sig));
258                index.insert(DocKey::Func(selector), entry);
259            }
260
261            // ── Event docs ──
262            let ud_events = userdoc
263                .and_then(|u| u.get("events"))
264                .and_then(|e| e.as_object());
265            let dd_events = devdoc
266                .and_then(|d| d.get("events"))
267                .and_then(|e| e.as_object());
268
269            let mut all_event_sigs: Vec<&str> = Vec::new();
270            if let Some(ue) = ud_events {
271                all_event_sigs.extend(ue.keys().map(|k| k.as_str()));
272            }
273            if let Some(de) = dd_events {
274                for k in de.keys() {
275                    if !all_event_sigs.contains(&k.as_str()) {
276                        all_event_sigs.push(k.as_str());
277                    }
278                }
279            }
280
281            for sig in &all_event_sigs {
282                let mut entry = DocEntry::default();
283
284                if let Some(ue) = ud_events
285                    && let Some(ev) = ue.get(*sig)
286                {
287                    entry.notice = ev
288                        .get("notice")
289                        .and_then(|v| v.as_str())
290                        .map(|s| s.to_string());
291                }
292
293                if let Some(de) = dd_events
294                    && let Some(ev) = de.get(*sig)
295                {
296                    entry.details = ev
297                        .get("details")
298                        .and_then(|v| v.as_str())
299                        .map(|s| s.to_string());
300                    if let Some(params) = ev.get("params").and_then(|p| p.as_object()) {
301                        for (pname, pdesc) in params {
302                            if let Some(desc) = pdesc.as_str() {
303                                entry.params.push((pname.clone(), desc.to_string()));
304                            }
305                        }
306                    }
307                }
308
309                if entry.notice.is_none() && entry.details.is_none() && entry.params.is_empty() {
310                    continue;
311                }
312
313                // Event topic = full keccak256 hash of canonical signature
314                let topic = EventSelector::new(compute_event_topic(sig));
315                index.insert(DocKey::Event(topic), entry);
316            }
317
318            // ── State variable docs (from devdoc) ──
319            if let Some(dd) = devdoc
320                && let Some(state_vars) = dd.get("stateVariables").and_then(|s| s.as_object())
321            {
322                for (var_name, var_doc) in state_vars {
323                    let mut entry = DocEntry::default();
324                    entry.details = var_doc
325                        .get("details")
326                        .and_then(|v| v.as_str())
327                        .map(|s| s.to_string());
328
329                    if let Some(returns) = var_doc.get("return").and_then(|v| v.as_str()) {
330                        entry.returns.push(("_0".to_string(), returns.to_string()));
331                    }
332                    if let Some(returns) = var_doc.get("returns").and_then(|r| r.as_object()) {
333                        for (rname, rdesc) in returns {
334                            if let Some(desc) = rdesc.as_str() {
335                                entry.returns.push((rname.clone(), desc.to_string()));
336                            }
337                        }
338                    }
339
340                    if entry.details.is_some() || !entry.returns.is_empty() {
341                        let key = DocKey::StateVar(format!("{path}:{name}:{var_name}"));
342                        index.insert(key, entry);
343                    }
344                }
345            }
346        }
347    }
348
349    index
350}
351
352/// Compute a 4-byte function/error selector from a canonical ABI signature.
353///
354/// `keccak256("transfer(address,uint256)")` → first 4 bytes as hex.
355fn compute_selector(sig: &str) -> String {
356    use tiny_keccak::{Hasher, Keccak};
357    let mut hasher = Keccak::v256();
358    hasher.update(sig.as_bytes());
359    let mut output = [0u8; 32];
360    hasher.finalize(&mut output);
361    hex::encode(&output[..4])
362}
363
364/// Compute a full 32-byte event topic from a canonical ABI signature.
365///
366/// `keccak256("Transfer(address,address,uint256)")` → full hash as hex.
367fn compute_event_topic(sig: &str) -> String {
368    use tiny_keccak::{Hasher, Keccak};
369    let mut hasher = Keccak::v256();
370    hasher.update(sig.as_bytes());
371    let mut output = [0u8; 32];
372    hasher.finalize(&mut output);
373    hex::encode(output)
374}
375
376/// Look up documentation for an AST declaration node from the DocIndex.
377///
378/// Returns a cloned DocEntry since key construction is dynamic.
379pub fn lookup_doc_entry(
380    doc_index: &DocIndex,
381    decl_node: &Value,
382    sources: &Value,
383) -> Option<DocEntry> {
384    let node_type = decl_node.get("nodeType").and_then(|v| v.as_str())?;
385
386    match node_type {
387        "FunctionDefinition" | "VariableDeclaration" => {
388            // Try by functionSelector first
389            if let Some(selector) = decl_node.get("functionSelector").and_then(|v| v.as_str()) {
390                let key = DocKey::Func(FuncSelector::new(selector));
391                if let Some(entry) = doc_index.get(&key) {
392                    return Some(entry.clone());
393                }
394            }
395
396            // For state variables without selector, try statevar key
397            if node_type == "VariableDeclaration" {
398                let var_name = decl_node.get("name").and_then(|v| v.as_str())?;
399                // Find containing contract via scope
400                let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
401                let scope_node = find_node_by_id(sources, NodeId(scope_id))?;
402                let contract_name = scope_node.get("name").and_then(|v| v.as_str())?;
403
404                // Need to find the path — walk source units
405                let path = find_source_path_for_node(sources, scope_id)?;
406                let key = DocKey::StateVar(format!("{path}:{contract_name}:{var_name}"));
407                if let Some(entry) = doc_index.get(&key) {
408                    return Some(entry.clone());
409                }
410            }
411
412            // Fallback: try method by name
413            let fn_name = decl_node.get("name").and_then(|v| v.as_str())?;
414            let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
415            let scope_node = find_node_by_id(sources, NodeId(scope_id))?;
416            let contract_name = scope_node.get("name").and_then(|v| v.as_str())?;
417            let path = find_source_path_for_node(sources, scope_id)?;
418            let key = DocKey::Method(format!("{path}:{contract_name}:{fn_name}"));
419            doc_index.get(&key).cloned()
420        }
421        "ErrorDefinition" => {
422            if let Some(selector) = decl_node.get("errorSelector").and_then(|v| v.as_str()) {
423                let key = DocKey::Func(FuncSelector::new(selector));
424                return doc_index.get(&key).cloned();
425            }
426            None
427        }
428        "EventDefinition" => {
429            if let Some(selector) = decl_node.get("eventSelector").and_then(|v| v.as_str()) {
430                let key = DocKey::Event(EventSelector::new(selector));
431                return doc_index.get(&key).cloned();
432            }
433            None
434        }
435        "ContractDefinition" => {
436            let contract_name = decl_node.get("name").and_then(|v| v.as_str())?;
437            // Find the source path for this contract
438            let node_id = decl_node.get("id").and_then(|v| v.as_u64())?;
439            let path = find_source_path_for_node(sources, node_id)?;
440            let key = DocKey::Contract(format!("{path}:{contract_name}"));
441            doc_index.get(&key).cloned()
442        }
443        _ => None,
444    }
445}
446
447/// Look up documentation for a parameter from its parent function/error/event.
448///
449/// When hovering a `VariableDeclaration` that is a parameter or return value,
450/// this walks up to the parent declaration (via `scope`) and extracts the
451/// relevant `@param` or `@return` entry for this specific name.
452///
453/// Tries the DocIndex first (structured devdoc), then falls back to parsing
454/// the raw AST `documentation` field.
455pub fn lookup_param_doc(
456    doc_index: &DocIndex,
457    decl_node: &Value,
458    sources: &Value,
459) -> Option<String> {
460    let node_type = decl_node.get("nodeType").and_then(|v| v.as_str())?;
461    if node_type != "VariableDeclaration" {
462        return None;
463    }
464
465    let param_name = decl_node.get("name").and_then(|v| v.as_str())?;
466    if param_name.is_empty() {
467        return None;
468    }
469
470    // Walk up to the parent via scope
471    let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
472    let parent_node = find_node_by_id(sources, NodeId(scope_id))?;
473    let parent_type = parent_node.get("nodeType").and_then(|v| v.as_str())?;
474
475    // Only handle function/error/event parents
476    if !matches!(
477        parent_type,
478        "FunctionDefinition" | "ErrorDefinition" | "EventDefinition" | "ModifierDefinition"
479    ) {
480        return None;
481    }
482
483    // Determine if this param is an input parameter or a return value
484    let is_return = if parent_type == "FunctionDefinition" {
485        parent_node
486            .get("returnParameters")
487            .and_then(|rp| rp.get("parameters"))
488            .and_then(|p| p.as_array())
489            .map(|arr| {
490                let decl_id = decl_node.get("id").and_then(|v| v.as_u64());
491                arr.iter()
492                    .any(|p| p.get("id").and_then(|v| v.as_u64()) == decl_id)
493            })
494            .unwrap_or(false)
495    } else {
496        false
497    };
498
499    // Try DocIndex first (structured devdoc)
500    if let Some(parent_doc) = lookup_doc_entry(doc_index, parent_node, sources) {
501        if is_return {
502            // Look in returns
503            for (rname, rdesc) in &parent_doc.returns {
504                if rname == param_name {
505                    return Some(rdesc.clone());
506                }
507            }
508        } else {
509            // Look in params
510            for (pname, pdesc) in &parent_doc.params {
511                if pname == param_name {
512                    return Some(pdesc.clone());
513                }
514            }
515        }
516    }
517
518    // Fallback: parse raw AST documentation on the parent
519    if let Some(doc_text) = extract_documentation(parent_node) {
520        // Resolve @inheritdoc if present
521        let resolved = if doc_text.contains("@inheritdoc") {
522            resolve_inheritdoc(sources, parent_node, &doc_text)
523        } else {
524            None
525        };
526        let text = resolved.as_deref().unwrap_or(&doc_text);
527
528        let tag = if is_return { "@return " } else { "@param " };
529        for line in text.lines() {
530            let trimmed = line.trim().trim_start_matches('*').trim();
531            if let Some(rest) = trimmed.strip_prefix(tag) {
532                if let Some((name, desc)) = rest.split_once(' ') {
533                    if name == param_name {
534                        return Some(desc.to_string());
535                    }
536                } else if rest == param_name {
537                    return Some(String::new());
538                }
539            }
540        }
541    }
542
543    None
544}
545
546/// Find the source file path that contains a given node id.
547fn find_source_path_for_node(sources: &Value, target_id: u64) -> Option<String> {
548    let sources_obj = sources.as_object()?;
549    for (path, source_data) in sources_obj {
550        let ast = source_data.get("ast")?;
551        // Check if this source unit contains the node (check source unit id first)
552        let source_id = ast.get("id").and_then(|v| v.as_u64())?;
553        if source_id == target_id {
554            return Some(path.clone());
555        }
556
557        // Check nodes in this source
558        if let Some(nodes) = ast.get("nodes").and_then(|n| n.as_array()) {
559            for node in nodes {
560                if let Some(id) = node.get("id").and_then(|v| v.as_u64())
561                    && id == target_id
562                {
563                    return Some(path.clone());
564                }
565                // Check one more level (functions inside contracts)
566                if let Some(sub_nodes) = node.get("nodes").and_then(|n| n.as_array()) {
567                    for sub in sub_nodes {
568                        if let Some(id) = sub.get("id").and_then(|v| v.as_u64())
569                            && id == target_id
570                        {
571                            return Some(path.clone());
572                        }
573                    }
574                }
575            }
576        }
577    }
578    None
579}
580
581/// Format a `DocEntry` as markdown for hover display.
582pub fn format_doc_entry(entry: &DocEntry) -> String {
583    let mut lines: Vec<String> = Vec::new();
584
585    // Title (contract-level)
586    if let Some(title) = &entry.title {
587        lines.push(format!("**{title}**"));
588        lines.push(String::new());
589    }
590
591    // Notice (@notice)
592    if let Some(notice) = &entry.notice {
593        lines.push(notice.clone());
594    }
595
596    // Author
597    if let Some(author) = &entry.author {
598        lines.push(format!("*@author {author}*"));
599    }
600
601    // Details (@dev)
602    if let Some(details) = &entry.details {
603        lines.push(String::new());
604        lines.push("**@dev**".to_string());
605        lines.push(format!("*{details}*"));
606    }
607
608    // Parameters (@param)
609    if !entry.params.is_empty() {
610        lines.push(String::new());
611        lines.push("**Parameters:**".to_string());
612        for (name, desc) in &entry.params {
613            lines.push(format!("- `{name}` — {desc}"));
614        }
615    }
616
617    // Returns (@return)
618    if !entry.returns.is_empty() {
619        lines.push(String::new());
620        lines.push("**Returns:**".to_string());
621        for (name, desc) in &entry.returns {
622            if name.starts_with('_') && name.len() <= 3 {
623                // Unnamed return (e.g. "_0") — just show description
624                lines.push(format!("- {desc}"));
625            } else {
626                lines.push(format!("- `{name}` — {desc}"));
627            }
628        }
629    }
630
631    lines.join("\n")
632}
633
634/// Find the raw AST node with the given id by walking all sources.
635pub fn find_node_by_id(sources: &Value, target_id: NodeId) -> Option<&Value> {
636    let sources_obj = sources.as_object()?;
637    for (_path, source_data) in sources_obj {
638        let ast = source_data.get("ast")?;
639
640        // Check root
641        if ast.get("id").and_then(|v| v.as_u64()) == Some(target_id.0) {
642            return Some(ast);
643        }
644
645        let mut stack = vec![ast];
646        while let Some(node) = stack.pop() {
647            if node.get("id").and_then(|v| v.as_u64()) == Some(target_id.0) {
648                return Some(node);
649            }
650            for key in CHILD_KEYS {
651                if let Some(value) = node.get(key) {
652                    match value {
653                        Value::Array(arr) => stack.extend(arr.iter()),
654                        Value::Object(_) => stack.push(value),
655                        _ => {}
656                    }
657                }
658            }
659        }
660    }
661    None
662}
663
664/// Extract documentation text from a node.
665/// Handles both object form `{text: "..."}` and plain string form.
666pub fn extract_documentation(node: &Value) -> Option<String> {
667    let doc = node.get("documentation")?;
668    match doc {
669        Value::Object(_) => doc
670            .get("text")
671            .and_then(|v| v.as_str())
672            .map(|s| s.to_string()),
673        Value::String(s) => Some(s.clone()),
674        _ => None,
675    }
676}
677
678/// Extract the selector from a declaration node.
679///
680/// Returns a [`Selector`] — either a 4-byte [`FuncSelector`] (for functions,
681/// public variables, and errors) or a 32-byte [`EventSelector`] (for events).
682pub fn extract_selector(node: &Value) -> Option<Selector> {
683    let node_type = node.get("nodeType").and_then(|v| v.as_str())?;
684    match node_type {
685        "FunctionDefinition" | "VariableDeclaration" => node
686            .get("functionSelector")
687            .and_then(|v| v.as_str())
688            .map(|s| Selector::Func(FuncSelector::new(s))),
689        "ErrorDefinition" => node
690            .get("errorSelector")
691            .and_then(|v| v.as_str())
692            .map(|s| Selector::Func(FuncSelector::new(s))),
693        "EventDefinition" => node
694            .get("eventSelector")
695            .and_then(|v| v.as_str())
696            .map(|s| Selector::Event(EventSelector::new(s))),
697        _ => None,
698    }
699}
700
701/// Resolve `@inheritdoc ParentName` by matching function selectors.
702///
703/// 1. Parse the parent contract name from `@inheritdoc ParentName`
704/// 2. Get the declaration's `functionSelector`
705/// 3. Find the parent contract in `baseContracts` of the scope contract
706/// 4. Match by selector in the parent's child nodes
707/// 5. Return the matched parent node's documentation
708pub fn resolve_inheritdoc<'a>(
709    sources: &'a Value,
710    decl_node: &'a Value,
711    doc_text: &str,
712) -> Option<String> {
713    // Parse "@inheritdoc ParentName"
714    let parent_name = doc_text
715        .lines()
716        .find_map(|line| {
717            let trimmed = line.trim().trim_start_matches('*').trim();
718            trimmed.strip_prefix("@inheritdoc ")
719        })?
720        .trim();
721
722    // Get the selector from the implementation function
723    let impl_selector = extract_selector(decl_node)?;
724
725    // Get the scope (containing contract id)
726    let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
727
728    // Find the scope contract
729    let scope_contract = find_node_by_id(sources, NodeId(scope_id))?;
730
731    // Find the parent contract in baseContracts by name
732    let base_contracts = scope_contract
733        .get("baseContracts")
734        .and_then(|v| v.as_array())?;
735    let parent_id = base_contracts.iter().find_map(|base| {
736        let name = base
737            .get("baseName")
738            .and_then(|bn| bn.get("name"))
739            .and_then(|n| n.as_str())?;
740        if name == parent_name {
741            base.get("baseName")
742                .and_then(|bn| bn.get("referencedDeclaration"))
743                .and_then(|v| v.as_u64())
744        } else {
745            None
746        }
747    })?;
748
749    // Find the parent contract node
750    let parent_contract = find_node_by_id(sources, NodeId(parent_id))?;
751
752    // Search parent's children for matching selector
753    let parent_nodes = parent_contract.get("nodes").and_then(|v| v.as_array())?;
754    for child in parent_nodes {
755        if let Some(child_selector) = extract_selector(child)
756            && child_selector == impl_selector
757        {
758            return extract_documentation(child);
759        }
760    }
761
762    None
763}
764
765/// Format NatSpec documentation as markdown.
766/// Strips leading `@` tags and formats them nicely.
767/// When `inherited_doc` is provided, it replaces `@inheritdoc` lines with the resolved content.
768pub fn format_natspec(text: &str, inherited_doc: Option<&str>) -> String {
769    let mut lines: Vec<String> = Vec::new();
770    let mut in_params = false;
771    let mut in_returns = false;
772
773    for raw_line in text.lines() {
774        let line = raw_line.trim().trim_start_matches('*').trim();
775        if line.is_empty() {
776            continue;
777        }
778
779        if let Some(rest) = line.strip_prefix("@title ") {
780            in_params = false;
781            in_returns = false;
782            lines.push(format!("**{rest}**"));
783            lines.push(String::new());
784        } else if let Some(rest) = line.strip_prefix("@notice ") {
785            in_params = false;
786            in_returns = false;
787            lines.push(rest.to_string());
788        } else if let Some(rest) = line.strip_prefix("@dev ") {
789            in_params = false;
790            in_returns = false;
791            lines.push(String::new());
792            lines.push("**@dev**".to_string());
793            lines.push(format!("*{rest}*"));
794        } else if let Some(rest) = line.strip_prefix("@param ") {
795            if !in_params {
796                in_params = true;
797                in_returns = false;
798                lines.push(String::new());
799                lines.push("**Parameters:**".to_string());
800            }
801            if let Some((name, desc)) = rest.split_once(' ') {
802                lines.push(format!("- `{name}` — {desc}"));
803            } else {
804                lines.push(format!("- `{rest}`"));
805            }
806        } else if let Some(rest) = line.strip_prefix("@return ") {
807            if !in_returns {
808                in_returns = true;
809                in_params = false;
810                lines.push(String::new());
811                lines.push("**Returns:**".to_string());
812            }
813            if let Some((name, desc)) = rest.split_once(' ') {
814                lines.push(format!("- `{name}` — {desc}"));
815            } else {
816                lines.push(format!("- `{rest}`"));
817            }
818        } else if let Some(rest) = line.strip_prefix("@author ") {
819            in_params = false;
820            in_returns = false;
821            lines.push(format!("*@author {rest}*"));
822        } else if line.starts_with("@inheritdoc ") {
823            // Resolve inherited docs if available
824            if let Some(inherited) = inherited_doc {
825                // Recursively format the inherited doc (it won't have another @inheritdoc)
826                let formatted = format_natspec(inherited, None);
827                if !formatted.is_empty() {
828                    lines.push(formatted);
829                }
830            } else {
831                let parent = line.strip_prefix("@inheritdoc ").unwrap_or("");
832                lines.push(format!("*Inherits documentation from `{parent}`*"));
833            }
834        } else if line.starts_with('@') {
835            // Any other tag (@custom:xyz, @dev, etc.)
836            in_params = false;
837            in_returns = false;
838            if let Some((tag, rest)) = line.split_once(' ') {
839                lines.push(String::new());
840                lines.push(format!("**{tag}**"));
841                lines.push(format!("*{rest}*"));
842            } else {
843                lines.push(String::new());
844                lines.push(format!("**{line}**"));
845            }
846        } else {
847            // Continuation line
848            lines.push(line.to_string());
849        }
850    }
851
852    lines.join("\n")
853}
854
855/// Build a function/modifier signature string from a raw AST node.
856fn build_function_signature(node: &Value) -> Option<String> {
857    let node_type = node.get("nodeType").and_then(|v| v.as_str())?;
858    let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
859
860    match node_type {
861        "FunctionDefinition" => {
862            let kind = node
863                .get("kind")
864                .and_then(|v| v.as_str())
865                .unwrap_or("function");
866            let visibility = node
867                .get("visibility")
868                .and_then(|v| v.as_str())
869                .unwrap_or("");
870            let state_mutability = node
871                .get("stateMutability")
872                .and_then(|v| v.as_str())
873                .unwrap_or("");
874
875            let params = format_parameters(node.get("parameters"));
876            let returns = format_parameters(node.get("returnParameters"));
877
878            let mut sig = match kind {
879                "constructor" => format!("constructor({params})"),
880                "receive" => "receive() external payable".to_string(),
881                "fallback" => format!("fallback({params})"),
882                _ => format!("function {name}({params})"),
883            };
884
885            if !visibility.is_empty() && kind != "constructor" && kind != "receive" {
886                sig.push_str(&format!(" {visibility}"));
887            }
888            if !state_mutability.is_empty() && state_mutability != "nonpayable" {
889                sig.push_str(&format!(" {state_mutability}"));
890            }
891            if !returns.is_empty() {
892                sig.push_str(&format!(" returns ({returns})"));
893            }
894            Some(sig)
895        }
896        "ModifierDefinition" => {
897            let params = format_parameters(node.get("parameters"));
898            Some(format!("modifier {name}({params})"))
899        }
900        "EventDefinition" => {
901            let params = format_parameters(node.get("parameters"));
902            Some(format!("event {name}({params})"))
903        }
904        "ErrorDefinition" => {
905            let params = format_parameters(node.get("parameters"));
906            Some(format!("error {name}({params})"))
907        }
908        "VariableDeclaration" => {
909            let type_str = node
910                .get("typeDescriptions")
911                .and_then(|v| v.get("typeString"))
912                .and_then(|v| v.as_str())
913                .unwrap_or("unknown");
914            let visibility = node
915                .get("visibility")
916                .and_then(|v| v.as_str())
917                .unwrap_or("");
918            let mutability = node
919                .get("mutability")
920                .and_then(|v| v.as_str())
921                .unwrap_or("");
922
923            let mut sig = type_str.to_string();
924            if !visibility.is_empty() {
925                sig.push_str(&format!(" {visibility}"));
926            }
927            if mutability == "constant" || mutability == "immutable" {
928                sig.push_str(&format!(" {mutability}"));
929            }
930            sig.push_str(&format!(" {name}"));
931            Some(sig)
932        }
933        "ContractDefinition" => {
934            let contract_kind = node
935                .get("contractKind")
936                .and_then(|v| v.as_str())
937                .unwrap_or("contract");
938
939            let mut sig = format!("{contract_kind} {name}");
940
941            // Add base contracts
942            if let Some(bases) = node.get("baseContracts").and_then(|v| v.as_array())
943                && !bases.is_empty()
944            {
945                let base_names: Vec<&str> = bases
946                    .iter()
947                    .filter_map(|b| {
948                        b.get("baseName")
949                            .and_then(|bn| bn.get("name"))
950                            .and_then(|n| n.as_str())
951                    })
952                    .collect();
953                if !base_names.is_empty() {
954                    sig.push_str(&format!(" is {}", base_names.join(", ")));
955                }
956            }
957            Some(sig)
958        }
959        "StructDefinition" => {
960            let mut sig = format!("struct {name} {{\n");
961            if let Some(members) = node.get("members").and_then(|v| v.as_array()) {
962                for member in members {
963                    let mname = member.get("name").and_then(|v| v.as_str()).unwrap_or("?");
964                    let mtype = member
965                        .get("typeDescriptions")
966                        .and_then(|v| v.get("typeString"))
967                        .and_then(|v| v.as_str())
968                        .unwrap_or("?");
969                    sig.push_str(&format!("    {mtype} {mname};\n"));
970                }
971            }
972            sig.push('}');
973            Some(sig)
974        }
975        "EnumDefinition" => {
976            let mut sig = format!("enum {name} {{\n");
977            if let Some(members) = node.get("members").and_then(|v| v.as_array()) {
978                let names: Vec<&str> = members
979                    .iter()
980                    .filter_map(|m| m.get("name").and_then(|v| v.as_str()))
981                    .collect();
982                for n in &names {
983                    sig.push_str(&format!("    {n},\n"));
984                }
985            }
986            sig.push('}');
987            Some(sig)
988        }
989        "UserDefinedValueTypeDefinition" => {
990            let underlying = node
991                .get("underlyingType")
992                .and_then(|v| v.get("typeDescriptions"))
993                .and_then(|v| v.get("typeString"))
994                .and_then(|v| v.as_str())
995                .unwrap_or("unknown");
996            Some(format!("type {name} is {underlying}"))
997        }
998        _ => None,
999    }
1000}
1001
1002/// Format parameter list from a parameters node.
1003fn format_parameters(params_node: Option<&Value>) -> String {
1004    let params_node = match params_node {
1005        Some(v) => v,
1006        None => return String::new(),
1007    };
1008    let params = match params_node.get("parameters").and_then(|v| v.as_array()) {
1009        Some(arr) => arr,
1010        None => return String::new(),
1011    };
1012
1013    let parts: Vec<String> = params
1014        .iter()
1015        .map(|p| {
1016            let type_str = p
1017                .get("typeDescriptions")
1018                .and_then(|v| v.get("typeString"))
1019                .and_then(|v| v.as_str())
1020                .unwrap_or("?");
1021            let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
1022            let storage = p
1023                .get("storageLocation")
1024                .and_then(|v| v.as_str())
1025                .unwrap_or("default");
1026
1027            if name.is_empty() {
1028                type_str.to_string()
1029            } else if storage != "default" {
1030                format!("{type_str} {storage} {name}")
1031            } else {
1032                format!("{type_str} {name}")
1033            }
1034        })
1035        .collect();
1036
1037    parts.join(", ")
1038}
1039
1040// ── Signature Help ─────────────────────────────────────────────────────────
1041
1042/// Build individual parameter strings from a parameters AST node.
1043///
1044/// Returns a vec of strings like `["uint256 amount", "uint16 tax", "uint16 base"]`.
1045fn build_parameter_strings(params_node: Option<&Value>) -> Vec<String> {
1046    let params_node = match params_node {
1047        Some(v) => v,
1048        None => return vec![],
1049    };
1050    let params = match params_node.get("parameters").and_then(|v| v.as_array()) {
1051        Some(arr) => arr,
1052        None => return vec![],
1053    };
1054
1055    params
1056        .iter()
1057        .map(|p| {
1058            let type_str = p
1059                .get("typeDescriptions")
1060                .and_then(|v| v.get("typeString"))
1061                .and_then(|v| v.as_str())
1062                .unwrap_or("?");
1063            let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
1064            let storage = p
1065                .get("storageLocation")
1066                .and_then(|v| v.as_str())
1067                .unwrap_or("default");
1068
1069            if name.is_empty() {
1070                type_str.to_string()
1071            } else if storage != "default" {
1072                format!("{type_str} {storage} {name}")
1073            } else {
1074                format!("{type_str} {name}")
1075            }
1076        })
1077        .collect()
1078}
1079
1080/// Find a mapping `VariableDeclaration` by name, walking all AST sources.
1081///
1082/// Returns the declaration node whose `name` matches and whose
1083/// `typeName.nodeType` is `"Mapping"`.
1084fn find_mapping_decl_by_name<'a>(sources: &'a Value, name: &str) -> Option<&'a Value> {
1085    let sources_obj = sources.as_object()?;
1086    for (_path, source_data) in sources_obj {
1087        let ast = source_data.get("ast")?;
1088        let mut stack = vec![ast];
1089        while let Some(node) = stack.pop() {
1090            if node.get("nodeType").and_then(|v| v.as_str()) == Some("VariableDeclaration")
1091                && node.get("name").and_then(|v| v.as_str()) == Some(name)
1092                && node
1093                    .get("typeName")
1094                    .and_then(|t| t.get("nodeType"))
1095                    .and_then(|v| v.as_str())
1096                    == Some("Mapping")
1097            {
1098                return Some(node);
1099            }
1100            for key in CHILD_KEYS {
1101                if let Some(value) = node.get(key) {
1102                    match value {
1103                        Value::Array(arr) => stack.extend(arr.iter()),
1104                        Value::Object(_) => stack.push(value),
1105                        _ => {}
1106                    }
1107                }
1108            }
1109        }
1110    }
1111    None
1112}
1113
1114/// Build signature help for a mapping index access like `orders[orderId]`.
1115///
1116/// Produces a label like `orders[bytes32 key]` or `orders[bytes32]` when the
1117/// mapping key has no name, and marks the key parameter as active.
1118fn mapping_signature_help(sources: &Value, name: &str) -> Option<SignatureHelp> {
1119    let decl = find_mapping_decl_by_name(sources, name)?;
1120    let type_name = decl.get("typeName")?;
1121
1122    let key_type = type_name
1123        .get("keyType")
1124        .and_then(|k| k.get("typeDescriptions"))
1125        .and_then(|t| t.get("typeString"))
1126        .and_then(|v| v.as_str())
1127        .unwrap_or("unknown");
1128
1129    // Named mapping keys (Solidity ≥0.8.18): `mapping(PoolId id => Pool.State)`
1130    let key_name = type_name
1131        .get("keyName")
1132        .and_then(|v| v.as_str())
1133        .filter(|s| !s.is_empty());
1134
1135    let param_str = if let Some(kn) = key_name {
1136        format!("{} {}", key_type, kn)
1137    } else {
1138        key_type.to_string()
1139    };
1140
1141    let sig_label = format!("{}[{}]", name, param_str);
1142
1143    // Parameter covers the key portion inside the brackets
1144    let param_start = name.len() + 1; // after `[`
1145    let param_end = param_start + param_str.len();
1146
1147    // Try to get @param doc for the key
1148    let key_param_name = key_name.unwrap_or("");
1149    let var_name = decl.get("name").and_then(|v| v.as_str()).unwrap_or("");
1150
1151    // stateVariables devdoc: look for @param doc on the mapping variable
1152    let _param_doc: Option<String> = None;
1153
1154    let param_info = ParameterInformation {
1155        label: ParameterLabel::LabelOffsets([param_start as u32, param_end as u32]),
1156        documentation: if !key_param_name.is_empty() {
1157            Some(Documentation::MarkupContent(MarkupContent {
1158                kind: MarkupKind::Markdown,
1159                value: format!("`{}` — key for `{}`", key_param_name, var_name),
1160            }))
1161        } else {
1162            None
1163        },
1164    };
1165
1166    // Get the value type for function-level documentation
1167    let value_type = type_name
1168        .get("valueType")
1169        .and_then(|v| v.get("typeDescriptions"))
1170        .and_then(|t| t.get("typeString"))
1171        .and_then(|v| v.as_str());
1172
1173    let sig_doc = value_type.map(|vt| format!("@returns `{}`", vt));
1174
1175    Some(SignatureHelp {
1176        signatures: vec![SignatureInformation {
1177            label: sig_label,
1178            documentation: sig_doc.map(|doc| {
1179                Documentation::MarkupContent(MarkupContent {
1180                    kind: MarkupKind::Markdown,
1181                    value: doc,
1182                })
1183            }),
1184            parameters: Some(vec![param_info]),
1185            active_parameter: Some(0),
1186        }],
1187        active_signature: Some(0),
1188        active_parameter: Some(0),
1189    })
1190}
1191
1192/// Produce signature help for the call at the given position.
1193///
1194/// Uses tree-sitter on the live buffer to find the enclosing call and argument
1195/// index, then resolves the declaration via `HintIndex` to build the signature
1196/// with parameter label offsets and `@param` documentation.
1197///
1198/// Also handles mapping index access (`name[key]`), showing the key type.
1199pub fn signature_help(
1200    ast_data: &Value,
1201    source_bytes: &[u8],
1202    position: Position,
1203    hint_index: &HintIndex,
1204    doc_index: &DocIndex,
1205) -> Option<SignatureHelp> {
1206    let sources = ast_data.get("sources")?;
1207
1208    let source_str = String::from_utf8_lossy(source_bytes);
1209    let tree = crate::inlay_hints::ts_parse(&source_str)?;
1210    let byte_pos = pos_to_bytes(source_bytes, position);
1211
1212    // Find the enclosing call and which argument the cursor is on
1213    let ctx =
1214        crate::inlay_hints::ts_find_call_for_signature(tree.root_node(), &source_str, byte_pos)?;
1215
1216    // Mapping index access: resolve via AST VariableDeclaration
1217    if ctx.is_index_access {
1218        return mapping_signature_help(sources, ctx.name);
1219    }
1220
1221    // Try all hint lookups to resolve the callsite declaration and get skip count
1222    let (decl_id, skip) = hint_index.values().find_map(|lookup| {
1223        lookup.resolve_callsite_with_skip(ctx.call_start_byte, ctx.name, ctx.arg_count)
1224    })?;
1225
1226    // Find the declaration AST node
1227    let decl_node = find_node_by_id(sources, NodeId(decl_id))?;
1228
1229    // Build the signature label
1230    let sig_label = build_function_signature(&decl_node)?;
1231
1232    // Build individual parameter strings for offset calculation
1233    let param_strings = build_parameter_strings(decl_node.get("parameters"));
1234
1235    // Look up @param docs from DocIndex
1236    let doc_entry = lookup_doc_entry(doc_index, &decl_node, sources);
1237
1238    // Calculate parameter label offsets within the signature string
1239    // The signature looks like: "function name(uint256 amount, uint16 tax) ..."
1240    // We need to find the byte offset of each parameter within the label
1241    let params_start = sig_label.find('(')? + 1;
1242    let mut param_infos = Vec::new();
1243    let mut offset = params_start;
1244
1245    for (i, param_str) in param_strings.iter().enumerate() {
1246        let start = offset;
1247        let end = start + param_str.len();
1248
1249        // Find @param doc for this parameter
1250        let param_name = decl_node
1251            .get("parameters")
1252            .and_then(|p| p.get("parameters"))
1253            .and_then(|arr| arr.as_array())
1254            .and_then(|arr| arr.get(i))
1255            .and_then(|p| p.get("name"))
1256            .and_then(|v| v.as_str())
1257            .unwrap_or("");
1258
1259        let param_doc = doc_entry.as_ref().and_then(|entry| {
1260            entry
1261                .params
1262                .iter()
1263                .find(|(name, _)| name == param_name)
1264                .map(|(_, desc)| desc.clone())
1265        });
1266
1267        param_infos.push(ParameterInformation {
1268            label: ParameterLabel::LabelOffsets([start as u32, end as u32]),
1269            documentation: param_doc.map(|doc| {
1270                Documentation::MarkupContent(MarkupContent {
1271                    kind: MarkupKind::Markdown,
1272                    value: doc,
1273                })
1274            }),
1275        });
1276
1277        // +2 for ", " separator
1278        offset = end + 2;
1279    }
1280
1281    // Build notice/dev documentation for the signature
1282    let sig_doc = doc_entry.as_ref().and_then(|entry| {
1283        let mut parts = Vec::new();
1284        if let Some(notice) = &entry.notice {
1285            parts.push(notice.clone());
1286        }
1287        if let Some(details) = &entry.details {
1288            parts.push(format!("*{}*", details));
1289        }
1290        if parts.is_empty() {
1291            None
1292        } else {
1293            Some(parts.join("\n\n"))
1294        }
1295    });
1296
1297    // Adjust activeParameter for using-for (skip=1 means first param is self)
1298    let active_param = (ctx.arg_index + skip) as u32;
1299
1300    Some(SignatureHelp {
1301        signatures: vec![SignatureInformation {
1302            label: sig_label,
1303            documentation: sig_doc.map(|doc| {
1304                Documentation::MarkupContent(MarkupContent {
1305                    kind: MarkupKind::Markdown,
1306                    value: doc,
1307                })
1308            }),
1309            parameters: Some(param_infos),
1310            active_parameter: Some(active_param),
1311        }],
1312        active_signature: Some(0),
1313        active_parameter: Some(active_param),
1314    })
1315}
1316
1317/// Check if the source text has the gas sentinel comment above a declaration.
1318///
1319/// Looks at the lines preceding the declaration's byte offset in the source
1320/// for a comment containing `@lsp-enable gas-estimates`.
1321fn source_has_gas_sentinel(source: &str, decl_node: &Value) -> bool {
1322    let src_str = decl_node.get("src").and_then(|v| v.as_str()).unwrap_or("");
1323    let offset = src_str
1324        .split(':')
1325        .next()
1326        .and_then(|s| s.parse::<usize>().ok())
1327        .unwrap_or(0);
1328
1329    // Look at the text before this declaration for the sentinel
1330    let preceding = &source[..offset.min(source.len())];
1331    // Check last few lines before the declaration
1332    for line in preceding.lines().rev().take(10) {
1333        let trimmed = line.trim();
1334        if trimmed.contains(gas::GAS_SENTINEL) {
1335            return true;
1336        }
1337        // Stop if we hit a non-comment, non-empty line
1338        if !trimmed.is_empty()
1339            && !trimmed.starts_with("///")
1340            && !trimmed.starts_with("//")
1341            && !trimmed.starts_with('*')
1342            && !trimmed.starts_with("/*")
1343        {
1344            break;
1345        }
1346    }
1347    false
1348}
1349
1350/// Build gas hover text for a function declaration.
1351fn gas_hover_for_function(
1352    decl_node: &Value,
1353    sources: &Value,
1354    gas_index: &GasIndex,
1355) -> Option<String> {
1356    let node_type = decl_node.get("nodeType").and_then(|v| v.as_str())?;
1357    if node_type != "FunctionDefinition" {
1358        return None;
1359    }
1360
1361    // Try by selector first (external/public functions)
1362    if let Some(selector) = decl_node.get("functionSelector").and_then(|v| v.as_str())
1363        && let Some((_contract, cost)) =
1364            gas::gas_by_selector(gas_index, &FuncSelector::new(selector))
1365    {
1366        return Some(format!("Gas: `{}`", gas::format_gas(cost)));
1367    }
1368
1369    // Try by name (internal functions)
1370    let fn_name = decl_node.get("name").and_then(|v| v.as_str())?;
1371    let contract_key = gas::resolve_contract_key(sources, decl_node, gas_index)?;
1372    let contract_gas = gas_index.get(&contract_key)?;
1373
1374    // Match by name prefix in internal gas estimates
1375    let prefix = format!("{fn_name}(");
1376    for (sig, cost) in &contract_gas.internal {
1377        if sig.starts_with(&prefix) {
1378            return Some(format!("Gas: `{}`", gas::format_gas(cost)));
1379        }
1380    }
1381
1382    None
1383}
1384
1385/// Build gas hover text for a contract declaration.
1386fn gas_hover_for_contract(
1387    decl_node: &Value,
1388    sources: &Value,
1389    gas_index: &GasIndex,
1390) -> Option<String> {
1391    let node_type = decl_node.get("nodeType").and_then(|v| v.as_str())?;
1392    if node_type != "ContractDefinition" {
1393        return None;
1394    }
1395
1396    let contract_key = gas::resolve_contract_key(sources, decl_node, gas_index)?;
1397    let contract_gas = gas_index.get(&contract_key)?;
1398
1399    let mut lines = Vec::new();
1400
1401    // Creation/deploy costs
1402    if !contract_gas.creation.is_empty() {
1403        lines.push("**Deploy Cost**".to_string());
1404        if let Some(cost) = contract_gas.creation.get("totalCost") {
1405            lines.push(format!("- Total: `{}`", gas::format_gas(cost)));
1406        }
1407        if let Some(cost) = contract_gas.creation.get("codeDepositCost") {
1408            lines.push(format!("- Code deposit: `{}`", gas::format_gas(cost)));
1409        }
1410        if let Some(cost) = contract_gas.creation.get("executionCost") {
1411            lines.push(format!("- Execution: `{}`", gas::format_gas(cost)));
1412        }
1413    }
1414
1415    // External function gas
1416    if !contract_gas.external_by_sig.is_empty() {
1417        lines.push(String::new());
1418        lines.push("**Function Gas**".to_string());
1419
1420        let mut fns: Vec<(&MethodId, &String)> = contract_gas.external_by_sig.iter().collect();
1421        fns.sort_by_key(|(k, _)| k.as_str().to_string());
1422
1423        for (sig, cost) in fns {
1424            lines.push(format!("- `{}`: `{}`", sig.name(), gas::format_gas(cost)));
1425        }
1426    }
1427
1428    if lines.is_empty() {
1429        return None;
1430    }
1431
1432    Some(lines.join("\n"))
1433}
1434
1435/// Produce hover information for the symbol at the given position.
1436pub fn hover_info(
1437    ast_data: &Value,
1438    file_uri: &Url,
1439    position: Position,
1440    source_bytes: &[u8],
1441    gas_index: &GasIndex,
1442    doc_index: &DocIndex,
1443    hint_index: &HintIndex,
1444) -> Option<Hover> {
1445    let sources = ast_data.get("sources")?;
1446    let source_id_to_path = ast_data
1447        .get("source_id_to_path")
1448        .and_then(|v| v.as_object())?;
1449
1450    let id_to_path: HashMap<String, String> = source_id_to_path
1451        .iter()
1452        .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
1453        .collect();
1454
1455    let (nodes, path_to_abs, external_refs) = cache_ids(sources);
1456
1457    // Resolve the file path
1458    let file_path = file_uri.to_file_path().ok()?;
1459    let file_path_str = file_path.to_str()?;
1460
1461    // Find the absolute path for this file
1462    let abs_path = path_to_abs
1463        .iter()
1464        .find(|(k, _)| file_path_str.ends_with(k.as_str()))
1465        .map(|(_, v)| v.clone())?;
1466
1467    let byte_pos = pos_to_bytes(source_bytes, position);
1468
1469    // Resolve: first try Yul external refs, then normal node lookup
1470    let node_id = byte_to_decl_via_external_refs(&external_refs, &id_to_path, &abs_path, byte_pos)
1471        .or_else(|| byte_to_id(&nodes, &abs_path, byte_pos))?;
1472
1473    // Get the NodeInfo for this node
1474    let node_info = nodes
1475        .values()
1476        .find_map(|file_nodes| file_nodes.get(&node_id))?;
1477
1478    // Follow referenced_declaration to the declaration node
1479    let decl_id = node_info.referenced_declaration.unwrap_or(node_id);
1480
1481    // Find the raw AST node for the declaration
1482    let decl_node = find_node_by_id(sources, decl_id)?;
1483
1484    // Build hover content
1485    let mut parts: Vec<String> = Vec::new();
1486
1487    // Signature in a code block
1488    if let Some(sig) = build_function_signature(decl_node) {
1489        parts.push(format!("```solidity\n{sig}\n```"));
1490    } else {
1491        // Fallback: show type description for any node
1492        if let Some(type_str) = decl_node
1493            .get("typeDescriptions")
1494            .and_then(|v| v.get("typeString"))
1495            .and_then(|v| v.as_str())
1496        {
1497            let name = decl_node.get("name").and_then(|v| v.as_str()).unwrap_or("");
1498            parts.push(format!("```solidity\n{type_str} {name}\n```"));
1499        }
1500    }
1501
1502    // Selector (function, error, or event)
1503    if let Some(selector) = extract_selector(decl_node) {
1504        parts.push(format!("Selector: `{}`", selector.to_prefixed()));
1505    }
1506
1507    // Gas estimates — only shown when `@lsp-enable gas-estimates` is present
1508    if !gas_index.is_empty() {
1509        let source_str = String::from_utf8_lossy(source_bytes);
1510        if source_has_gas_sentinel(&source_str, decl_node) {
1511            if let Some(gas_text) = gas_hover_for_function(decl_node, sources, gas_index) {
1512                parts.push(gas_text);
1513            } else if let Some(gas_text) = gas_hover_for_contract(decl_node, sources, gas_index) {
1514                parts.push(gas_text);
1515            }
1516        }
1517    }
1518
1519    // Documentation — try userdoc/devdoc first, fall back to AST docs
1520    if let Some(doc_entry) = lookup_doc_entry(doc_index, decl_node, sources) {
1521        let formatted = format_doc_entry(&doc_entry);
1522        if !formatted.is_empty() {
1523            parts.push(format!("---\n{formatted}"));
1524        }
1525    } else if let Some(doc_text) = extract_documentation(decl_node) {
1526        let inherited_doc = resolve_inheritdoc(sources, decl_node, &doc_text);
1527        let formatted = format_natspec(&doc_text, inherited_doc.as_deref());
1528        if !formatted.is_empty() {
1529            parts.push(format!("---\n{formatted}"));
1530        }
1531    } else if let Some(param_doc) = lookup_param_doc(doc_index, decl_node, sources) {
1532        // Parameter/return value — show the @param/@return description from parent
1533        if !param_doc.is_empty() {
1534            parts.push(format!("---\n{param_doc}"));
1535        }
1536    }
1537
1538    // Call-site parameter doc: when the hovered node is used as an argument
1539    // in a function call, show the @param doc from the called function's definition.
1540    // Uses tree-sitter on the live buffer to find the enclosing call and argument
1541    // index, then resolves via HintIndex for the param name and declaration id.
1542    if let Some(hint_lookup) = hint_index.get(&abs_path) {
1543        let source_str = String::from_utf8_lossy(source_bytes);
1544        if let Some(tree) = crate::inlay_hints::ts_parse(&source_str) {
1545            if let Some(ctx) =
1546                crate::inlay_hints::ts_find_call_at_byte(tree.root_node(), &source_str, byte_pos)
1547            {
1548                if let Some(resolved) = hint_lookup.resolve_callsite_param(
1549                    ctx.call_start_byte,
1550                    ctx.name,
1551                    ctx.arg_count,
1552                    ctx.arg_index,
1553                ) {
1554                    // Look up @param doc using the declaration id
1555                    let fn_decl = find_node_by_id(sources, NodeId(resolved.decl_id));
1556                    let param_doc = fn_decl.and_then(|decl| {
1557                        // Try DocIndex first (structured devdoc)
1558                        if let Some(doc_entry) = lookup_doc_entry(doc_index, decl, sources) {
1559                            for (pname, pdesc) in &doc_entry.params {
1560                                if pname == &resolved.param_name {
1561                                    return Some(pdesc.clone());
1562                                }
1563                            }
1564                        }
1565                        // Fallback: parse raw NatSpec on the function definition
1566                        if let Some(doc_text) = extract_documentation(decl) {
1567                            let resolved_doc = if doc_text.contains("@inheritdoc") {
1568                                resolve_inheritdoc(sources, decl, &doc_text)
1569                            } else {
1570                                None
1571                            };
1572                            let text = resolved_doc.as_deref().unwrap_or(&doc_text);
1573                            for line in text.lines() {
1574                                let trimmed = line.trim().trim_start_matches('*').trim();
1575                                if let Some(rest) = trimmed.strip_prefix("@param ") {
1576                                    if let Some((name, desc)) = rest.split_once(' ') {
1577                                        if name == resolved.param_name {
1578                                            return Some(desc.to_string());
1579                                        }
1580                                    }
1581                                }
1582                            }
1583                        }
1584                        None
1585                    });
1586                    if let Some(desc) = param_doc {
1587                        if !desc.is_empty() {
1588                            parts.push(format!("**@param `{}`** — {desc}", resolved.param_name));
1589                        }
1590                    }
1591                }
1592            }
1593        }
1594    }
1595
1596    if parts.is_empty() {
1597        return None;
1598    }
1599
1600    Some(Hover {
1601        contents: HoverContents::Markup(MarkupContent {
1602            kind: MarkupKind::Markdown,
1603            value: parts.join("\n\n"),
1604        }),
1605        range: None,
1606    })
1607}
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612
1613    fn load_test_ast() -> Value {
1614        let data = std::fs::read_to_string("pool-manager-ast.json").expect("test fixture");
1615        let raw: Value = serde_json::from_str(&data).expect("valid json");
1616        crate::solc::normalize_forge_output(raw)
1617    }
1618
1619    #[test]
1620    fn test_find_node_by_id_pool_manager() {
1621        let ast = load_test_ast();
1622        let sources = ast.get("sources").unwrap();
1623        let node = find_node_by_id(sources, NodeId(1767)).unwrap();
1624        assert_eq!(
1625            node.get("name").and_then(|v| v.as_str()),
1626            Some("PoolManager")
1627        );
1628        assert_eq!(
1629            node.get("nodeType").and_then(|v| v.as_str()),
1630            Some("ContractDefinition")
1631        );
1632    }
1633
1634    #[test]
1635    fn test_find_node_by_id_initialize() {
1636        let ast = load_test_ast();
1637        let sources = ast.get("sources").unwrap();
1638        // IPoolManager.initialize has the full docs
1639        let node = find_node_by_id(sources, NodeId(2411)).unwrap();
1640        assert_eq!(
1641            node.get("name").and_then(|v| v.as_str()),
1642            Some("initialize")
1643        );
1644    }
1645
1646    #[test]
1647    fn test_extract_documentation_object() {
1648        let ast = load_test_ast();
1649        let sources = ast.get("sources").unwrap();
1650        // IPoolManager.initialize (id=2411) has full NatSpec
1651        let node = find_node_by_id(sources, NodeId(2411)).unwrap();
1652        let doc = extract_documentation(node).unwrap();
1653        assert!(doc.contains("@notice"));
1654        assert!(doc.contains("@param key"));
1655    }
1656
1657    #[test]
1658    fn test_extract_documentation_none() {
1659        let ast = load_test_ast();
1660        let sources = ast.get("sources").unwrap();
1661        // PoolKey struct (id=8887) — check if it has docs
1662        let node = find_node_by_id(sources, NodeId(8887)).unwrap();
1663        // PoolKey may or may not have docs, just verify no crash
1664        let _ = extract_documentation(node);
1665    }
1666
1667    #[test]
1668    fn test_format_natspec_notice_and_params() {
1669        let text = "@notice Initialize the state for a given pool ID\n @param key The pool key\n @param sqrtPriceX96 The initial square root price\n @return tick The initial tick";
1670        let formatted = format_natspec(text, None);
1671        assert!(formatted.contains("Initialize the state"));
1672        assert!(formatted.contains("**Parameters:**"));
1673        assert!(formatted.contains("`key`"));
1674        assert!(formatted.contains("**Returns:**"));
1675        assert!(formatted.contains("`tick`"));
1676    }
1677
1678    #[test]
1679    fn test_format_natspec_inheritdoc() {
1680        let text = "@inheritdoc IPoolManager";
1681        let formatted = format_natspec(text, None);
1682        assert!(formatted.contains("Inherits documentation from `IPoolManager`"));
1683    }
1684
1685    #[test]
1686    fn test_format_natspec_dev() {
1687        let text = "@notice Do something\n @dev This is an implementation detail";
1688        let formatted = format_natspec(text, None);
1689        assert!(formatted.contains("Do something"));
1690        assert!(formatted.contains("**@dev**"));
1691        assert!(formatted.contains("*This is an implementation detail*"));
1692    }
1693
1694    #[test]
1695    fn test_format_natspec_custom_tag() {
1696        let text = "@notice Do something\n @custom:security Non-reentrant";
1697        let formatted = format_natspec(text, None);
1698        assert!(formatted.contains("Do something"));
1699        assert!(formatted.contains("**@custom:security**"));
1700        assert!(formatted.contains("*Non-reentrant*"));
1701    }
1702
1703    #[test]
1704    fn test_build_function_signature_initialize() {
1705        let ast = load_test_ast();
1706        let sources = ast.get("sources").unwrap();
1707        let node = find_node_by_id(sources, NodeId(2411)).unwrap();
1708        let sig = build_function_signature(node).unwrap();
1709        assert!(sig.starts_with("function initialize("));
1710        assert!(sig.contains("returns"));
1711    }
1712
1713    #[test]
1714    fn test_build_signature_contract() {
1715        let ast = load_test_ast();
1716        let sources = ast.get("sources").unwrap();
1717        let node = find_node_by_id(sources, NodeId(1767)).unwrap();
1718        let sig = build_function_signature(node).unwrap();
1719        assert!(sig.contains("contract PoolManager"));
1720        assert!(sig.contains(" is "));
1721    }
1722
1723    #[test]
1724    fn test_build_signature_struct() {
1725        let ast = load_test_ast();
1726        let sources = ast.get("sources").unwrap();
1727        let node = find_node_by_id(sources, NodeId(8887)).unwrap();
1728        let sig = build_function_signature(node).unwrap();
1729        assert!(sig.starts_with("struct PoolKey"));
1730        assert!(sig.contains('{'));
1731    }
1732
1733    #[test]
1734    fn test_build_signature_error() {
1735        let ast = load_test_ast();
1736        let sources = ast.get("sources").unwrap();
1737        // Find an ErrorDefinition
1738        let node = find_node_by_id(sources, NodeId(508)).unwrap();
1739        assert_eq!(
1740            node.get("nodeType").and_then(|v| v.as_str()),
1741            Some("ErrorDefinition")
1742        );
1743        let sig = build_function_signature(node).unwrap();
1744        assert!(sig.starts_with("error "));
1745    }
1746
1747    #[test]
1748    fn test_build_signature_event() {
1749        let ast = load_test_ast();
1750        let sources = ast.get("sources").unwrap();
1751        // Find an EventDefinition
1752        let node = find_node_by_id(sources, NodeId(8)).unwrap();
1753        assert_eq!(
1754            node.get("nodeType").and_then(|v| v.as_str()),
1755            Some("EventDefinition")
1756        );
1757        let sig = build_function_signature(node).unwrap();
1758        assert!(sig.starts_with("event "));
1759    }
1760
1761    #[test]
1762    fn test_build_signature_variable() {
1763        let ast = load_test_ast();
1764        let sources = ast.get("sources").unwrap();
1765        // Find a VariableDeclaration with documentation — check a state var
1766        // PoolManager has state variables, find one
1767        let pm = find_node_by_id(sources, NodeId(1767)).unwrap();
1768        if let Some(nodes) = pm.get("nodes").and_then(|v| v.as_array()) {
1769            for node in nodes {
1770                if node.get("nodeType").and_then(|v| v.as_str()) == Some("VariableDeclaration") {
1771                    let sig = build_function_signature(node);
1772                    assert!(sig.is_some());
1773                    break;
1774                }
1775            }
1776        }
1777    }
1778
1779    #[test]
1780    fn test_pool_manager_has_documentation() {
1781        let ast = load_test_ast();
1782        let sources = ast.get("sources").unwrap();
1783        // Owned contract (id=59) has NatSpec
1784        let node = find_node_by_id(sources, NodeId(59)).unwrap();
1785        let doc = extract_documentation(node).unwrap();
1786        assert!(doc.contains("@notice"));
1787    }
1788
1789    #[test]
1790    fn test_format_parameters_empty() {
1791        let result = format_parameters(None);
1792        assert_eq!(result, "");
1793    }
1794
1795    #[test]
1796    fn test_format_parameters_with_data() {
1797        let params: Value = serde_json::json!({
1798            "parameters": [
1799                {
1800                    "name": "key",
1801                    "typeDescriptions": { "typeString": "struct PoolKey" },
1802                    "storageLocation": "memory"
1803                },
1804                {
1805                    "name": "sqrtPriceX96",
1806                    "typeDescriptions": { "typeString": "uint160" },
1807                    "storageLocation": "default"
1808                }
1809            ]
1810        });
1811        let result = format_parameters(Some(&params));
1812        assert!(result.contains("struct PoolKey memory key"));
1813        assert!(result.contains("uint160 sqrtPriceX96"));
1814    }
1815
1816    // --- Selector tests ---
1817
1818    #[test]
1819    fn test_extract_selector_function() {
1820        let ast = load_test_ast();
1821        let sources = ast.get("sources").unwrap();
1822        // PoolManager.swap (id=1167) has functionSelector "f3cd914c"
1823        let node = find_node_by_id(sources, NodeId(1167)).unwrap();
1824        let selector = extract_selector(node).unwrap();
1825        assert_eq!(selector, Selector::Func(FuncSelector::new("f3cd914c")));
1826        assert_eq!(selector.as_hex(), "f3cd914c");
1827    }
1828
1829    #[test]
1830    fn test_extract_selector_error() {
1831        let ast = load_test_ast();
1832        let sources = ast.get("sources").unwrap();
1833        // DelegateCallNotAllowed (id=508) has errorSelector
1834        let node = find_node_by_id(sources, NodeId(508)).unwrap();
1835        let selector = extract_selector(node).unwrap();
1836        assert_eq!(selector, Selector::Func(FuncSelector::new("0d89438e")));
1837        assert_eq!(selector.as_hex(), "0d89438e");
1838    }
1839
1840    #[test]
1841    fn test_extract_selector_event() {
1842        let ast = load_test_ast();
1843        let sources = ast.get("sources").unwrap();
1844        // OwnershipTransferred (id=8) has eventSelector
1845        let node = find_node_by_id(sources, NodeId(8)).unwrap();
1846        let selector = extract_selector(node).unwrap();
1847        assert!(matches!(selector, Selector::Event(_)));
1848        assert_eq!(selector.as_hex().len(), 64); // 32-byte keccak hash
1849    }
1850
1851    #[test]
1852    fn test_extract_selector_public_variable() {
1853        let ast = load_test_ast();
1854        let sources = ast.get("sources").unwrap();
1855        // owner (id=10) is public, has functionSelector
1856        let node = find_node_by_id(sources, NodeId(10)).unwrap();
1857        let selector = extract_selector(node).unwrap();
1858        assert_eq!(selector, Selector::Func(FuncSelector::new("8da5cb5b")));
1859    }
1860
1861    #[test]
1862    fn test_extract_selector_internal_function_none() {
1863        let ast = load_test_ast();
1864        let sources = ast.get("sources").unwrap();
1865        // Pool.swap (id=5960) is internal, no selector
1866        let node = find_node_by_id(sources, NodeId(5960)).unwrap();
1867        assert!(extract_selector(node).is_none());
1868    }
1869
1870    // --- @inheritdoc resolution tests ---
1871
1872    #[test]
1873    fn test_resolve_inheritdoc_swap() {
1874        let ast = load_test_ast();
1875        let sources = ast.get("sources").unwrap();
1876        // PoolManager.swap (id=1167) has "@inheritdoc IPoolManager"
1877        let decl = find_node_by_id(sources, NodeId(1167)).unwrap();
1878        let doc_text = extract_documentation(decl).unwrap();
1879        assert!(doc_text.contains("@inheritdoc"));
1880
1881        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
1882        assert!(resolved.contains("@notice"));
1883        assert!(resolved.contains("Swap against the given pool"));
1884    }
1885
1886    #[test]
1887    fn test_resolve_inheritdoc_initialize() {
1888        let ast = load_test_ast();
1889        let sources = ast.get("sources").unwrap();
1890        // PoolManager.initialize (id=881) has "@inheritdoc IPoolManager"
1891        let decl = find_node_by_id(sources, NodeId(881)).unwrap();
1892        let doc_text = extract_documentation(decl).unwrap();
1893
1894        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
1895        assert!(resolved.contains("Initialize the state"));
1896        assert!(resolved.contains("@param key"));
1897    }
1898
1899    #[test]
1900    fn test_resolve_inheritdoc_extsload_overload() {
1901        let ast = load_test_ast();
1902        let sources = ast.get("sources").unwrap();
1903
1904        // extsload(bytes32) — id=442, selector "1e2eaeaf"
1905        let decl = find_node_by_id(sources, NodeId(442)).unwrap();
1906        let doc_text = extract_documentation(decl).unwrap();
1907        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
1908        assert!(resolved.contains("granular pool state"));
1909        // Should match the single-slot overload doc
1910        assert!(resolved.contains("@param slot"));
1911
1912        // extsload(bytes32, uint256) — id=455, selector "35fd631a"
1913        let decl2 = find_node_by_id(sources, NodeId(455)).unwrap();
1914        let doc_text2 = extract_documentation(decl2).unwrap();
1915        let resolved2 = resolve_inheritdoc(sources, decl2, &doc_text2).unwrap();
1916        assert!(resolved2.contains("@param startSlot"));
1917
1918        // extsload(bytes32[]) — id=467, selector "dbd035ff"
1919        let decl3 = find_node_by_id(sources, NodeId(467)).unwrap();
1920        let doc_text3 = extract_documentation(decl3).unwrap();
1921        let resolved3 = resolve_inheritdoc(sources, decl3, &doc_text3).unwrap();
1922        assert!(resolved3.contains("sparse pool state"));
1923    }
1924
1925    #[test]
1926    fn test_resolve_inheritdoc_formats_in_hover() {
1927        let ast = load_test_ast();
1928        let sources = ast.get("sources").unwrap();
1929        // PoolManager.swap with @inheritdoc — verify format_natspec resolves it
1930        let decl = find_node_by_id(sources, NodeId(1167)).unwrap();
1931        let doc_text = extract_documentation(decl).unwrap();
1932        let inherited = resolve_inheritdoc(sources, decl, &doc_text);
1933        let formatted = format_natspec(&doc_text, inherited.as_deref());
1934        // Should have the resolved content, not "@inheritdoc"
1935        assert!(!formatted.contains("@inheritdoc"));
1936        assert!(formatted.contains("Swap against the given pool"));
1937        assert!(formatted.contains("**Parameters:**"));
1938    }
1939
1940    // --- Parameter/return doc tests ---
1941
1942    #[test]
1943    fn test_param_doc_error_parameter() {
1944        let ast = load_test_ast();
1945        let sources = ast.get("sources").unwrap();
1946        let doc_index = build_doc_index(&ast);
1947
1948        // PriceLimitAlreadyExceeded.sqrtPriceCurrentX96 (id=4760)
1949        let param_node = find_node_by_id(sources, NodeId(4760)).unwrap();
1950        assert_eq!(
1951            param_node.get("name").and_then(|v| v.as_str()),
1952            Some("sqrtPriceCurrentX96")
1953        );
1954
1955        let doc = lookup_param_doc(&doc_index, param_node, sources).unwrap();
1956        assert!(
1957            doc.contains("invalid"),
1958            "should describe the invalid price: {doc}"
1959        );
1960    }
1961
1962    #[test]
1963    fn test_param_doc_error_second_parameter() {
1964        let ast = load_test_ast();
1965        let sources = ast.get("sources").unwrap();
1966        let doc_index = build_doc_index(&ast);
1967
1968        // PriceLimitAlreadyExceeded.sqrtPriceLimitX96 (id=4762)
1969        let param_node = find_node_by_id(sources, NodeId(4762)).unwrap();
1970        let doc = lookup_param_doc(&doc_index, param_node, sources).unwrap();
1971        assert!(
1972            doc.contains("surpassed price limit"),
1973            "should describe the surpassed limit: {doc}"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_param_doc_function_return_value() {
1979        let ast = load_test_ast();
1980        let sources = ast.get("sources").unwrap();
1981        let doc_index = build_doc_index(&ast);
1982
1983        // Pool.modifyLiquidity return param "delta" (id=4994)
1984        let param_node = find_node_by_id(sources, NodeId(4994)).unwrap();
1985        assert_eq!(
1986            param_node.get("name").and_then(|v| v.as_str()),
1987            Some("delta")
1988        );
1989
1990        let doc = lookup_param_doc(&doc_index, param_node, sources).unwrap();
1991        assert!(
1992            doc.contains("deltas of the token balances"),
1993            "should have return doc: {doc}"
1994        );
1995    }
1996
1997    #[test]
1998    fn test_param_doc_function_input_parameter() {
1999        let ast = load_test_ast();
2000        let sources = ast.get("sources").unwrap();
2001        let doc_index = build_doc_index(&ast);
2002
2003        // Pool.modifyLiquidity input param "params" (id 4992 or similar)
2004        // Find it via the function's parameters
2005        let fn_node = find_node_by_id(sources, NodeId(5310)).unwrap();
2006        let params_arr = fn_node
2007            .get("parameters")
2008            .and_then(|p| p.get("parameters"))
2009            .and_then(|p| p.as_array())
2010            .unwrap();
2011        let params_param = params_arr
2012            .iter()
2013            .find(|p| p.get("name").and_then(|v| v.as_str()) == Some("params"))
2014            .unwrap();
2015
2016        let doc = lookup_param_doc(&doc_index, params_param, sources).unwrap();
2017        assert!(
2018            doc.contains("position details"),
2019            "should have param doc: {doc}"
2020        );
2021    }
2022
2023    #[test]
2024    fn test_param_doc_inherited_function_via_docindex() {
2025        let ast = load_test_ast();
2026        let sources = ast.get("sources").unwrap();
2027        let doc_index = build_doc_index(&ast);
2028
2029        // PoolManager.swap `key` param (id=1029) — parent has @inheritdoc IPoolManager
2030        // The DocIndex should have the resolved devdoc from IPoolManager
2031        let param_node = find_node_by_id(sources, NodeId(1029)).unwrap();
2032        assert_eq!(param_node.get("name").and_then(|v| v.as_str()), Some("key"));
2033
2034        let doc = lookup_param_doc(&doc_index, param_node, sources).unwrap();
2035        assert!(
2036            doc.contains("pool to swap"),
2037            "should have inherited param doc: {doc}"
2038        );
2039    }
2040
2041    #[test]
2042    fn test_param_doc_non_parameter_returns_none() {
2043        let ast = load_test_ast();
2044        let sources = ast.get("sources").unwrap();
2045        let doc_index = build_doc_index(&ast);
2046
2047        // PoolManager contract (id=1767) is not a parameter
2048        let node = find_node_by_id(sources, NodeId(1767)).unwrap();
2049        assert!(lookup_param_doc(&doc_index, node, sources).is_none());
2050    }
2051
2052    // ── DocIndex integration tests (poolmanager.json) ──
2053
2054    fn load_solc_fixture() -> Value {
2055        let data = std::fs::read_to_string("poolmanager.json").expect("test fixture");
2056        let raw: Value = serde_json::from_str(&data).expect("valid json");
2057        crate::solc::normalize_solc_output(raw, None)
2058    }
2059
2060    #[test]
2061    fn test_doc_index_is_not_empty() {
2062        let ast = load_solc_fixture();
2063        let index = build_doc_index(&ast);
2064        assert!(!index.is_empty(), "DocIndex should contain entries");
2065    }
2066
2067    #[test]
2068    fn test_doc_index_has_contract_entries() {
2069        let ast = load_solc_fixture();
2070        let index = build_doc_index(&ast);
2071
2072        // PoolManager has both title and notice
2073        let pm_keys: Vec<_> = index
2074            .keys()
2075            .filter(|k| matches!(k, DocKey::Contract(s) if s.contains("PoolManager")))
2076            .collect();
2077        assert!(
2078            !pm_keys.is_empty(),
2079            "should have a Contract entry for PoolManager"
2080        );
2081
2082        let pm_key = DocKey::Contract(
2083            "/Users/meek/developer/mmsaki/solidity-language-server/v4-core/src/PoolManager.sol:PoolManager".to_string(),
2084        );
2085        let entry = index.get(&pm_key).expect("PoolManager contract entry");
2086        assert_eq!(entry.title.as_deref(), Some("PoolManager"));
2087        assert_eq!(
2088            entry.notice.as_deref(),
2089            Some("Holds the state for all pools")
2090        );
2091    }
2092
2093    #[test]
2094    fn test_doc_index_has_function_by_selector() {
2095        let ast = load_solc_fixture();
2096        let index = build_doc_index(&ast);
2097
2098        // initialize selector = 6276cbbe
2099        let init_key = DocKey::Func(FuncSelector::new("6276cbbe"));
2100        let entry = index
2101            .get(&init_key)
2102            .expect("should have initialize by selector");
2103        assert_eq!(
2104            entry.notice.as_deref(),
2105            Some("Initialize the state for a given pool ID")
2106        );
2107        assert!(
2108            entry
2109                .details
2110                .as_deref()
2111                .unwrap_or("")
2112                .contains("MAX_SWAP_FEE"),
2113            "devdoc details should mention MAX_SWAP_FEE"
2114        );
2115        // params: key, sqrtPriceX96
2116        let param_names: Vec<&str> = entry.params.iter().map(|(n, _)| n.as_str()).collect();
2117        assert!(param_names.contains(&"key"), "should have param 'key'");
2118        assert!(
2119            param_names.contains(&"sqrtPriceX96"),
2120            "should have param 'sqrtPriceX96'"
2121        );
2122        // returns: tick
2123        let return_names: Vec<&str> = entry.returns.iter().map(|(n, _)| n.as_str()).collect();
2124        assert!(return_names.contains(&"tick"), "should have return 'tick'");
2125    }
2126
2127    #[test]
2128    fn test_doc_index_swap_by_selector() {
2129        let ast = load_solc_fixture();
2130        let index = build_doc_index(&ast);
2131
2132        // swap selector = f3cd914c
2133        let swap_key = DocKey::Func(FuncSelector::new("f3cd914c"));
2134        let entry = index.get(&swap_key).expect("should have swap by selector");
2135        assert!(
2136            entry
2137                .notice
2138                .as_deref()
2139                .unwrap_or("")
2140                .contains("Swap against the given pool"),
2141            "swap notice should describe swapping"
2142        );
2143        // devdoc params: key, params, hookData
2144        assert!(
2145            !entry.params.is_empty(),
2146            "swap should have param documentation"
2147        );
2148    }
2149
2150    #[test]
2151    fn test_doc_index_settle_by_selector() {
2152        let ast = load_solc_fixture();
2153        let index = build_doc_index(&ast);
2154
2155        // settle() selector = 11da60b4
2156        let key = DocKey::Func(FuncSelector::new("11da60b4"));
2157        let entry = index.get(&key).expect("should have settle by selector");
2158        assert!(
2159            entry.notice.is_some(),
2160            "settle should have a notice from userdoc"
2161        );
2162    }
2163
2164    #[test]
2165    fn test_doc_index_has_error_entries() {
2166        let ast = load_solc_fixture();
2167        let index = build_doc_index(&ast);
2168
2169        // AlreadyUnlocked() → keccak256("AlreadyUnlocked()")[0..4]
2170        let selector = compute_selector("AlreadyUnlocked()");
2171        let key = DocKey::Func(FuncSelector::new(&selector));
2172        let entry = index.get(&key).expect("should have AlreadyUnlocked error");
2173        assert!(
2174            entry
2175                .notice
2176                .as_deref()
2177                .unwrap_or("")
2178                .contains("already unlocked"),
2179            "AlreadyUnlocked notice: {:?}",
2180            entry.notice
2181        );
2182    }
2183
2184    #[test]
2185    fn test_doc_index_error_with_params() {
2186        let ast = load_solc_fixture();
2187        let index = build_doc_index(&ast);
2188
2189        // CurrenciesOutOfOrderOrEqual(address,address) has a notice
2190        let selector = compute_selector("CurrenciesOutOfOrderOrEqual(address,address)");
2191        let key = DocKey::Func(FuncSelector::new(&selector));
2192        let entry = index
2193            .get(&key)
2194            .expect("should have CurrenciesOutOfOrderOrEqual error");
2195        assert!(entry.notice.is_some(), "error should have notice");
2196    }
2197
2198    #[test]
2199    fn test_doc_index_has_event_entries() {
2200        let ast = load_solc_fixture();
2201        let index = build_doc_index(&ast);
2202
2203        // Count event entries
2204        let event_count = index
2205            .keys()
2206            .filter(|k| matches!(k, DocKey::Event(_)))
2207            .count();
2208        assert!(event_count > 0, "should have event entries in the DocIndex");
2209    }
2210
2211    #[test]
2212    fn test_doc_index_swap_event() {
2213        let ast = load_solc_fixture();
2214        let index = build_doc_index(&ast);
2215
2216        // Swap event topic = keccak256("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)")
2217        let topic =
2218            compute_event_topic("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)");
2219        let key = DocKey::Event(EventSelector::new(&topic));
2220        let entry = index.get(&key).expect("should have Swap event");
2221
2222        // userdoc notice
2223        assert!(
2224            entry
2225                .notice
2226                .as_deref()
2227                .unwrap_or("")
2228                .contains("swaps between currency0 and currency1"),
2229            "Swap event notice: {:?}",
2230            entry.notice
2231        );
2232
2233        // devdoc params (amount0, amount1, id, sender, sqrtPriceX96, etc.)
2234        let param_names: Vec<&str> = entry.params.iter().map(|(n, _)| n.as_str()).collect();
2235        assert!(
2236            param_names.contains(&"amount0"),
2237            "should have param 'amount0'"
2238        );
2239        assert!(
2240            param_names.contains(&"sender"),
2241            "should have param 'sender'"
2242        );
2243        assert!(param_names.contains(&"id"), "should have param 'id'");
2244    }
2245
2246    #[test]
2247    fn test_doc_index_initialize_event() {
2248        let ast = load_solc_fixture();
2249        let index = build_doc_index(&ast);
2250
2251        let topic = compute_event_topic(
2252            "Initialize(bytes32,address,address,uint24,int24,address,uint160,int24)",
2253        );
2254        let key = DocKey::Event(EventSelector::new(&topic));
2255        let entry = index.get(&key).expect("should have Initialize event");
2256        assert!(
2257            !entry.params.is_empty(),
2258            "Initialize event should have param docs"
2259        );
2260    }
2261
2262    #[test]
2263    fn test_doc_index_no_state_variables_for_pool_manager() {
2264        let ast = load_solc_fixture();
2265        let index = build_doc_index(&ast);
2266
2267        // PoolManager has no devdoc.stateVariables, so no StateVar keys for it
2268        let sv_count = index
2269            .keys()
2270            .filter(|k| matches!(k, DocKey::StateVar(s) if s.contains("PoolManager")))
2271            .count();
2272        assert_eq!(
2273            sv_count, 0,
2274            "PoolManager should have no state variable doc entries"
2275        );
2276    }
2277
2278    #[test]
2279    fn test_doc_index_multiple_contracts() {
2280        let ast = load_solc_fixture();
2281        let index = build_doc_index(&ast);
2282
2283        // Should have contract entries for multiple contracts (ERC6909, Extsload, IPoolManager, etc.)
2284        let contract_count = index
2285            .keys()
2286            .filter(|k| matches!(k, DocKey::Contract(_)))
2287            .count();
2288        assert!(
2289            contract_count >= 5,
2290            "should have at least 5 contract-level entries, got {contract_count}"
2291        );
2292    }
2293
2294    #[test]
2295    fn test_doc_index_func_key_count() {
2296        let ast = load_solc_fixture();
2297        let index = build_doc_index(&ast);
2298
2299        let func_count = index
2300            .keys()
2301            .filter(|k| matches!(k, DocKey::Func(_)))
2302            .count();
2303        // We have methods + errors keyed by selector across all 43 contracts
2304        assert!(
2305            func_count >= 30,
2306            "should have at least 30 Func entries (methods + errors), got {func_count}"
2307        );
2308    }
2309
2310    #[test]
2311    fn test_doc_index_format_initialize_entry() {
2312        let ast = load_solc_fixture();
2313        let index = build_doc_index(&ast);
2314
2315        let key = DocKey::Func(FuncSelector::new("6276cbbe"));
2316        let entry = index.get(&key).expect("initialize entry");
2317        let formatted = format_doc_entry(entry);
2318
2319        assert!(
2320            formatted.contains("Initialize the state for a given pool ID"),
2321            "formatted should include notice"
2322        );
2323        assert!(
2324            formatted.contains("**@dev**"),
2325            "formatted should include dev section"
2326        );
2327        assert!(
2328            formatted.contains("**Parameters:**"),
2329            "formatted should include parameters"
2330        );
2331        assert!(
2332            formatted.contains("`key`"),
2333            "formatted should include key param"
2334        );
2335        assert!(
2336            formatted.contains("**Returns:**"),
2337            "formatted should include returns"
2338        );
2339        assert!(
2340            formatted.contains("`tick`"),
2341            "formatted should include tick return"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_doc_index_format_contract_entry() {
2347        let ast = load_solc_fixture();
2348        let index = build_doc_index(&ast);
2349
2350        let key = DocKey::Contract(
2351            "/Users/meek/developer/mmsaki/solidity-language-server/v4-core/src/PoolManager.sol:PoolManager".to_string(),
2352        );
2353        let entry = index.get(&key).expect("PoolManager contract entry");
2354        let formatted = format_doc_entry(entry);
2355
2356        assert!(
2357            formatted.contains("**PoolManager**"),
2358            "should include bold title"
2359        );
2360        assert!(
2361            formatted.contains("Holds the state for all pools"),
2362            "should include notice"
2363        );
2364    }
2365
2366    #[test]
2367    fn test_doc_index_inherited_docs_resolved() {
2368        let ast = load_solc_fixture();
2369        let index = build_doc_index(&ast);
2370
2371        // Both PoolManager and IPoolManager define methods with the same selector.
2372        // The last one written wins (PoolManager overwrites IPoolManager for same selector).
2373        // Either way, swap(f3cd914c) should have full docs, not just "@inheritdoc".
2374        let key = DocKey::Func(FuncSelector::new("f3cd914c"));
2375        let entry = index.get(&key).expect("swap entry");
2376        // The notice should be the resolved text, not "@inheritdoc IPoolManager"
2377        let notice = entry.notice.as_deref().unwrap_or("");
2378        assert!(
2379            !notice.contains("@inheritdoc"),
2380            "userdoc/devdoc should have resolved inherited docs, not raw @inheritdoc"
2381        );
2382    }
2383
2384    #[test]
2385    fn test_compute_selector_known_values() {
2386        // keccak256("AlreadyUnlocked()") first 4 bytes
2387        let sel = compute_selector("AlreadyUnlocked()");
2388        assert_eq!(sel.len(), 8, "selector should be 8 hex chars");
2389
2390        // Verify against a known selector from evm.methodIdentifiers
2391        let init_sel =
2392            compute_selector("initialize((address,address,uint24,int24,address),uint160)");
2393        assert_eq!(
2394            init_sel, "6276cbbe",
2395            "computed initialize selector should match evm.methodIdentifiers"
2396        );
2397    }
2398
2399    #[test]
2400    fn test_compute_event_topic_length() {
2401        let topic =
2402            compute_event_topic("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)");
2403        assert_eq!(
2404            topic.len(),
2405            64,
2406            "event topic should be 64 hex chars (32 bytes)"
2407        );
2408    }
2409
2410    #[test]
2411    fn test_doc_index_error_count_poolmanager() {
2412        let ast = load_solc_fixture();
2413        let index = build_doc_index(&ast);
2414
2415        // PoolManager userdoc has 14 errors. Check that they're all indexed.
2416        // Compute selectors for all 14 error signatures and verify they exist.
2417        let error_sigs = [
2418            "AlreadyUnlocked()",
2419            "CurrenciesOutOfOrderOrEqual(address,address)",
2420            "CurrencyNotSettled()",
2421            "InvalidCaller()",
2422            "ManagerLocked()",
2423            "MustClearExactPositiveDelta()",
2424            "NonzeroNativeValue()",
2425            "PoolNotInitialized()",
2426            "ProtocolFeeCurrencySynced()",
2427            "ProtocolFeeTooLarge(uint24)",
2428            "SwapAmountCannotBeZero()",
2429            "TickSpacingTooLarge(int24)",
2430            "TickSpacingTooSmall(int24)",
2431            "UnauthorizedDynamicLPFeeUpdate()",
2432        ];
2433        let mut found = 0;
2434        for sig in &error_sigs {
2435            let selector = compute_selector(sig);
2436            let key = DocKey::Func(FuncSelector::new(&selector));
2437            if index.contains_key(&key) {
2438                found += 1;
2439            }
2440        }
2441        assert_eq!(
2442            found,
2443            error_sigs.len(),
2444            "all 14 PoolManager errors should be in the DocIndex"
2445        );
2446    }
2447
2448    #[test]
2449    fn test_doc_index_extsload_overloads_have_different_selectors() {
2450        let ast = load_solc_fixture();
2451        let index = build_doc_index(&ast);
2452
2453        // Three extsload overloads should each have their own selector entry
2454        // extsload(bytes32) = 1e2eaeaf
2455        // extsload(bytes32,uint256) = 35fd631a
2456        // extsload(bytes32[]) = dbd035ff
2457        let sel1 = DocKey::Func(FuncSelector::new("1e2eaeaf"));
2458        let sel2 = DocKey::Func(FuncSelector::new("35fd631a"));
2459        let sel3 = DocKey::Func(FuncSelector::new("dbd035ff"));
2460
2461        assert!(index.contains_key(&sel1), "extsload(bytes32) should exist");
2462        assert!(
2463            index.contains_key(&sel2),
2464            "extsload(bytes32,uint256) should exist"
2465        );
2466        assert!(
2467            index.contains_key(&sel3),
2468            "extsload(bytes32[]) should exist"
2469        );
2470    }
2471
2472    #[test]
2473    fn test_build_parameter_strings_basic() {
2474        let node: Value = serde_json::json!({
2475            "parameters": {
2476                "parameters": [
2477                    {
2478                        "name": "amount",
2479                        "typeDescriptions": { "typeString": "uint256" },
2480                        "storageLocation": "default"
2481                    },
2482                    {
2483                        "name": "tax",
2484                        "typeDescriptions": { "typeString": "uint16" },
2485                        "storageLocation": "default"
2486                    }
2487                ]
2488            }
2489        });
2490        let params = build_parameter_strings(Some(&node.get("parameters").unwrap()));
2491        assert_eq!(params, vec!["uint256 amount", "uint16 tax"]);
2492    }
2493
2494    #[test]
2495    fn test_build_parameter_strings_with_storage() {
2496        let node: Value = serde_json::json!({
2497            "parameters": {
2498                "parameters": [
2499                    {
2500                        "name": "key",
2501                        "typeDescriptions": { "typeString": "struct PoolKey" },
2502                        "storageLocation": "calldata"
2503                    }
2504                ]
2505            }
2506        });
2507        let params = build_parameter_strings(Some(&node.get("parameters").unwrap()));
2508        assert_eq!(params, vec!["struct PoolKey calldata key"]);
2509    }
2510
2511    #[test]
2512    fn test_build_parameter_strings_empty() {
2513        let node: Value = serde_json::json!({
2514            "parameters": { "parameters": [] }
2515        });
2516        let params = build_parameter_strings(Some(&node.get("parameters").unwrap()));
2517        assert!(params.is_empty());
2518    }
2519
2520    #[test]
2521    fn test_build_parameter_strings_unnamed() {
2522        let node: Value = serde_json::json!({
2523            "parameters": {
2524                "parameters": [
2525                    {
2526                        "name": "",
2527                        "typeDescriptions": { "typeString": "uint256" },
2528                        "storageLocation": "default"
2529                    }
2530                ]
2531            }
2532        });
2533        let params = build_parameter_strings(Some(&node.get("parameters").unwrap()));
2534        assert_eq!(params, vec!["uint256"]);
2535    }
2536
2537    #[test]
2538    fn test_signature_help_parameter_offsets() {
2539        // Simulate a signature like: "function addTax(uint256 amount, uint16 tax, uint16 base)"
2540        let label = "function addTax(uint256 amount, uint16 tax, uint16 base)";
2541        let param_strings = vec![
2542            "uint256 amount".to_string(),
2543            "uint16 tax".to_string(),
2544            "uint16 base".to_string(),
2545        ];
2546
2547        let params_start = label.find('(').unwrap() + 1;
2548        let mut offsets = Vec::new();
2549        let mut offset = params_start;
2550        for param_str in &param_strings {
2551            let start = offset;
2552            let end = start + param_str.len();
2553            offsets.push((start, end));
2554            offset = end + 2; // ", "
2555        }
2556
2557        // Verify the offsets correctly slice the label
2558        assert_eq!(&label[offsets[0].0..offsets[0].1], "uint256 amount");
2559        assert_eq!(&label[offsets[1].0..offsets[1].1], "uint16 tax");
2560        assert_eq!(&label[offsets[2].0..offsets[2].1], "uint16 base");
2561    }
2562
2563    #[test]
2564    fn test_find_mapping_decl_by_name_pools() {
2565        let ast = load_test_ast();
2566        let sources = ast.get("sources").unwrap();
2567        let decl = find_mapping_decl_by_name(sources, "_pools").unwrap();
2568        assert_eq!(decl.get("name").and_then(|v| v.as_str()), Some("_pools"));
2569        assert_eq!(
2570            decl.get("typeName")
2571                .and_then(|t| t.get("nodeType"))
2572                .and_then(|v| v.as_str()),
2573            Some("Mapping")
2574        );
2575    }
2576
2577    #[test]
2578    fn test_find_mapping_decl_by_name_not_found() {
2579        let ast = load_test_ast();
2580        let sources = ast.get("sources").unwrap();
2581        assert!(find_mapping_decl_by_name(sources, "nonexistent").is_none());
2582    }
2583
2584    #[test]
2585    fn test_mapping_signature_help_pools() {
2586        let ast = load_test_ast();
2587        let sources = ast.get("sources").unwrap();
2588        let help = mapping_signature_help(sources, "_pools").unwrap();
2589
2590        assert_eq!(help.signatures.len(), 1);
2591        let sig = &help.signatures[0];
2592        // Named key: _pools[PoolId id]
2593        assert_eq!(sig.label, "_pools[PoolId id]");
2594        assert_eq!(sig.active_parameter, Some(0));
2595
2596        // Parameter offsets should cover "PoolId id" inside brackets
2597        let params = sig.parameters.as_ref().unwrap();
2598        assert_eq!(params.len(), 1);
2599        if let ParameterLabel::LabelOffsets([start, end]) = params[0].label {
2600            assert_eq!(&sig.label[start as usize..end as usize], "PoolId id");
2601        } else {
2602            panic!("expected LabelOffsets");
2603        }
2604
2605        // Value type shown in documentation
2606        let doc = sig.documentation.as_ref().unwrap();
2607        if let Documentation::MarkupContent(mc) = doc {
2608            assert!(mc.value.contains("struct Pool.State"));
2609        }
2610    }
2611
2612    #[test]
2613    fn test_mapping_signature_help_protocol_fees() {
2614        let ast = load_test_ast();
2615        let sources = ast.get("sources").unwrap();
2616        let help = mapping_signature_help(sources, "protocolFeesAccrued").unwrap();
2617        let sig = &help.signatures[0];
2618        assert_eq!(sig.label, "protocolFeesAccrued[Currency currency]");
2619    }
2620
2621    #[test]
2622    fn test_mapping_signature_help_non_mapping() {
2623        let ast = load_test_ast();
2624        let sources = ast.get("sources").unwrap();
2625        // "owner" is a regular address variable, not a mapping
2626        assert!(mapping_signature_help(sources, "owner").is_none());
2627    }
2628}