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/// Produce hover information for the symbol at the given position.
1226pub fn hover_info(
1227    cached_build: &crate::goto::CachedBuild,
1228    file_uri: &Url,
1229    position: Position,
1230    source_bytes: &[u8],
1231) -> Option<Hover> {
1232    let nodes = &cached_build.nodes;
1233    let path_to_abs = &cached_build.path_to_abs;
1234    let external_refs = &cached_build.external_refs;
1235    let id_to_path = &cached_build.id_to_path_map;
1236    let doc_index = &cached_build.doc_index;
1237    let hint_index = &cached_build.hint_index;
1238
1239    // Resolve the file path
1240    let file_path = file_uri.to_file_path().ok()?;
1241    let file_path_str = file_path.to_str()?;
1242
1243    // Find the absolute path for this file
1244    let abs_path = path_to_abs
1245        .iter()
1246        .find(|(k, _)| file_path_str.ends_with(k.as_str()))
1247        .map(|(_, v)| v.clone())?;
1248
1249    let byte_pos = pos_to_bytes(source_bytes, position);
1250
1251    // Resolve: first try Yul external refs, then normal node lookup
1252    let node_id = byte_to_decl_via_external_refs(external_refs, id_to_path, &abs_path, byte_pos)
1253        .or_else(|| byte_to_id(nodes, &abs_path, byte_pos))?;
1254
1255    // Get the NodeInfo for this node
1256    let node_info = nodes
1257        .values()
1258        .find_map(|file_nodes| file_nodes.get(&node_id))?;
1259
1260    // Follow referenced_declaration to the declaration node
1261    let decl_id = node_info.referenced_declaration.unwrap_or(node_id);
1262
1263    // Typed DeclNode — O(1) from decl_index
1264    let typed_decl = cached_build.decl_index.get(&decl_id);
1265
1266    // Build hover content
1267    let mut parts: Vec<String> = Vec::new();
1268
1269    // Signature in a code block
1270    if let Some(sig) = typed_decl.and_then(|d| d.build_signature()) {
1271        parts.push(format!("```solidity\n{sig}\n```"));
1272    } else if let Some(d) = typed_decl {
1273        // Fallback: show type description for any node
1274        if let Some(ts) = d.type_string() {
1275            parts.push(format!("```solidity\n{ts} {}\n```", d.name()));
1276        }
1277    }
1278
1279    // Selector (function, error, or event)
1280    if let Some(selector) = typed_decl.and_then(|d| d.extract_typed_selector()) {
1281        parts.push(format!("Selector: `{}`", selector.to_prefixed()));
1282    }
1283
1284    // Node ID for debugging: show the cursor-hit node and the resolved
1285    // declaration (if different, e.g. when hovering a reference).
1286    if node_id == decl_id {
1287        parts.push(format!("NodeId: `{}`", decl_id.0));
1288    } else {
1289        parts.push(format!(
1290            "NodeId: `{}` referencedDeclaration: `{}`",
1291            node_id.0, decl_id.0
1292        ));
1293    }
1294
1295    // Storage layout: show slot (and offset if packed) for state variables.
1296    if let Some(slot_info) = cached_build.storage_layout.get(&decl_id) {
1297        if slot_info.offset > 0 {
1298            parts.push(format!(
1299                "Storage: slot `{}`, offset `{}`",
1300                slot_info.slot, slot_info.offset
1301            ));
1302        } else {
1303            parts.push(format!("Storage: slot `{}`", slot_info.slot));
1304        }
1305    }
1306
1307    let di = &cached_build.decl_index;
1308    let id_to_path = &cached_build.node_id_to_source_path;
1309
1310    // Documentation
1311    if let Some(doc_entry) =
1312        typed_decl.and_then(|d| lookup_doc_entry_typed(doc_index, d, di, id_to_path))
1313    {
1314        let formatted = format_doc_entry(&doc_entry);
1315        if !formatted.is_empty() {
1316            parts.push(format!("---\n{formatted}"));
1317        }
1318    } else if let Some(doc_text) = typed_decl.and_then(|d| d.extract_doc_text()) {
1319        let inherited_doc = typed_decl.and_then(|d| resolve_inheritdoc_typed(d, &doc_text, di));
1320        let formatted = format_natspec(&doc_text, inherited_doc.as_deref());
1321        if !formatted.is_empty() {
1322            parts.push(format!("---\n{formatted}"));
1323        }
1324    } else if let Some(param_doc) =
1325        typed_decl.and_then(|d| lookup_param_doc_typed(doc_index, d, di, id_to_path))
1326    {
1327        // Parameter/return value — show the @param/@return description from parent
1328        if !param_doc.is_empty() {
1329            parts.push(format!("---\n{param_doc}"));
1330        }
1331    }
1332
1333    // Call-site parameter doc: when the hovered node is used as an argument
1334    // in a function call, show the @param doc from the called function's definition.
1335    // Uses tree-sitter on the live buffer to find the enclosing call and argument
1336    // index, then resolves via HintIndex for the param name and declaration id.
1337    if let Some(hint_lookup) = hint_index.get(&abs_path) {
1338        let source_str = String::from_utf8_lossy(source_bytes);
1339        if let Some(tree) = crate::inlay_hints::ts_parse(&source_str)
1340            && let Some(ctx) =
1341                crate::inlay_hints::ts_find_call_at_byte(tree.root_node(), &source_str, byte_pos)
1342            && let Some(resolved) = hint_lookup.resolve_callsite_param(
1343                ctx.call_start_byte,
1344                ctx.name,
1345                ctx.arg_count,
1346                ctx.arg_index,
1347            )
1348        {
1349            // Look up @param doc via typed DeclNode
1350            let typed_fn = di.get(&resolved.decl_id);
1351            let param_doc = typed_fn.and_then(|fn_decl| {
1352                // Try DocIndex first (structured devdoc)
1353                if let Some(doc_entry) = lookup_doc_entry_typed(doc_index, fn_decl, di, id_to_path)
1354                {
1355                    for (pname, pdesc) in &doc_entry.params {
1356                        if pname == &resolved.param_name {
1357                            return Some(pdesc.clone());
1358                        }
1359                    }
1360                }
1361                // Fallback: parse typed NatSpec on the function definition
1362                if let Some(doc_text) = fn_decl.extract_doc_text() {
1363                    let resolved_doc = if doc_text.contains("@inheritdoc") {
1364                        resolve_inheritdoc_typed(fn_decl, &doc_text, di)
1365                    } else {
1366                        None
1367                    };
1368                    let text = resolved_doc.as_deref().unwrap_or(&doc_text);
1369                    for line in text.lines() {
1370                        let trimmed = line.trim().trim_start_matches('*').trim();
1371                        if let Some(rest) = trimmed.strip_prefix("@param ")
1372                            && let Some((name, desc)) = rest.split_once(' ')
1373                            && name == resolved.param_name
1374                        {
1375                            return Some(desc.to_string());
1376                        }
1377                    }
1378                }
1379                None
1380            });
1381            if let Some(desc) = param_doc
1382                && !desc.is_empty()
1383            {
1384                parts.push(format!("**@param `{}`** — {desc}", resolved.param_name));
1385            }
1386        }
1387    }
1388
1389    if parts.is_empty() {
1390        return None;
1391    }
1392
1393    Some(Hover {
1394        contents: HoverContents::Markup(MarkupContent {
1395            kind: MarkupKind::Markdown,
1396            value: parts.join("\n\n"),
1397        }),
1398        range: None,
1399    })
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405
1406    fn load_test_ast() -> Value {
1407        let data = std::fs::read_to_string("poolmanager.json").expect("test fixture");
1408        let raw: Value = serde_json::from_str(&data).expect("valid json");
1409        crate::solc::normalize_solc_output(raw, None)
1410    }
1411
1412    #[test]
1413    fn test_find_node_by_id_pool_manager() {
1414        let ast = load_test_ast();
1415        let sources = ast.get("sources").unwrap();
1416        let node = find_node_by_id(sources, NodeId(1216)).unwrap();
1417        assert_eq!(
1418            node.get("name").and_then(|v| v.as_str()),
1419            Some("PoolManager")
1420        );
1421        assert_eq!(
1422            node.get("nodeType").and_then(|v| v.as_str()),
1423            Some("ContractDefinition")
1424        );
1425    }
1426
1427    #[test]
1428    fn test_find_node_by_id_initialize() {
1429        let ast = load_test_ast();
1430        let sources = ast.get("sources").unwrap();
1431        // IPoolManager.initialize has the full docs
1432        let node = find_node_by_id(sources, NodeId(2003)).unwrap();
1433        assert_eq!(
1434            node.get("name").and_then(|v| v.as_str()),
1435            Some("initialize")
1436        );
1437    }
1438
1439    #[test]
1440    fn test_extract_documentation_object() {
1441        let ast = load_test_ast();
1442        let sources = ast.get("sources").unwrap();
1443        // IPoolManager.initialize (id=2003) has full NatSpec
1444        let node = find_node_by_id(sources, NodeId(2003)).unwrap();
1445        let doc = extract_documentation(node).unwrap();
1446        assert!(doc.contains("@notice"));
1447        assert!(doc.contains("@param key"));
1448    }
1449
1450    #[test]
1451    fn test_extract_documentation_none() {
1452        let ast = load_test_ast();
1453        let sources = ast.get("sources").unwrap();
1454        // PoolKey struct (id=6871) — check if it has docs
1455        let node = find_node_by_id(sources, NodeId(6871)).unwrap();
1456        // PoolKey may or may not have docs, just verify no crash
1457        let _ = extract_documentation(node);
1458    }
1459
1460    #[test]
1461    fn test_format_natspec_notice_and_params() {
1462        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";
1463        let formatted = format_natspec(text, None);
1464        assert!(formatted.contains("Initialize the state"));
1465        assert!(formatted.contains("**Parameters:**"));
1466        assert!(formatted.contains("`key`"));
1467        assert!(formatted.contains("**Returns:**"));
1468        assert!(formatted.contains("`tick`"));
1469    }
1470
1471    #[test]
1472    fn test_format_natspec_inheritdoc() {
1473        let text = "@inheritdoc IPoolManager";
1474        let formatted = format_natspec(text, None);
1475        assert!(formatted.contains("Inherits documentation from `IPoolManager`"));
1476    }
1477
1478    #[test]
1479    fn test_format_natspec_dev() {
1480        let text = "@notice Do something\n @dev This is an implementation detail";
1481        let formatted = format_natspec(text, None);
1482        assert!(formatted.contains("Do something"));
1483        assert!(formatted.contains("**@dev**"));
1484        assert!(formatted.contains("*This is an implementation detail*"));
1485    }
1486
1487    #[test]
1488    fn test_format_natspec_custom_tag() {
1489        let text = "@notice Do something\n @custom:security Non-reentrant";
1490        let formatted = format_natspec(text, None);
1491        assert!(formatted.contains("Do something"));
1492        assert!(formatted.contains("**@custom:security**"));
1493        assert!(formatted.contains("*Non-reentrant*"));
1494    }
1495
1496    #[test]
1497    fn test_build_function_signature_initialize() {
1498        let ast = load_test_ast();
1499        let sources = ast.get("sources").unwrap();
1500        let node = find_node_by_id(sources, NodeId(2003)).unwrap();
1501        let sig = build_function_signature(node).unwrap();
1502        assert!(sig.starts_with("function initialize("));
1503        assert!(sig.contains("returns"));
1504    }
1505
1506    #[test]
1507    fn test_build_signature_contract() {
1508        let ast = load_test_ast();
1509        let sources = ast.get("sources").unwrap();
1510        let node = find_node_by_id(sources, NodeId(1216)).unwrap();
1511        let sig = build_function_signature(node).unwrap();
1512        assert!(sig.contains("contract PoolManager"));
1513        assert!(sig.contains(" is "));
1514    }
1515
1516    #[test]
1517    fn test_build_signature_struct() {
1518        let ast = load_test_ast();
1519        let sources = ast.get("sources").unwrap();
1520        let node = find_node_by_id(sources, NodeId(6871)).unwrap();
1521        let sig = build_function_signature(node).unwrap();
1522        assert!(sig.starts_with("struct PoolKey"));
1523        assert!(sig.contains('{'));
1524    }
1525
1526    #[test]
1527    fn test_build_signature_error() {
1528        let ast = load_test_ast();
1529        let sources = ast.get("sources").unwrap();
1530        // Find an ErrorDefinition
1531        let node = find_node_by_id(sources, NodeId(1372)).unwrap();
1532        assert_eq!(
1533            node.get("nodeType").and_then(|v| v.as_str()),
1534            Some("ErrorDefinition")
1535        );
1536        let sig = build_function_signature(node).unwrap();
1537        assert!(sig.starts_with("error "));
1538    }
1539
1540    #[test]
1541    fn test_build_signature_event() {
1542        let ast = load_test_ast();
1543        let sources = ast.get("sources").unwrap();
1544        // Find an EventDefinition
1545        let node = find_node_by_id(sources, NodeId(7404)).unwrap();
1546        assert_eq!(
1547            node.get("nodeType").and_then(|v| v.as_str()),
1548            Some("EventDefinition")
1549        );
1550        let sig = build_function_signature(node).unwrap();
1551        assert!(sig.starts_with("event "));
1552    }
1553
1554    #[test]
1555    fn test_build_signature_variable() {
1556        let ast = load_test_ast();
1557        let sources = ast.get("sources").unwrap();
1558        // Find a VariableDeclaration with documentation — check a state var
1559        // PoolManager has state variables, find one
1560        let pm = find_node_by_id(sources, NodeId(1216)).unwrap();
1561        if let Some(nodes) = pm.get("nodes").and_then(|v| v.as_array()) {
1562            for node in nodes {
1563                if node.get("nodeType").and_then(|v| v.as_str()) == Some("VariableDeclaration") {
1564                    let sig = build_function_signature(node);
1565                    assert!(sig.is_some());
1566                    break;
1567                }
1568            }
1569        }
1570    }
1571
1572    #[test]
1573    fn test_pool_manager_has_documentation() {
1574        let ast = load_test_ast();
1575        let sources = ast.get("sources").unwrap();
1576        // Owned contract (id=7455) has NatSpec
1577        let node = find_node_by_id(sources, NodeId(7455)).unwrap();
1578        let doc = extract_documentation(node).unwrap();
1579        assert!(doc.contains("@notice"));
1580    }
1581
1582    #[test]
1583    fn test_format_parameters_empty() {
1584        let result = format_parameters(None);
1585        assert_eq!(result, "");
1586    }
1587
1588    #[test]
1589    fn test_format_parameters_with_data() {
1590        let params: Value = serde_json::json!({
1591            "parameters": [
1592                {
1593                    "name": "key",
1594                    "typeDescriptions": { "typeString": "struct PoolKey" },
1595                    "storageLocation": "memory"
1596                },
1597                {
1598                    "name": "sqrtPriceX96",
1599                    "typeDescriptions": { "typeString": "uint160" },
1600                    "storageLocation": "default"
1601                }
1602            ]
1603        });
1604        let result = format_parameters(Some(&params));
1605        assert!(result.contains("struct PoolKey memory key"));
1606        assert!(result.contains("uint160 sqrtPriceX96"));
1607    }
1608
1609    // --- Selector tests ---
1610
1611    #[test]
1612    fn test_extract_selector_function() {
1613        let ast = load_test_ast();
1614        let sources = ast.get("sources").unwrap();
1615        // PoolManager.swap (id=616) has functionSelector "f3cd914c"
1616        let node = find_node_by_id(sources, NodeId(616)).unwrap();
1617        let selector = extract_selector(node).unwrap();
1618        assert_eq!(selector, Selector::Func(FuncSelector::new("f3cd914c")));
1619        assert_eq!(selector.as_hex(), "f3cd914c");
1620    }
1621
1622    #[test]
1623    fn test_extract_selector_error() {
1624        let ast = load_test_ast();
1625        let sources = ast.get("sources").unwrap();
1626        // DelegateCallNotAllowed (id=1372) has errorSelector
1627        let node = find_node_by_id(sources, NodeId(1372)).unwrap();
1628        let selector = extract_selector(node).unwrap();
1629        assert_eq!(selector, Selector::Func(FuncSelector::new("0d89438e")));
1630        assert_eq!(selector.as_hex(), "0d89438e");
1631    }
1632
1633    #[test]
1634    fn test_extract_selector_event() {
1635        let ast = load_test_ast();
1636        let sources = ast.get("sources").unwrap();
1637        // OwnershipTransferred (id=7404) has eventSelector
1638        let node = find_node_by_id(sources, NodeId(7404)).unwrap();
1639        let selector = extract_selector(node).unwrap();
1640        assert!(matches!(selector, Selector::Event(_)));
1641        assert_eq!(selector.as_hex().len(), 64); // 32-byte keccak hash
1642    }
1643
1644    #[test]
1645    fn test_extract_selector_public_variable() {
1646        let ast = load_test_ast();
1647        let sources = ast.get("sources").unwrap();
1648        // owner (id=7406) is public, has functionSelector
1649        let node = find_node_by_id(sources, NodeId(7406)).unwrap();
1650        let selector = extract_selector(node).unwrap();
1651        assert_eq!(selector, Selector::Func(FuncSelector::new("8da5cb5b")));
1652    }
1653
1654    #[test]
1655    fn test_extract_selector_internal_function_none() {
1656        let ast = load_test_ast();
1657        let sources = ast.get("sources").unwrap();
1658        // Pool.swap (id=5021) is internal, no selector
1659        let node = find_node_by_id(sources, NodeId(5021)).unwrap();
1660        assert!(extract_selector(node).is_none());
1661    }
1662
1663    // --- @inheritdoc resolution tests ---
1664
1665    #[test]
1666    fn test_resolve_inheritdoc_swap() {
1667        let ast = load_test_ast();
1668        let build = crate::goto::CachedBuild::new(ast, 0, None);
1669        // PoolManager.swap (id=616) has "@inheritdoc IPoolManager"
1670        let decl = build.decl_index.get(&NodeId(616)).unwrap();
1671        let doc_text = decl.extract_doc_text().unwrap();
1672        assert!(doc_text.contains("@inheritdoc"));
1673
1674        let resolved = resolve_inheritdoc_typed(decl, &doc_text, &build.decl_index).unwrap();
1675        assert!(resolved.contains("@notice"));
1676        assert!(resolved.contains("Swap against the given pool"));
1677    }
1678
1679    #[test]
1680    fn test_resolve_inheritdoc_initialize() {
1681        let ast = load_test_ast();
1682        let build = crate::goto::CachedBuild::new(ast, 0, None);
1683        // PoolManager.initialize (id=330) has "@inheritdoc IPoolManager"
1684        let decl = build.decl_index.get(&NodeId(330)).unwrap();
1685        let doc_text = decl.extract_doc_text().unwrap();
1686
1687        let resolved = resolve_inheritdoc_typed(decl, &doc_text, &build.decl_index).unwrap();
1688        assert!(resolved.contains("Initialize the state"));
1689        assert!(resolved.contains("@param key"));
1690    }
1691
1692    #[test]
1693    fn test_resolve_inheritdoc_extsload_overload() {
1694        let ast = load_test_ast();
1695        let build = crate::goto::CachedBuild::new(ast, 0, None);
1696
1697        // extsload(bytes32) — id=1306, selector "1e2eaeaf"
1698        let decl = build.decl_index.get(&NodeId(1306)).unwrap();
1699        let doc_text = decl.extract_doc_text().unwrap();
1700        let resolved = resolve_inheritdoc_typed(decl, &doc_text, &build.decl_index).unwrap();
1701        assert!(resolved.contains("granular pool state"));
1702        assert!(resolved.contains("@param slot"));
1703
1704        // extsload(bytes32, uint256) — id=1319, selector "35fd631a"
1705        let decl2 = build.decl_index.get(&NodeId(1319)).unwrap();
1706        let doc_text2 = decl2.extract_doc_text().unwrap();
1707        let resolved2 = resolve_inheritdoc_typed(decl2, &doc_text2, &build.decl_index).unwrap();
1708        assert!(resolved2.contains("@param startSlot"));
1709
1710        // extsload(bytes32[]) — id=1331, selector "dbd035ff"
1711        let decl3 = build.decl_index.get(&NodeId(1331)).unwrap();
1712        let doc_text3 = decl3.extract_doc_text().unwrap();
1713        let resolved3 = resolve_inheritdoc_typed(decl3, &doc_text3, &build.decl_index).unwrap();
1714        assert!(resolved3.contains("sparse pool state"));
1715    }
1716
1717    #[test]
1718    fn test_resolve_inheritdoc_formats_in_hover() {
1719        let ast = load_test_ast();
1720        let build = crate::goto::CachedBuild::new(ast, 0, None);
1721        // PoolManager.swap with @inheritdoc — verify format_natspec resolves it
1722        let decl = build.decl_index.get(&NodeId(616)).unwrap();
1723        let doc_text = decl.extract_doc_text().unwrap();
1724        let inherited = resolve_inheritdoc_typed(decl, &doc_text, &build.decl_index);
1725        let formatted = format_natspec(&doc_text, inherited.as_deref());
1726        assert!(!formatted.contains("@inheritdoc"));
1727        assert!(formatted.contains("Swap against the given pool"));
1728        assert!(formatted.contains("**Parameters:**"));
1729    }
1730
1731    // --- Parameter/return doc tests ---
1732
1733    #[test]
1734    fn test_param_doc_error_parameter() {
1735        let ast = load_test_ast();
1736        let build = crate::goto::CachedBuild::new(ast, 0, None);
1737
1738        // PriceLimitAlreadyExceeded.sqrtPriceCurrentX96 (id=3821)
1739        let decl = build.decl_index.get(&NodeId(3821)).unwrap();
1740        assert_eq!(decl.name(), "sqrtPriceCurrentX96");
1741
1742        let doc = lookup_param_doc_typed(
1743            &build.doc_index,
1744            decl,
1745            &build.decl_index,
1746            &build.node_id_to_source_path,
1747        )
1748        .unwrap();
1749        assert!(
1750            doc.contains("invalid"),
1751            "should describe the invalid price: {doc}"
1752        );
1753    }
1754
1755    #[test]
1756    fn test_param_doc_error_second_parameter() {
1757        let ast = load_test_ast();
1758        let build = crate::goto::CachedBuild::new(ast, 0, None);
1759
1760        // PriceLimitAlreadyExceeded.sqrtPriceLimitX96 (id=3823)
1761        let decl = build.decl_index.get(&NodeId(3823)).unwrap();
1762        let doc = lookup_param_doc_typed(
1763            &build.doc_index,
1764            decl,
1765            &build.decl_index,
1766            &build.node_id_to_source_path,
1767        )
1768        .unwrap();
1769        assert!(
1770            doc.contains("surpassed price limit"),
1771            "should describe the surpassed limit: {doc}"
1772        );
1773    }
1774
1775    #[test]
1776    fn test_param_doc_function_return_value() {
1777        let ast = load_test_ast();
1778        let build = crate::goto::CachedBuild::new(ast, 0, None);
1779
1780        // Pool.modifyLiquidity return param "delta" (id=4055)
1781        let decl = build.decl_index.get(&NodeId(4055)).unwrap();
1782        assert_eq!(decl.name(), "delta");
1783
1784        let doc = lookup_param_doc_typed(
1785            &build.doc_index,
1786            decl,
1787            &build.decl_index,
1788            &build.node_id_to_source_path,
1789        )
1790        .unwrap();
1791        assert!(
1792            doc.contains("deltas of the token balances"),
1793            "should have return doc: {doc}"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_param_doc_function_input_parameter() {
1799        let ast = load_test_ast();
1800        let build = crate::goto::CachedBuild::new(ast, 0, None);
1801
1802        // Pool.modifyLiquidity input param "params" — find by scanning decl_index
1803        // The function id=4371 has a parameter named "params"
1804        let params_decl = build
1805            .decl_index
1806            .values()
1807            .find(|d| d.name() == "params" && d.scope() == Some(4371))
1808            .unwrap();
1809
1810        let doc = lookup_param_doc_typed(
1811            &build.doc_index,
1812            params_decl,
1813            &build.decl_index,
1814            &build.node_id_to_source_path,
1815        )
1816        .unwrap();
1817        assert!(
1818            doc.contains("position details"),
1819            "should have param doc: {doc}"
1820        );
1821    }
1822
1823    #[test]
1824    fn test_param_doc_inherited_function_via_docindex() {
1825        let ast = load_test_ast();
1826        let build = crate::goto::CachedBuild::new(ast, 0, None);
1827
1828        // PoolManager.swap `key` param (id=478) — parent has @inheritdoc IPoolManager
1829        let decl = build.decl_index.get(&NodeId(478)).unwrap();
1830        assert_eq!(decl.name(), "key");
1831
1832        let doc = lookup_param_doc_typed(
1833            &build.doc_index,
1834            decl,
1835            &build.decl_index,
1836            &build.node_id_to_source_path,
1837        )
1838        .unwrap();
1839        assert!(
1840            doc.contains("pool to swap"),
1841            "should have inherited param doc: {doc}"
1842        );
1843    }
1844
1845    #[test]
1846    fn test_param_doc_non_parameter_returns_none() {
1847        let ast = load_test_ast();
1848        let build = crate::goto::CachedBuild::new(ast, 0, None);
1849
1850        // PoolManager contract (id=1216) is not a parameter
1851        let decl = build.decl_index.get(&NodeId(1216)).unwrap();
1852        assert!(
1853            lookup_param_doc_typed(
1854                &build.doc_index,
1855                decl,
1856                &build.decl_index,
1857                &build.node_id_to_source_path,
1858            )
1859            .is_none()
1860        );
1861    }
1862
1863    // ── DocIndex integration tests (poolmanager.json) ──
1864
1865    fn load_solc_fixture() -> Value {
1866        let data = std::fs::read_to_string("poolmanager.json").expect("test fixture");
1867        let raw: Value = serde_json::from_str(&data).expect("valid json");
1868        crate::solc::normalize_solc_output(raw, None)
1869    }
1870
1871    #[test]
1872    fn test_doc_index_is_not_empty() {
1873        let ast = load_solc_fixture();
1874        let index = build_doc_index(&ast);
1875        assert!(!index.is_empty(), "DocIndex should contain entries");
1876    }
1877
1878    #[test]
1879    fn test_doc_index_has_contract_entries() {
1880        let ast = load_solc_fixture();
1881        let index = build_doc_index(&ast);
1882
1883        // PoolManager has both title and notice
1884        let pm_keys: Vec<_> = index
1885            .keys()
1886            .filter(|k| matches!(k, DocKey::Contract(s) if s.contains("PoolManager")))
1887            .collect();
1888        assert!(
1889            !pm_keys.is_empty(),
1890            "should have a Contract entry for PoolManager"
1891        );
1892
1893        let pm_key = DocKey::Contract(
1894            "/Users/meek/developer/asyncswap/solidity-language-server/v4-core/src/PoolManager.sol:PoolManager".to_string(),
1895        );
1896        let entry = index.get(&pm_key).expect("PoolManager contract entry");
1897        assert_eq!(entry.title.as_deref(), Some("PoolManager"));
1898        assert_eq!(
1899            entry.notice.as_deref(),
1900            Some("Holds the state for all pools")
1901        );
1902    }
1903
1904    #[test]
1905    fn test_doc_index_has_function_by_selector() {
1906        let ast = load_solc_fixture();
1907        let index = build_doc_index(&ast);
1908
1909        // initialize selector = 6276cbbe
1910        let init_key = DocKey::Func(FuncSelector::new("6276cbbe"));
1911        let entry = index
1912            .get(&init_key)
1913            .expect("should have initialize by selector");
1914        assert_eq!(
1915            entry.notice.as_deref(),
1916            Some("Initialize the state for a given pool ID")
1917        );
1918        assert!(
1919            entry
1920                .details
1921                .as_deref()
1922                .unwrap_or("")
1923                .contains("MAX_SWAP_FEE"),
1924            "devdoc details should mention MAX_SWAP_FEE"
1925        );
1926        // params: key, sqrtPriceX96
1927        let param_names: Vec<&str> = entry.params.iter().map(|(n, _)| n.as_str()).collect();
1928        assert!(param_names.contains(&"key"), "should have param 'key'");
1929        assert!(
1930            param_names.contains(&"sqrtPriceX96"),
1931            "should have param 'sqrtPriceX96'"
1932        );
1933        // returns: tick
1934        let return_names: Vec<&str> = entry.returns.iter().map(|(n, _)| n.as_str()).collect();
1935        assert!(return_names.contains(&"tick"), "should have return 'tick'");
1936    }
1937
1938    #[test]
1939    fn test_doc_index_swap_by_selector() {
1940        let ast = load_solc_fixture();
1941        let index = build_doc_index(&ast);
1942
1943        // swap selector = f3cd914c
1944        let swap_key = DocKey::Func(FuncSelector::new("f3cd914c"));
1945        let entry = index.get(&swap_key).expect("should have swap by selector");
1946        assert!(
1947            entry
1948                .notice
1949                .as_deref()
1950                .unwrap_or("")
1951                .contains("Swap against the given pool"),
1952            "swap notice should describe swapping"
1953        );
1954        // devdoc params: key, params, hookData
1955        assert!(
1956            !entry.params.is_empty(),
1957            "swap should have param documentation"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_doc_index_settle_by_selector() {
1963        let ast = load_solc_fixture();
1964        let index = build_doc_index(&ast);
1965
1966        // settle() selector = 11da60b4
1967        let key = DocKey::Func(FuncSelector::new("11da60b4"));
1968        let entry = index.get(&key).expect("should have settle by selector");
1969        assert!(
1970            entry.notice.is_some(),
1971            "settle should have a notice from userdoc"
1972        );
1973    }
1974
1975    #[test]
1976    fn test_doc_index_has_error_entries() {
1977        let ast = load_solc_fixture();
1978        let index = build_doc_index(&ast);
1979
1980        // AlreadyUnlocked() → keccak256("AlreadyUnlocked()")[0..4]
1981        let selector = compute_selector("AlreadyUnlocked()");
1982        let key = DocKey::Func(FuncSelector::new(&selector));
1983        let entry = index.get(&key).expect("should have AlreadyUnlocked error");
1984        assert!(
1985            entry
1986                .notice
1987                .as_deref()
1988                .unwrap_or("")
1989                .contains("already unlocked"),
1990            "AlreadyUnlocked notice: {:?}",
1991            entry.notice
1992        );
1993    }
1994
1995    #[test]
1996    fn test_doc_index_error_with_params() {
1997        let ast = load_solc_fixture();
1998        let index = build_doc_index(&ast);
1999
2000        // CurrenciesOutOfOrderOrEqual(address,address) has a notice
2001        let selector = compute_selector("CurrenciesOutOfOrderOrEqual(address,address)");
2002        let key = DocKey::Func(FuncSelector::new(&selector));
2003        let entry = index
2004            .get(&key)
2005            .expect("should have CurrenciesOutOfOrderOrEqual error");
2006        assert!(entry.notice.is_some(), "error should have notice");
2007    }
2008
2009    #[test]
2010    fn test_doc_index_has_event_entries() {
2011        let ast = load_solc_fixture();
2012        let index = build_doc_index(&ast);
2013
2014        // Count event entries
2015        let event_count = index
2016            .keys()
2017            .filter(|k| matches!(k, DocKey::Event(_)))
2018            .count();
2019        assert!(event_count > 0, "should have event entries in the DocIndex");
2020    }
2021
2022    #[test]
2023    fn test_doc_index_swap_event() {
2024        let ast = load_solc_fixture();
2025        let index = build_doc_index(&ast);
2026
2027        // Swap event topic = keccak256("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)")
2028        let topic =
2029            compute_event_topic("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)");
2030        let key = DocKey::Event(EventSelector::new(&topic));
2031        let entry = index.get(&key).expect("should have Swap event");
2032
2033        // userdoc notice
2034        assert!(
2035            entry
2036                .notice
2037                .as_deref()
2038                .unwrap_or("")
2039                .contains("swaps between currency0 and currency1"),
2040            "Swap event notice: {:?}",
2041            entry.notice
2042        );
2043
2044        // devdoc params (amount0, amount1, id, sender, sqrtPriceX96, etc.)
2045        let param_names: Vec<&str> = entry.params.iter().map(|(n, _)| n.as_str()).collect();
2046        assert!(
2047            param_names.contains(&"amount0"),
2048            "should have param 'amount0'"
2049        );
2050        assert!(
2051            param_names.contains(&"sender"),
2052            "should have param 'sender'"
2053        );
2054        assert!(param_names.contains(&"id"), "should have param 'id'");
2055    }
2056
2057    #[test]
2058    fn test_doc_index_initialize_event() {
2059        let ast = load_solc_fixture();
2060        let index = build_doc_index(&ast);
2061
2062        let topic = compute_event_topic(
2063            "Initialize(bytes32,address,address,uint24,int24,address,uint160,int24)",
2064        );
2065        let key = DocKey::Event(EventSelector::new(&topic));
2066        let entry = index.get(&key).expect("should have Initialize event");
2067        assert!(
2068            !entry.params.is_empty(),
2069            "Initialize event should have param docs"
2070        );
2071    }
2072
2073    #[test]
2074    fn test_doc_index_no_state_variables_for_pool_manager() {
2075        let ast = load_solc_fixture();
2076        let index = build_doc_index(&ast);
2077
2078        // PoolManager has no devdoc.stateVariables, so no StateVar keys for it
2079        let sv_count = index
2080            .keys()
2081            .filter(|k| matches!(k, DocKey::StateVar(s) if s.contains("PoolManager")))
2082            .count();
2083        assert_eq!(
2084            sv_count, 0,
2085            "PoolManager should have no state variable doc entries"
2086        );
2087    }
2088
2089    #[test]
2090    fn test_doc_index_multiple_contracts() {
2091        let ast = load_solc_fixture();
2092        let index = build_doc_index(&ast);
2093
2094        // Should have contract entries for multiple contracts (ERC6909, Extsload, IPoolManager, etc.)
2095        let contract_count = index
2096            .keys()
2097            .filter(|k| matches!(k, DocKey::Contract(_)))
2098            .count();
2099        assert!(
2100            contract_count >= 5,
2101            "should have at least 5 contract-level entries, got {contract_count}"
2102        );
2103    }
2104
2105    #[test]
2106    fn test_doc_index_func_key_count() {
2107        let ast = load_solc_fixture();
2108        let index = build_doc_index(&ast);
2109
2110        let func_count = index
2111            .keys()
2112            .filter(|k| matches!(k, DocKey::Func(_)))
2113            .count();
2114        // We have methods + errors keyed by selector across all 43 contracts
2115        assert!(
2116            func_count >= 30,
2117            "should have at least 30 Func entries (methods + errors), got {func_count}"
2118        );
2119    }
2120
2121    #[test]
2122    fn test_doc_index_format_initialize_entry() {
2123        let ast = load_solc_fixture();
2124        let index = build_doc_index(&ast);
2125
2126        let key = DocKey::Func(FuncSelector::new("6276cbbe"));
2127        let entry = index.get(&key).expect("initialize entry");
2128        let formatted = format_doc_entry(entry);
2129
2130        assert!(
2131            formatted.contains("Initialize the state for a given pool ID"),
2132            "formatted should include notice"
2133        );
2134        assert!(
2135            formatted.contains("**@dev**"),
2136            "formatted should include dev section"
2137        );
2138        assert!(
2139            formatted.contains("**Parameters:**"),
2140            "formatted should include parameters"
2141        );
2142        assert!(
2143            formatted.contains("`key`"),
2144            "formatted should include key param"
2145        );
2146        assert!(
2147            formatted.contains("**Returns:**"),
2148            "formatted should include returns"
2149        );
2150        assert!(
2151            formatted.contains("`tick`"),
2152            "formatted should include tick return"
2153        );
2154    }
2155
2156    #[test]
2157    fn test_doc_index_format_contract_entry() {
2158        let ast = load_solc_fixture();
2159        let index = build_doc_index(&ast);
2160
2161        let key = DocKey::Contract(
2162            "/Users/meek/developer/asyncswap/solidity-language-server/v4-core/src/PoolManager.sol:PoolManager".to_string(),
2163        );
2164        let entry = index.get(&key).expect("PoolManager contract entry");
2165        let formatted = format_doc_entry(entry);
2166
2167        assert!(
2168            formatted.contains("**PoolManager**"),
2169            "should include bold title"
2170        );
2171        assert!(
2172            formatted.contains("Holds the state for all pools"),
2173            "should include notice"
2174        );
2175    }
2176
2177    #[test]
2178    fn test_doc_index_inherited_docs_resolved() {
2179        let ast = load_solc_fixture();
2180        let index = build_doc_index(&ast);
2181
2182        // Both PoolManager and IPoolManager define methods with the same selector.
2183        // The last one written wins (PoolManager overwrites IPoolManager for same selector).
2184        // Either way, swap(f3cd914c) should have full docs, not just "@inheritdoc".
2185        let key = DocKey::Func(FuncSelector::new("f3cd914c"));
2186        let entry = index.get(&key).expect("swap entry");
2187        // The notice should be the resolved text, not "@inheritdoc IPoolManager"
2188        let notice = entry.notice.as_deref().unwrap_or("");
2189        assert!(
2190            !notice.contains("@inheritdoc"),
2191            "userdoc/devdoc should have resolved inherited docs, not raw @inheritdoc"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_compute_selector_known_values() {
2197        // keccak256("AlreadyUnlocked()") first 4 bytes
2198        let sel = compute_selector("AlreadyUnlocked()");
2199        assert_eq!(sel.len(), 8, "selector should be 8 hex chars");
2200
2201        // Verify against a known selector from evm.methodIdentifiers
2202        let init_sel =
2203            compute_selector("initialize((address,address,uint24,int24,address),uint160)");
2204        assert_eq!(
2205            init_sel, "6276cbbe",
2206            "computed initialize selector should match evm.methodIdentifiers"
2207        );
2208    }
2209
2210    #[test]
2211    fn test_compute_event_topic_length() {
2212        let topic =
2213            compute_event_topic("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)");
2214        assert_eq!(
2215            topic.len(),
2216            64,
2217            "event topic should be 64 hex chars (32 bytes)"
2218        );
2219    }
2220
2221    #[test]
2222    fn test_doc_index_error_count_poolmanager() {
2223        let ast = load_solc_fixture();
2224        let index = build_doc_index(&ast);
2225
2226        // PoolManager userdoc has 14 errors. Check that they're all indexed.
2227        // Compute selectors for all 14 error signatures and verify they exist.
2228        let error_sigs = [
2229            "AlreadyUnlocked()",
2230            "CurrenciesOutOfOrderOrEqual(address,address)",
2231            "CurrencyNotSettled()",
2232            "InvalidCaller()",
2233            "ManagerLocked()",
2234            "MustClearExactPositiveDelta()",
2235            "NonzeroNativeValue()",
2236            "PoolNotInitialized()",
2237            "ProtocolFeeCurrencySynced()",
2238            "ProtocolFeeTooLarge(uint24)",
2239            "SwapAmountCannotBeZero()",
2240            "TickSpacingTooLarge(int24)",
2241            "TickSpacingTooSmall(int24)",
2242            "UnauthorizedDynamicLPFeeUpdate()",
2243        ];
2244        let mut found = 0;
2245        for sig in &error_sigs {
2246            let selector = compute_selector(sig);
2247            let key = DocKey::Func(FuncSelector::new(&selector));
2248            if index.contains_key(&key) {
2249                found += 1;
2250            }
2251        }
2252        assert_eq!(
2253            found,
2254            error_sigs.len(),
2255            "all 14 PoolManager errors should be in the DocIndex"
2256        );
2257    }
2258
2259    #[test]
2260    fn test_doc_index_extsload_overloads_have_different_selectors() {
2261        let ast = load_solc_fixture();
2262        let index = build_doc_index(&ast);
2263
2264        // Three extsload overloads should each have their own selector entry
2265        // extsload(bytes32) = 1e2eaeaf
2266        // extsload(bytes32,uint256) = 35fd631a
2267        // extsload(bytes32[]) = dbd035ff
2268        let sel1 = DocKey::Func(FuncSelector::new("1e2eaeaf"));
2269        let sel2 = DocKey::Func(FuncSelector::new("35fd631a"));
2270        let sel3 = DocKey::Func(FuncSelector::new("dbd035ff"));
2271
2272        assert!(index.contains_key(&sel1), "extsload(bytes32) should exist");
2273        assert!(
2274            index.contains_key(&sel2),
2275            "extsload(bytes32,uint256) should exist"
2276        );
2277        assert!(
2278            index.contains_key(&sel3),
2279            "extsload(bytes32[]) should exist"
2280        );
2281    }
2282
2283    #[test]
2284    fn test_signature_help_parameter_offsets() {
2285        // Simulate a signature like: "function addTax(uint256 amount, uint16 tax, uint16 base)"
2286        let label = "function addTax(uint256 amount, uint16 tax, uint16 base)";
2287        let param_strings = vec![
2288            "uint256 amount".to_string(),
2289            "uint16 tax".to_string(),
2290            "uint16 base".to_string(),
2291        ];
2292
2293        let params_start = label.find('(').unwrap() + 1;
2294        let mut offsets = Vec::new();
2295        let mut offset = params_start;
2296        for param_str in &param_strings {
2297            let start = offset;
2298            let end = start + param_str.len();
2299            offsets.push((start, end));
2300            offset = end + 2; // ", "
2301        }
2302
2303        // Verify the offsets correctly slice the label
2304        assert_eq!(&label[offsets[0].0..offsets[0].1], "uint256 amount");
2305        assert_eq!(&label[offsets[1].0..offsets[1].1], "uint16 tax");
2306        assert_eq!(&label[offsets[2].0..offsets[2].1], "uint16 base");
2307    }
2308
2309    // ── Typed mapping signature help tests ────────────
2310
2311    #[test]
2312    fn find_mapping_decl_typed_pools() {
2313        let ast = load_test_ast();
2314        let build = crate::goto::CachedBuild::new(ast, 0, None);
2315        let decl = find_mapping_decl_typed(&build.decl_index, "_pools").unwrap();
2316        assert_eq!(decl.name, "_pools");
2317        assert!(matches!(
2318            decl.type_name.as_ref(),
2319            Some(crate::solc_ast::TypeName::Mapping(_))
2320        ));
2321    }
2322
2323    #[test]
2324    fn find_mapping_decl_typed_not_found() {
2325        let ast = load_test_ast();
2326        let build = crate::goto::CachedBuild::new(ast, 0, None);
2327        assert!(find_mapping_decl_typed(&build.decl_index, "nonexistent").is_none());
2328    }
2329
2330    #[test]
2331    fn mapping_signature_help_typed_pools() {
2332        let ast = load_test_ast();
2333        let build = crate::goto::CachedBuild::new(ast, 0, None);
2334        let help = mapping_signature_help_typed(&build.decl_index, "_pools").unwrap();
2335        assert!(help.signatures[0].label.contains("_pools"));
2336        let params = help.signatures[0].parameters.as_ref().unwrap();
2337        assert!(!params.is_empty());
2338    }
2339
2340    #[test]
2341    fn mapping_signature_help_typed_protocol_fees() {
2342        let ast = load_test_ast();
2343        let build = crate::goto::CachedBuild::new(ast, 0, None);
2344        let help = mapping_signature_help_typed(&build.decl_index, "protocolFeesAccrued").unwrap();
2345        assert!(help.signatures[0].label.contains("protocolFeesAccrued"));
2346    }
2347
2348    #[test]
2349    fn mapping_signature_help_typed_non_mapping() {
2350        let ast = load_test_ast();
2351        let build = crate::goto::CachedBuild::new(ast, 0, None);
2352        assert!(mapping_signature_help_typed(&build.decl_index, "owner").is_none());
2353    }
2354}