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