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