Skip to main content

solidity_language_server/
hover.rs

1use serde_json::Value;
2use std::collections::HashMap;
3use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Url};
4
5use crate::goto::{cache_ids, pos_to_bytes, CHILD_KEYS};
6use crate::references::{byte_to_decl_via_external_refs, byte_to_id};
7
8/// Find the raw AST node with the given id by walking all sources.
9pub fn find_node_by_id<'a>(sources: &'a Value, target_id: u64) -> Option<&'a Value> {
10    let sources_obj = sources.as_object()?;
11    for (_path, contents) in sources_obj {
12        let contents_array = contents.as_array()?;
13        let first_content = contents_array.first()?;
14        let source_file = first_content.get("source_file")?;
15        let ast = source_file.get("ast")?;
16
17        // Check root
18        if ast.get("id").and_then(|v| v.as_u64()) == Some(target_id) {
19            return Some(ast);
20        }
21
22        let mut stack = vec![ast];
23        while let Some(node) = stack.pop() {
24            if node.get("id").and_then(|v| v.as_u64()) == Some(target_id) {
25                return Some(node);
26            }
27            for key in CHILD_KEYS {
28                if let Some(value) = node.get(key) {
29                    match value {
30                        Value::Array(arr) => stack.extend(arr.iter()),
31                        Value::Object(_) => stack.push(value),
32                        _ => {}
33                    }
34                }
35            }
36        }
37    }
38    None
39}
40
41/// Extract documentation text from a node.
42/// Handles both object form `{text: "..."}` and plain string form.
43pub fn extract_documentation(node: &Value) -> Option<String> {
44    let doc = node.get("documentation")?;
45    match doc {
46        Value::Object(_) => doc
47            .get("text")
48            .and_then(|v| v.as_str())
49            .map(|s| s.to_string()),
50        Value::String(s) => Some(s.clone()),
51        _ => None,
52    }
53}
54
55/// Extract the selector from a declaration node.
56/// Returns (selector_hex, selector_kind) where kind is "function", "error", or "event".
57pub fn extract_selector(node: &Value) -> Option<(String, &'static str)> {
58    let node_type = node.get("nodeType").and_then(|v| v.as_str())?;
59    match node_type {
60        "FunctionDefinition" => node
61            .get("functionSelector")
62            .and_then(|v| v.as_str())
63            .map(|s| (s.to_string(), "function")),
64        "VariableDeclaration" => node
65            .get("functionSelector")
66            .and_then(|v| v.as_str())
67            .map(|s| (s.to_string(), "function")),
68        "ErrorDefinition" => node
69            .get("errorSelector")
70            .and_then(|v| v.as_str())
71            .map(|s| (s.to_string(), "error")),
72        "EventDefinition" => node
73            .get("eventSelector")
74            .and_then(|v| v.as_str())
75            .map(|s| (s.to_string(), "event")),
76        _ => None,
77    }
78}
79
80/// Resolve `@inheritdoc ParentName` by matching function selectors.
81///
82/// 1. Parse the parent contract name from `@inheritdoc ParentName`
83/// 2. Get the declaration's `functionSelector`
84/// 3. Find the parent contract in `baseContracts` of the scope contract
85/// 4. Match by selector in the parent's child nodes
86/// 5. Return the matched parent node's documentation
87pub fn resolve_inheritdoc<'a>(
88    sources: &'a Value,
89    decl_node: &'a Value,
90    doc_text: &str,
91) -> Option<String> {
92    // Parse "@inheritdoc ParentName"
93    let parent_name = doc_text
94        .lines()
95        .find_map(|line| {
96            let trimmed = line.trim().trim_start_matches('*').trim();
97            trimmed.strip_prefix("@inheritdoc ")
98        })?
99        .trim();
100
101    // Get the selector from the implementation function
102    let (impl_selector, _) = extract_selector(decl_node)?;
103
104    // Get the scope (containing contract id)
105    let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
106
107    // Find the scope contract
108    let scope_contract = find_node_by_id(sources, scope_id)?;
109
110    // Find the parent contract in baseContracts by name
111    let base_contracts = scope_contract
112        .get("baseContracts")
113        .and_then(|v| v.as_array())?;
114    let parent_id = base_contracts.iter().find_map(|base| {
115        let name = base
116            .get("baseName")
117            .and_then(|bn| bn.get("name"))
118            .and_then(|n| n.as_str())?;
119        if name == parent_name {
120            base.get("baseName")
121                .and_then(|bn| bn.get("referencedDeclaration"))
122                .and_then(|v| v.as_u64())
123        } else {
124            None
125        }
126    })?;
127
128    // Find the parent contract node
129    let parent_contract = find_node_by_id(sources, parent_id)?;
130
131    // Search parent's children for matching selector
132    let parent_nodes = parent_contract.get("nodes").and_then(|v| v.as_array())?;
133    for child in parent_nodes {
134        if let Some((child_selector, _)) = extract_selector(child) {
135            if child_selector == impl_selector {
136                return extract_documentation(child);
137            }
138        }
139    }
140
141    None
142}
143
144/// Format NatSpec documentation as markdown.
145/// Strips leading `@` tags and formats them nicely.
146/// When `inherited_doc` is provided, it replaces `@inheritdoc` lines with the resolved content.
147pub fn format_natspec(text: &str, inherited_doc: Option<&str>) -> String {
148    let mut lines: Vec<String> = Vec::new();
149    let mut in_params = false;
150    let mut in_returns = false;
151
152    for raw_line in text.lines() {
153        let line = raw_line.trim().trim_start_matches('*').trim();
154        if line.is_empty() {
155            continue;
156        }
157
158        if let Some(rest) = line.strip_prefix("@notice ") {
159            in_params = false;
160            in_returns = false;
161            lines.push(rest.to_string());
162        } else if let Some(rest) = line.strip_prefix("@dev ") {
163            in_params = false;
164            in_returns = false;
165            lines.push(String::new());
166            lines.push(format!("*{rest}*"));
167        } else if let Some(rest) = line.strip_prefix("@param ") {
168            if !in_params {
169                in_params = true;
170                in_returns = false;
171                lines.push(String::new());
172                lines.push("**Parameters:**".to_string());
173            }
174            if let Some((name, desc)) = rest.split_once(' ') {
175                lines.push(format!("- `{name}` — {desc}"));
176            } else {
177                lines.push(format!("- `{rest}`"));
178            }
179        } else if let Some(rest) = line.strip_prefix("@return ") {
180            if !in_returns {
181                in_returns = true;
182                in_params = false;
183                lines.push(String::new());
184                lines.push("**Returns:**".to_string());
185            }
186            if let Some((name, desc)) = rest.split_once(' ') {
187                lines.push(format!("- `{name}` — {desc}"));
188            } else {
189                lines.push(format!("- `{rest}`"));
190            }
191        } else if line.starts_with("@author ") {
192            // skip author for hover
193        } else if line.starts_with("@inheritdoc ") {
194            // Resolve inherited docs if available
195            if let Some(inherited) = inherited_doc {
196                // Recursively format the inherited doc (it won't have another @inheritdoc)
197                let formatted = format_natspec(inherited, None);
198                if !formatted.is_empty() {
199                    lines.push(formatted);
200                }
201            } else {
202                let parent = line.strip_prefix("@inheritdoc ").unwrap_or("");
203                lines.push(format!("*Inherits documentation from `{parent}`*"));
204            }
205        } else {
206            // Continuation line
207            lines.push(line.to_string());
208        }
209    }
210
211    lines.join("\n")
212}
213
214/// Build a function/modifier signature string from a raw AST node.
215fn build_function_signature(node: &Value) -> Option<String> {
216    let node_type = node.get("nodeType").and_then(|v| v.as_str())?;
217    let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
218
219    match node_type {
220        "FunctionDefinition" => {
221            let kind = node
222                .get("kind")
223                .and_then(|v| v.as_str())
224                .unwrap_or("function");
225            let visibility = node
226                .get("visibility")
227                .and_then(|v| v.as_str())
228                .unwrap_or("");
229            let state_mutability = node
230                .get("stateMutability")
231                .and_then(|v| v.as_str())
232                .unwrap_or("");
233
234            let params = format_parameters(node.get("parameters"));
235            let returns = format_parameters(node.get("returnParameters"));
236
237            let mut sig = match kind {
238                "constructor" => format!("constructor({params})"),
239                "receive" => "receive() external payable".to_string(),
240                "fallback" => format!("fallback({params})"),
241                _ => format!("function {name}({params})"),
242            };
243
244            if !visibility.is_empty() && kind != "constructor" && kind != "receive" {
245                sig.push_str(&format!(" {visibility}"));
246            }
247            if !state_mutability.is_empty() && state_mutability != "nonpayable" {
248                sig.push_str(&format!(" {state_mutability}"));
249            }
250            if !returns.is_empty() {
251                sig.push_str(&format!(" returns ({returns})"));
252            }
253            Some(sig)
254        }
255        "ModifierDefinition" => {
256            let params = format_parameters(node.get("parameters"));
257            Some(format!("modifier {name}({params})"))
258        }
259        "EventDefinition" => {
260            let params = format_parameters(node.get("parameters"));
261            Some(format!("event {name}({params})"))
262        }
263        "ErrorDefinition" => {
264            let params = format_parameters(node.get("parameters"));
265            Some(format!("error {name}({params})"))
266        }
267        "VariableDeclaration" => {
268            let type_str = node
269                .get("typeDescriptions")
270                .and_then(|v| v.get("typeString"))
271                .and_then(|v| v.as_str())
272                .unwrap_or("unknown");
273            let visibility = node
274                .get("visibility")
275                .and_then(|v| v.as_str())
276                .unwrap_or("");
277            let mutability = node
278                .get("mutability")
279                .and_then(|v| v.as_str())
280                .unwrap_or("");
281
282            let mut sig = format!("{type_str}");
283            if !visibility.is_empty() {
284                sig.push_str(&format!(" {visibility}"));
285            }
286            if mutability == "constant" || mutability == "immutable" {
287                sig.push_str(&format!(" {mutability}"));
288            }
289            sig.push_str(&format!(" {name}"));
290            Some(sig)
291        }
292        "ContractDefinition" => {
293            let contract_kind = node
294                .get("contractKind")
295                .and_then(|v| v.as_str())
296                .unwrap_or("contract");
297
298            let mut sig = format!("{contract_kind} {name}");
299
300            // Add base contracts
301            if let Some(bases) = node.get("baseContracts").and_then(|v| v.as_array()) {
302                if !bases.is_empty() {
303                    let base_names: Vec<&str> = bases
304                        .iter()
305                        .filter_map(|b| {
306                            b.get("baseName")
307                                .and_then(|bn| bn.get("name"))
308                                .and_then(|n| n.as_str())
309                        })
310                        .collect();
311                    if !base_names.is_empty() {
312                        sig.push_str(&format!(" is {}", base_names.join(", ")));
313                    }
314                }
315            }
316            Some(sig)
317        }
318        "StructDefinition" => {
319            let mut sig = format!("struct {name} {{\n");
320            if let Some(members) = node.get("members").and_then(|v| v.as_array()) {
321                for member in members {
322                    let mname = member.get("name").and_then(|v| v.as_str()).unwrap_or("?");
323                    let mtype = member
324                        .get("typeDescriptions")
325                        .and_then(|v| v.get("typeString"))
326                        .and_then(|v| v.as_str())
327                        .unwrap_or("?");
328                    sig.push_str(&format!("    {mtype} {mname};\n"));
329                }
330            }
331            sig.push('}');
332            Some(sig)
333        }
334        "EnumDefinition" => {
335            let mut sig = format!("enum {name} {{\n");
336            if let Some(members) = node.get("members").and_then(|v| v.as_array()) {
337                let names: Vec<&str> = members
338                    .iter()
339                    .filter_map(|m| m.get("name").and_then(|v| v.as_str()))
340                    .collect();
341                for n in &names {
342                    sig.push_str(&format!("    {n},\n"));
343                }
344            }
345            sig.push('}');
346            Some(sig)
347        }
348        "UserDefinedValueTypeDefinition" => {
349            let underlying = node
350                .get("underlyingType")
351                .and_then(|v| v.get("typeDescriptions"))
352                .and_then(|v| v.get("typeString"))
353                .and_then(|v| v.as_str())
354                .unwrap_or("unknown");
355            Some(format!("type {name} is {underlying}"))
356        }
357        _ => None,
358    }
359}
360
361/// Format parameter list from a parameters node.
362fn format_parameters(params_node: Option<&Value>) -> String {
363    let params_node = match params_node {
364        Some(v) => v,
365        None => return String::new(),
366    };
367    let params = match params_node.get("parameters").and_then(|v| v.as_array()) {
368        Some(arr) => arr,
369        None => return String::new(),
370    };
371
372    let parts: Vec<String> = params
373        .iter()
374        .map(|p| {
375            let type_str = p
376                .get("typeDescriptions")
377                .and_then(|v| v.get("typeString"))
378                .and_then(|v| v.as_str())
379                .unwrap_or("?");
380            let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
381            let storage = p
382                .get("storageLocation")
383                .and_then(|v| v.as_str())
384                .unwrap_or("default");
385
386            if name.is_empty() {
387                type_str.to_string()
388            } else if storage != "default" {
389                format!("{type_str} {storage} {name}")
390            } else {
391                format!("{type_str} {name}")
392            }
393        })
394        .collect();
395
396    parts.join(", ")
397}
398
399/// Produce hover information for the symbol at the given position.
400pub fn hover_info(
401    ast_data: &Value,
402    file_uri: &Url,
403    position: Position,
404    source_bytes: &[u8],
405) -> Option<Hover> {
406    let sources = ast_data.get("sources")?;
407    let build_infos = ast_data.get("build_infos").and_then(|v| v.as_array())?;
408    let first_build = build_infos.first()?;
409    let source_id_to_path = first_build
410        .get("source_id_to_path")
411        .and_then(|v| v.as_object())?;
412
413    let id_to_path: HashMap<String, String> = source_id_to_path
414        .iter()
415        .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
416        .collect();
417
418    let (nodes, path_to_abs, external_refs) = cache_ids(sources);
419
420    // Resolve the file path
421    let file_path = file_uri.to_file_path().ok()?;
422    let file_path_str = file_path.to_str()?;
423
424    // Find the absolute path for this file
425    let abs_path = path_to_abs
426        .iter()
427        .find(|(k, _)| file_path_str.ends_with(k.as_str()))
428        .map(|(_, v)| v.clone())?;
429
430    let byte_pos = pos_to_bytes(source_bytes, position);
431
432    // Resolve: first try Yul external refs, then normal node lookup
433    let node_id = byte_to_decl_via_external_refs(&external_refs, &id_to_path, &abs_path, byte_pos)
434        .or_else(|| byte_to_id(&nodes, &abs_path, byte_pos))?;
435
436    // Get the NodeInfo for this node
437    let node_info = nodes
438        .values()
439        .find_map(|file_nodes| file_nodes.get(&node_id))?;
440
441    // Follow referenced_declaration to the declaration node
442    let decl_id = node_info.referenced_declaration.unwrap_or(node_id);
443
444    // Find the raw AST node for the declaration
445    let decl_node = find_node_by_id(sources, decl_id)?;
446
447    // Build hover content
448    let mut parts: Vec<String> = Vec::new();
449
450    // Signature in a code block
451    if let Some(sig) = build_function_signature(decl_node) {
452        parts.push(format!("```solidity\n{sig}\n```"));
453    } else {
454        // Fallback: show type description for any node
455        if let Some(type_str) = decl_node
456            .get("typeDescriptions")
457            .and_then(|v| v.get("typeString"))
458            .and_then(|v| v.as_str())
459        {
460            let name = decl_node.get("name").and_then(|v| v.as_str()).unwrap_or("");
461            parts.push(format!("```solidity\n{type_str} {name}\n```"));
462        }
463    }
464
465    // Selector (function, error, or event)
466    if let Some((selector, kind)) = extract_selector(decl_node) {
467        match kind {
468            "event" => parts.push(format!("Selector: `0x{selector}`")),
469            _ => parts.push(format!("Selector: `0x{selector}`")),
470        }
471    }
472
473    // Documentation — resolve @inheritdoc via selector matching
474    if let Some(doc_text) = extract_documentation(decl_node) {
475        let inherited_doc = resolve_inheritdoc(sources, decl_node, &doc_text);
476        let formatted = format_natspec(&doc_text, inherited_doc.as_deref());
477        if !formatted.is_empty() {
478            parts.push(format!("---\n{formatted}"));
479        }
480    }
481
482    if parts.is_empty() {
483        return None;
484    }
485
486    Some(Hover {
487        contents: HoverContents::Markup(MarkupContent {
488            kind: MarkupKind::Markdown,
489            value: parts.join("\n\n"),
490        }),
491        range: None,
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn load_test_ast() -> Value {
500        let data = std::fs::read_to_string("pool-manager-ast.json").expect("test fixture");
501        serde_json::from_str(&data).expect("valid json")
502    }
503
504    #[test]
505    fn test_find_node_by_id_pool_manager() {
506        let ast = load_test_ast();
507        let sources = ast.get("sources").unwrap();
508        let node = find_node_by_id(sources, 1767).unwrap();
509        assert_eq!(
510            node.get("name").and_then(|v| v.as_str()),
511            Some("PoolManager")
512        );
513        assert_eq!(
514            node.get("nodeType").and_then(|v| v.as_str()),
515            Some("ContractDefinition")
516        );
517    }
518
519    #[test]
520    fn test_find_node_by_id_initialize() {
521        let ast = load_test_ast();
522        let sources = ast.get("sources").unwrap();
523        // IPoolManager.initialize has the full docs
524        let node = find_node_by_id(sources, 2411).unwrap();
525        assert_eq!(
526            node.get("name").and_then(|v| v.as_str()),
527            Some("initialize")
528        );
529    }
530
531    #[test]
532    fn test_extract_documentation_object() {
533        let ast = load_test_ast();
534        let sources = ast.get("sources").unwrap();
535        // IPoolManager.initialize (id=2411) has full NatSpec
536        let node = find_node_by_id(sources, 2411).unwrap();
537        let doc = extract_documentation(node).unwrap();
538        assert!(doc.contains("@notice"));
539        assert!(doc.contains("@param key"));
540    }
541
542    #[test]
543    fn test_extract_documentation_none() {
544        let ast = load_test_ast();
545        let sources = ast.get("sources").unwrap();
546        // PoolKey struct (id=8887) — check if it has docs
547        let node = find_node_by_id(sources, 8887).unwrap();
548        // PoolKey may or may not have docs, just verify no crash
549        let _ = extract_documentation(node);
550    }
551
552    #[test]
553    fn test_format_natspec_notice_and_params() {
554        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";
555        let formatted = format_natspec(text, None);
556        assert!(formatted.contains("Initialize the state"));
557        assert!(formatted.contains("**Parameters:**"));
558        assert!(formatted.contains("`key`"));
559        assert!(formatted.contains("**Returns:**"));
560        assert!(formatted.contains("`tick`"));
561    }
562
563    #[test]
564    fn test_format_natspec_inheritdoc() {
565        let text = "@inheritdoc IPoolManager";
566        let formatted = format_natspec(text, None);
567        assert!(formatted.contains("Inherits documentation from `IPoolManager`"));
568    }
569
570    #[test]
571    fn test_format_natspec_dev() {
572        let text = "@notice Do something\n @dev This is an implementation detail";
573        let formatted = format_natspec(text, None);
574        assert!(formatted.contains("Do something"));
575        assert!(formatted.contains("*This is an implementation detail*"));
576    }
577
578    #[test]
579    fn test_build_function_signature_initialize() {
580        let ast = load_test_ast();
581        let sources = ast.get("sources").unwrap();
582        let node = find_node_by_id(sources, 2411).unwrap();
583        let sig = build_function_signature(node).unwrap();
584        assert!(sig.starts_with("function initialize("));
585        assert!(sig.contains("returns"));
586    }
587
588    #[test]
589    fn test_build_signature_contract() {
590        let ast = load_test_ast();
591        let sources = ast.get("sources").unwrap();
592        let node = find_node_by_id(sources, 1767).unwrap();
593        let sig = build_function_signature(node).unwrap();
594        assert!(sig.contains("contract PoolManager"));
595        assert!(sig.contains(" is "));
596    }
597
598    #[test]
599    fn test_build_signature_struct() {
600        let ast = load_test_ast();
601        let sources = ast.get("sources").unwrap();
602        let node = find_node_by_id(sources, 8887).unwrap();
603        let sig = build_function_signature(node).unwrap();
604        assert!(sig.starts_with("struct PoolKey"));
605        assert!(sig.contains('{'));
606    }
607
608    #[test]
609    fn test_build_signature_error() {
610        let ast = load_test_ast();
611        let sources = ast.get("sources").unwrap();
612        // Find an ErrorDefinition
613        let node = find_node_by_id(sources, 508).unwrap();
614        assert_eq!(
615            node.get("nodeType").and_then(|v| v.as_str()),
616            Some("ErrorDefinition")
617        );
618        let sig = build_function_signature(node).unwrap();
619        assert!(sig.starts_with("error "));
620    }
621
622    #[test]
623    fn test_build_signature_event() {
624        let ast = load_test_ast();
625        let sources = ast.get("sources").unwrap();
626        // Find an EventDefinition
627        let node = find_node_by_id(sources, 8).unwrap();
628        assert_eq!(
629            node.get("nodeType").and_then(|v| v.as_str()),
630            Some("EventDefinition")
631        );
632        let sig = build_function_signature(node).unwrap();
633        assert!(sig.starts_with("event "));
634    }
635
636    #[test]
637    fn test_build_signature_variable() {
638        let ast = load_test_ast();
639        let sources = ast.get("sources").unwrap();
640        // Find a VariableDeclaration with documentation — check a state var
641        // PoolManager has state variables, find one
642        let pm = find_node_by_id(sources, 1767).unwrap();
643        if let Some(nodes) = pm.get("nodes").and_then(|v| v.as_array()) {
644            for node in nodes {
645                if node.get("nodeType").and_then(|v| v.as_str()) == Some("VariableDeclaration") {
646                    let sig = build_function_signature(node);
647                    assert!(sig.is_some());
648                    break;
649                }
650            }
651        }
652    }
653
654    #[test]
655    fn test_pool_manager_has_documentation() {
656        let ast = load_test_ast();
657        let sources = ast.get("sources").unwrap();
658        // Owned contract (id=59) has NatSpec
659        let node = find_node_by_id(sources, 59).unwrap();
660        let doc = extract_documentation(node).unwrap();
661        assert!(doc.contains("@notice"));
662    }
663
664    #[test]
665    fn test_format_parameters_empty() {
666        let result = format_parameters(None);
667        assert_eq!(result, "");
668    }
669
670    #[test]
671    fn test_format_parameters_with_data() {
672        let params: Value = serde_json::json!({
673            "parameters": [
674                {
675                    "name": "key",
676                    "typeDescriptions": { "typeString": "struct PoolKey" },
677                    "storageLocation": "memory"
678                },
679                {
680                    "name": "sqrtPriceX96",
681                    "typeDescriptions": { "typeString": "uint160" },
682                    "storageLocation": "default"
683                }
684            ]
685        });
686        let result = format_parameters(Some(&params));
687        assert!(result.contains("struct PoolKey memory key"));
688        assert!(result.contains("uint160 sqrtPriceX96"));
689    }
690
691    // --- Selector tests ---
692
693    #[test]
694    fn test_extract_selector_function() {
695        let ast = load_test_ast();
696        let sources = ast.get("sources").unwrap();
697        // PoolManager.swap (id=1167) has functionSelector "f3cd914c"
698        let node = find_node_by_id(sources, 1167).unwrap();
699        let (selector, kind) = extract_selector(node).unwrap();
700        assert_eq!(selector, "f3cd914c");
701        assert_eq!(kind, "function");
702    }
703
704    #[test]
705    fn test_extract_selector_error() {
706        let ast = load_test_ast();
707        let sources = ast.get("sources").unwrap();
708        // DelegateCallNotAllowed (id=508) has errorSelector
709        let node = find_node_by_id(sources, 508).unwrap();
710        let (selector, kind) = extract_selector(node).unwrap();
711        assert_eq!(selector, "0d89438e");
712        assert_eq!(kind, "error");
713    }
714
715    #[test]
716    fn test_extract_selector_event() {
717        let ast = load_test_ast();
718        let sources = ast.get("sources").unwrap();
719        // OwnershipTransferred (id=8) has eventSelector
720        let node = find_node_by_id(sources, 8).unwrap();
721        let (selector, kind) = extract_selector(node).unwrap();
722        assert!(selector.len() == 64); // 32-byte keccak hash
723        assert_eq!(kind, "event");
724    }
725
726    #[test]
727    fn test_extract_selector_public_variable() {
728        let ast = load_test_ast();
729        let sources = ast.get("sources").unwrap();
730        // owner (id=10) is public, has functionSelector
731        let node = find_node_by_id(sources, 10).unwrap();
732        let (selector, kind) = extract_selector(node).unwrap();
733        assert_eq!(selector, "8da5cb5b");
734        assert_eq!(kind, "function");
735    }
736
737    #[test]
738    fn test_extract_selector_internal_function_none() {
739        let ast = load_test_ast();
740        let sources = ast.get("sources").unwrap();
741        // Pool.swap (id=5960) is internal, no selector
742        let node = find_node_by_id(sources, 5960).unwrap();
743        assert!(extract_selector(node).is_none());
744    }
745
746    // --- @inheritdoc resolution tests ---
747
748    #[test]
749    fn test_resolve_inheritdoc_swap() {
750        let ast = load_test_ast();
751        let sources = ast.get("sources").unwrap();
752        // PoolManager.swap (id=1167) has "@inheritdoc IPoolManager"
753        let decl = find_node_by_id(sources, 1167).unwrap();
754        let doc_text = extract_documentation(decl).unwrap();
755        assert!(doc_text.contains("@inheritdoc"));
756
757        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
758        assert!(resolved.contains("@notice"));
759        assert!(resolved.contains("Swap against the given pool"));
760    }
761
762    #[test]
763    fn test_resolve_inheritdoc_initialize() {
764        let ast = load_test_ast();
765        let sources = ast.get("sources").unwrap();
766        // PoolManager.initialize (id=881) has "@inheritdoc IPoolManager"
767        let decl = find_node_by_id(sources, 881).unwrap();
768        let doc_text = extract_documentation(decl).unwrap();
769
770        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
771        assert!(resolved.contains("Initialize the state"));
772        assert!(resolved.contains("@param key"));
773    }
774
775    #[test]
776    fn test_resolve_inheritdoc_extsload_overload() {
777        let ast = load_test_ast();
778        let sources = ast.get("sources").unwrap();
779
780        // extsload(bytes32) — id=442, selector "1e2eaeaf"
781        let decl = find_node_by_id(sources, 442).unwrap();
782        let doc_text = extract_documentation(decl).unwrap();
783        let resolved = resolve_inheritdoc(sources, decl, &doc_text).unwrap();
784        assert!(resolved.contains("granular pool state"));
785        // Should match the single-slot overload doc
786        assert!(resolved.contains("@param slot"));
787
788        // extsload(bytes32, uint256) — id=455, selector "35fd631a"
789        let decl2 = find_node_by_id(sources, 455).unwrap();
790        let doc_text2 = extract_documentation(decl2).unwrap();
791        let resolved2 = resolve_inheritdoc(sources, decl2, &doc_text2).unwrap();
792        assert!(resolved2.contains("@param startSlot"));
793
794        // extsload(bytes32[]) — id=467, selector "dbd035ff"
795        let decl3 = find_node_by_id(sources, 467).unwrap();
796        let doc_text3 = extract_documentation(decl3).unwrap();
797        let resolved3 = resolve_inheritdoc(sources, decl3, &doc_text3).unwrap();
798        assert!(resolved3.contains("sparse pool state"));
799    }
800
801    #[test]
802    fn test_resolve_inheritdoc_formats_in_hover() {
803        let ast = load_test_ast();
804        let sources = ast.get("sources").unwrap();
805        // PoolManager.swap with @inheritdoc — verify format_natspec resolves it
806        let decl = find_node_by_id(sources, 1167).unwrap();
807        let doc_text = extract_documentation(decl).unwrap();
808        let inherited = resolve_inheritdoc(sources, decl, &doc_text);
809        let formatted = format_natspec(&doc_text, inherited.as_deref());
810        // Should have the resolved content, not "@inheritdoc"
811        assert!(!formatted.contains("@inheritdoc"));
812        assert!(formatted.contains("Swap against the given pool"));
813        assert!(formatted.contains("**Parameters:**"));
814    }
815}