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