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