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