Skip to main content

solidity_language_server/
inlay_hints.rs

1use crate::gas;
2use crate::goto::CachedBuild;
3use crate::types::SourceLoc;
4use serde_json::Value;
5use std::collections::HashMap;
6use tower_lsp::lsp_types::*;
7use tree_sitter::{Node, Parser};
8
9/// Where to place gas inlay hints on function definitions.
10/// Contracts always use the opening brace.
11#[allow(dead_code)]
12enum FnGasHintPosition {
13    /// Show after the opening `{` brace.
14    Opening,
15    /// Show after the closing `}` brace.
16    Closing,
17}
18
19/// Change this to switch function gas hint placement.
20const FN_GAS_HINT_POSITION: FnGasHintPosition = FnGasHintPosition::Closing;
21
22/// Parameter info resolved from the AST for a callable.
23#[derive(Debug, Clone)]
24struct ParamInfo {
25    /// Parameter names from the declaration.
26    names: Vec<String>,
27    /// Number of leading params to skip (1 for using-for library calls).
28    skip: usize,
29}
30
31/// Call-site info extracted from the AST, keyed by source byte offset.
32#[derive(Debug, Clone)]
33struct CallSite {
34    /// The resolved parameter info for this specific call.
35    info: ParamInfo,
36    /// Function/event name (for matching with tree-sitter).
37    name: String,
38    /// The AST node id of the called function/event declaration (for DocIndex lookup).
39    decl_id: u64,
40}
41
42/// Resolved callsite info returned to hover for param doc lookup.
43#[derive(Debug, Clone)]
44pub struct ResolvedCallSite {
45    /// The parameter name at the given argument index.
46    pub param_name: String,
47    /// The AST node id of the called function/event declaration.
48    pub decl_id: u64,
49}
50
51/// Both lookup strategies: exact byte-offset match and (name, arg_count) fallback.
52/// Built once per file when the AST is cached, reused on every inlay hint request.
53#[derive(Debug, Clone)]
54pub struct HintLookup {
55    /// Primary: byte_offset → CallSite (exact match when AST offsets are fresh).
56    by_offset: HashMap<usize, CallSite>,
57    /// Fallback: (name, arg_count) → CallSite (works even with stale offsets).
58    by_name: HashMap<(String, usize), CallSite>,
59}
60
61impl HintLookup {
62    /// Resolve callsite parameter info for hover.
63    ///
64    /// Given a call's byte offset (from tree-sitter), the function name,
65    /// the argument count, and the 0-based argument index, returns a
66    /// `ResolvedCallSite` with the parameter name and declaration id.
67    pub fn resolve_callsite_param(
68        &self,
69        call_offset: usize,
70        func_name: &str,
71        arg_count: usize,
72        arg_index: usize,
73    ) -> Option<ResolvedCallSite> {
74        let site = lookup_call_site(self, call_offset, func_name, arg_count)?;
75        let param_idx = arg_index + site.info.skip;
76        if param_idx >= site.info.names.len() {
77            return None;
78        }
79        let param_name = &site.info.names[param_idx];
80        if param_name.is_empty() {
81            return None;
82        }
83        Some(ResolvedCallSite {
84            param_name: param_name.clone(),
85            decl_id: site.decl_id,
86        })
87    }
88}
89
90/// Pre-computed hint lookups for all files, keyed by absolutePath.
91/// Built once in `CachedBuild::new()`, reused on every inlay hint request.
92pub type HintIndex = HashMap<String, HintLookup>;
93
94/// Build the hint index for all files from the AST sources.
95/// Called once in `CachedBuild::new()`.
96pub fn build_hint_index(sources: &Value) -> HintIndex {
97    let id_index = build_id_index(sources);
98    let mut hint_index = HashMap::new();
99
100    if let Some(obj) = sources.as_object() {
101        for (_, source_data) in obj {
102            if let Some(ast) = source_data.get("ast")
103                && let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str())
104            {
105                let lookup = build_hint_lookup(ast, &id_index);
106                hint_index.insert(abs_path.to_string(), lookup);
107            }
108        }
109    }
110
111    hint_index
112}
113
114/// Generate inlay hints for a given range of source.
115///
116/// Uses tree-sitter on the **live buffer** for argument positions (so hints
117/// follow edits in real time) and the pre-cached hint index for semantic
118/// info (parameter names via `referencedDeclaration`).
119pub fn inlay_hints(
120    build: &CachedBuild,
121    uri: &Url,
122    range: Range,
123    live_source: &[u8],
124) -> Vec<InlayHint> {
125    let path_str = match uri.to_file_path() {
126        Ok(p) => p.to_str().unwrap_or("").to_string(),
127        Err(_) => return vec![],
128    };
129
130    let abs = match build
131        .path_to_abs
132        .iter()
133        .find(|(k, _)| path_str.ends_with(k.as_str()))
134    {
135        Some((_, v)) => v.clone(),
136        None => return vec![],
137    };
138
139    // Use the pre-cached hint lookup for this file
140    let lookup = match build.hint_index.get(&abs) {
141        Some(l) => l,
142        None => return vec![],
143    };
144
145    // Walk tree-sitter on the live buffer for real-time argument positions
146    let source_str = String::from_utf8_lossy(live_source);
147    let tree = match ts_parse(&source_str) {
148        Some(t) => t,
149        None => return vec![],
150    };
151
152    let mut hints = Vec::new();
153    collect_ts_hints(tree.root_node(), &source_str, &range, lookup, &mut hints);
154
155    // Gas inlay hints: use tree-sitter positions (tracks live buffer)
156    if !build.gas_index.is_empty() {
157        collect_ts_gas_hints(
158            tree.root_node(),
159            &source_str,
160            &range,
161            &build.gas_index,
162            &abs,
163            &mut hints,
164        );
165    }
166
167    hints
168}
169
170/// Build a flat node-id → AST-node index from all sources.
171/// This is O(total_nodes) and replaces the O(calls × total_nodes)
172/// `find_declaration` that walked the entire AST per lookup.
173fn build_id_index(sources: &Value) -> HashMap<u64, &Value> {
174    let mut index = HashMap::new();
175    if let Some(obj) = sources.as_object() {
176        for (_, source_data) in obj {
177            if let Some(ast) = source_data.get("ast") {
178                index_node_ids(ast, &mut index);
179            }
180        }
181    }
182    index
183}
184
185/// Recursively index all nodes that have an `id` field.
186fn index_node_ids<'a>(node: &'a Value, index: &mut HashMap<u64, &'a Value>) {
187    if let Some(id) = node.get("id").and_then(|v| v.as_u64()) {
188        index.insert(id, node);
189    }
190    for key in crate::goto::CHILD_KEYS {
191        if let Some(child) = node.get(*key) {
192            if child.is_array() {
193                if let Some(arr) = child.as_array() {
194                    for item in arr {
195                        index_node_ids(item, index);
196                    }
197                }
198            } else if child.is_object() {
199                index_node_ids(child, index);
200            }
201        }
202    }
203    if let Some(nodes) = node.get("nodes").and_then(|v| v.as_array()) {
204        for child in nodes {
205            index_node_ids(child, index);
206        }
207    }
208}
209
210/// Parse Solidity source with tree-sitter.
211pub fn ts_parse(source: &str) -> Option<tree_sitter::Tree> {
212    let mut parser = Parser::new();
213    parser
214        .set_language(&tree_sitter_solidity::LANGUAGE.into())
215        .expect("failed to load Solidity grammar");
216    parser.parse(source, None)
217}
218
219/// Build both lookup strategies from the AST.
220fn build_hint_lookup(file_ast: &Value, id_index: &HashMap<u64, &Value>) -> HintLookup {
221    let mut lookup = HintLookup {
222        by_offset: HashMap::new(),
223        by_name: HashMap::new(),
224    };
225    collect_ast_calls(file_ast, id_index, &mut lookup);
226    lookup
227}
228
229/// Parse the `src` field ("offset:length:fileId") and return the byte offset.
230fn parse_src_offset(node: &Value) -> Option<usize> {
231    let src = node.get("src").and_then(|v| v.as_str())?;
232    SourceLoc::parse(src).map(|loc| loc.offset)
233}
234
235/// Recursively walk AST nodes collecting call site info.
236fn collect_ast_calls(node: &Value, id_index: &HashMap<u64, &Value>, lookup: &mut HintLookup) {
237    let node_type = node.get("nodeType").and_then(|v| v.as_str()).unwrap_or("");
238
239    match node_type {
240        "FunctionCall" => {
241            if let Some(call_info) = extract_call_info(node, id_index) {
242                let arg_count = node
243                    .get("arguments")
244                    .and_then(|v| v.as_array())
245                    .map(|a| a.len())
246                    .unwrap_or(0);
247                let site = CallSite {
248                    info: ParamInfo {
249                        names: call_info.params.names,
250                        skip: call_info.params.skip,
251                    },
252                    name: call_info.name,
253                    decl_id: call_info.decl_id,
254                };
255                if let Some(offset) = parse_src_offset(node) {
256                    lookup.by_offset.insert(offset, site.clone());
257                }
258
259                lookup
260                    .by_name
261                    .entry((site.name.clone(), arg_count))
262                    .or_insert(site);
263            }
264        }
265        "EmitStatement" => {
266            if let Some(event_call) = node.get("eventCall")
267                && let Some(call_info) = extract_call_info(event_call, id_index)
268            {
269                let arg_count = event_call
270                    .get("arguments")
271                    .and_then(|v| v.as_array())
272                    .map(|a| a.len())
273                    .unwrap_or(0);
274                let site = CallSite {
275                    info: ParamInfo {
276                        names: call_info.params.names,
277                        skip: call_info.params.skip,
278                    },
279                    name: call_info.name,
280                    decl_id: call_info.decl_id,
281                };
282                if let Some(offset) = parse_src_offset(node) {
283                    lookup.by_offset.insert(offset, site.clone());
284                }
285
286                lookup
287                    .by_name
288                    .entry((site.name.clone(), arg_count))
289                    .or_insert(site);
290            }
291        }
292        _ => {}
293    }
294
295    // Recurse into children
296    for key in crate::goto::CHILD_KEYS {
297        if let Some(child) = node.get(*key) {
298            if child.is_array() {
299                if let Some(arr) = child.as_array() {
300                    for item in arr {
301                        collect_ast_calls(item, id_index, lookup);
302                    }
303                }
304            } else if child.is_object() {
305                collect_ast_calls(child, id_index, lookup);
306            }
307        }
308    }
309}
310
311/// Resolved call info including the declaration id of the called function/event.
312struct CallInfo {
313    /// Function/event name.
314    name: String,
315    /// Parameter names and skip count.
316    params: ParamInfo,
317    /// The AST node id of the referenced declaration (for DocIndex lookup).
318    decl_id: u64,
319}
320
321/// Extract function/event name and parameter info from an AST FunctionCall node.
322fn extract_call_info(node: &Value, id_index: &HashMap<u64, &Value>) -> Option<CallInfo> {
323    let args = node.get("arguments")?.as_array()?;
324    if args.is_empty() {
325        return None;
326    }
327
328    // Skip struct constructors with named args
329    let kind = node.get("kind").and_then(|v| v.as_str()).unwrap_or("");
330    if kind == "structConstructorCall"
331        && node
332            .get("names")
333            .and_then(|v| v.as_array())
334            .is_some_and(|n| !n.is_empty())
335    {
336        return None;
337    }
338
339    let expr = node.get("expression")?;
340    let decl_id = expr.get("referencedDeclaration").and_then(|v| v.as_u64())?;
341
342    let decl_node = id_index.get(&decl_id)?;
343    let names = get_parameter_names(decl_node)?;
344
345    // Extract the function name from the expression
346    let func_name = extract_function_name(expr)?;
347
348    // Using-for library calls pass the receiver as the implicit first param,
349    // so the AST has one fewer arg than the declaration has params.
350    // Direct library calls (Transaction.addTax) and struct constructors
351    // pass all params explicitly — arg count matches param count.
352    let arg_count = node
353        .get("arguments")
354        .and_then(|v| v.as_array())
355        .map(|a| a.len())
356        .unwrap_or(0);
357    let skip = if is_member_access(expr) && arg_count < names.len() {
358        1
359    } else {
360        0
361    };
362
363    Some(CallInfo {
364        name: func_name,
365        params: ParamInfo { names, skip },
366        decl_id,
367    })
368}
369
370/// Extract the function/event name from an AST expression node.
371fn extract_function_name(expr: &Value) -> Option<String> {
372    let node_type = expr.get("nodeType").and_then(|v| v.as_str())?;
373    match node_type {
374        "Identifier" => expr.get("name").and_then(|v| v.as_str()).map(String::from),
375        "MemberAccess" => expr
376            .get("memberName")
377            .and_then(|v| v.as_str())
378            .map(String::from),
379        _ => None,
380    }
381}
382
383/// Check if expression is a MemberAccess (potential using-for call).
384fn is_member_access(expr: &Value) -> bool {
385    expr.get("nodeType")
386        .and_then(|v| v.as_str())
387        .is_some_and(|t| t == "MemberAccess")
388}
389
390// ── Tree-sitter walk ──────────────────────────────────────────────────────
391
392/// Look up call site info: try exact byte-offset match first, fall back to (name, arg_count).
393fn lookup_call_site<'a>(
394    lookup: &'a HintLookup,
395    offset: usize,
396    name: &str,
397    arg_count: usize,
398) -> Option<&'a CallSite> {
399    // Exact match by byte offset (works when AST is fresh)
400    if let Some(site) = lookup.by_offset.get(&offset)
401        && site.name == name
402    {
403        return Some(site);
404    }
405    // Fallback by (name, arg_count) (works with stale offsets after edits)
406    lookup.by_name.get(&(name.to_string(), arg_count))
407}
408
409/// Recursively walk tree-sitter nodes, emitting hints for calls in the visible range.
410fn collect_ts_hints(
411    node: Node,
412    source: &str,
413    range: &Range,
414    lookup: &HintLookup,
415    hints: &mut Vec<InlayHint>,
416) {
417    // Quick range check — skip nodes entirely outside the visible range
418    let node_start = node.start_position();
419    let node_end = node.end_position();
420    if (node_end.row as u32) < range.start.line || (node_start.row as u32) > range.end.line {
421        return;
422    }
423
424    match node.kind() {
425        "call_expression" => {
426            emit_call_hints(node, source, lookup, hints);
427        }
428        "emit_statement" => {
429            emit_emit_hints(node, source, lookup, hints);
430        }
431        _ => {}
432    }
433
434    // Recurse into children
435    let mut cursor = node.walk();
436    for child in node.children(&mut cursor) {
437        collect_ts_hints(child, source, range, lookup, hints);
438    }
439}
440
441/// Emit parameter hints for a `call_expression` node.
442fn emit_call_hints(node: Node, source: &str, lookup: &HintLookup, hints: &mut Vec<InlayHint>) {
443    let func_name = match ts_call_function_name(node, source) {
444        Some(n) => n,
445        None => return,
446    };
447
448    let args = ts_call_arguments(node);
449    if args.is_empty() {
450        return;
451    }
452
453    let site = match lookup_call_site(lookup, node.start_byte(), func_name, args.len()) {
454        Some(s) => s,
455        None => return,
456    };
457
458    emit_param_hints(&args, &site.info, hints);
459}
460
461/// Emit parameter hints for an `emit_statement` node.
462fn emit_emit_hints(node: Node, source: &str, lookup: &HintLookup, hints: &mut Vec<InlayHint>) {
463    let event_name = match ts_emit_event_name(node, source) {
464        Some(n) => n,
465        None => return,
466    };
467
468    let args = ts_call_arguments(node);
469    if args.is_empty() {
470        return;
471    }
472
473    let site = match lookup_call_site(lookup, node.start_byte(), event_name, args.len()) {
474        Some(s) => s,
475        None => return,
476    };
477
478    emit_param_hints(&args, &site.info, hints);
479}
480
481/// Emit InlayHint items for each argument, using tree-sitter positions.
482fn emit_param_hints(args: &[Node], info: &ParamInfo, hints: &mut Vec<InlayHint>) {
483    for (i, arg) in args.iter().enumerate() {
484        let pi = i + info.skip;
485        if pi >= info.names.len() || info.names[pi].is_empty() {
486            continue;
487        }
488
489        let start = arg.start_position();
490        let position = Position::new(start.row as u32, start.column as u32);
491
492        hints.push(InlayHint {
493            position,
494            kind: Some(InlayHintKind::PARAMETER),
495            label: InlayHintLabel::String(format!("{}:", info.names[pi])),
496            text_edits: None,
497            tooltip: None,
498            padding_left: None,
499            padding_right: Some(true),
500            data: None,
501        });
502    }
503}
504
505// ── Tree-sitter helpers ───────────────────────────────────────────────────
506
507/// Get the function name from a `call_expression` node.
508///
509/// For `transfer(...)` → "transfer"
510/// For `PRICE.addTax(...)` → "addTax"
511fn ts_call_function_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
512    let func_expr = node.child_by_field_name("function")?;
513    // The expression wrapper has one named child
514    let inner = first_named_child(func_expr)?;
515    match inner.kind() {
516        "identifier" => Some(&source[inner.byte_range()]),
517        "member_expression" => {
518            let prop = inner.child_by_field_name("property")?;
519            Some(&source[prop.byte_range()])
520        }
521        _ => None,
522    }
523}
524
525/// Get the event name from an `emit_statement` node.
526fn ts_emit_event_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
527    let name_expr = node.child_by_field_name("name")?;
528    let inner = first_named_child(name_expr)?;
529    match inner.kind() {
530        "identifier" => Some(&source[inner.byte_range()]),
531        "member_expression" => {
532            let prop = inner.child_by_field_name("property")?;
533            Some(&source[prop.byte_range()])
534        }
535        _ => None,
536    }
537}
538
539/// Collect `call_argument` children from a node (works for both
540/// `call_expression` and `emit_statement` since `_call_arguments` is hidden).
541fn ts_call_arguments(node: Node) -> Vec<Node> {
542    let mut args = Vec::new();
543    let mut cursor = node.walk();
544    for child in node.children(&mut cursor) {
545        if child.kind() == "call_argument" {
546            args.push(child);
547        }
548    }
549    args
550}
551
552/// Get the first named child of a node.
553fn first_named_child(node: Node) -> Option<Node> {
554    let mut cursor = node.walk();
555    node.children(&mut cursor).find(|c| c.is_named())
556}
557
558/// Result of finding the enclosing call site at a byte position via tree-sitter.
559pub struct TsCallContext<'a> {
560    /// The function/event name.
561    pub name: &'a str,
562    /// 0-based index of the argument the cursor is on.
563    pub arg_index: usize,
564    /// Total number of arguments in the call.
565    pub arg_count: usize,
566    /// Start byte of the call_expression/emit_statement node (for HintIndex lookup).
567    pub call_start_byte: usize,
568}
569
570/// Find the enclosing `call_expression` or `emit_statement` for a given byte
571/// position in the live buffer using tree-sitter.
572///
573/// Returns `None` if the position is not inside a call argument.
574pub fn ts_find_call_at_byte<'a>(
575    root: tree_sitter::Node<'a>,
576    source: &'a str,
577    byte_pos: usize,
578) -> Option<TsCallContext<'a>> {
579    // Find the deepest node containing byte_pos
580    let mut node = root.descendant_for_byte_range(byte_pos, byte_pos)?;
581
582    // Walk up the tree to find a call_argument parent
583    loop {
584        if node.kind() == "call_argument" {
585            break;
586        }
587        node = node.parent()?;
588    }
589
590    // The call_argument's parent should be the call_expression or emit_statement
591    let call_node = node.parent()?;
592    let args = ts_call_arguments(call_node);
593    let arg_index = args.iter().position(|a| a.id() == node.id())?;
594
595    match call_node.kind() {
596        "call_expression" => {
597            let name = ts_call_function_name(call_node, source)?;
598            Some(TsCallContext {
599                name,
600                arg_index,
601                arg_count: args.len(),
602                call_start_byte: call_node.start_byte(),
603            })
604        }
605        "emit_statement" => {
606            let name = ts_emit_event_name(call_node, source)?;
607            Some(TsCallContext {
608                name,
609                arg_index,
610                arg_count: args.len(),
611                call_start_byte: call_node.start_byte(),
612            })
613        }
614        _ => None,
615    }
616}
617
618// ── Gas inlay hints (tree-sitter based) ──────────────────────────────────
619
620/// Walk tree-sitter nodes for function/contract definitions, emitting gas
621/// cost hints using **live buffer positions** so they track edits in real time.
622fn collect_ts_gas_hints(
623    node: Node,
624    source: &str,
625    range: &Range,
626    gas_index: &gas::GasIndex,
627    abs_path: &str,
628    hints: &mut Vec<InlayHint>,
629) {
630    let node_start = node.start_position();
631    let node_end = node.end_position();
632    if (node_end.row as u32) < range.start.line || (node_start.row as u32) > range.end.line {
633        return;
634    }
635
636    match node.kind() {
637        "function_definition" => {
638            if let Some(hint) = ts_gas_hint_for_function(node, source, range, gas_index, abs_path) {
639                hints.push(hint);
640            }
641        }
642        "contract_declaration" | "library_declaration" | "interface_declaration" => {
643            if let Some(hint) = ts_gas_hint_for_contract(node, source, range, gas_index, abs_path) {
644                hints.push(hint);
645            }
646        }
647        _ => {}
648    }
649
650    let mut cursor = node.walk();
651    for child in node.children(&mut cursor) {
652        collect_ts_gas_hints(child, source, range, gas_index, abs_path, hints);
653    }
654}
655
656/// Extract the identifier (name) child from a tree-sitter node.
657fn ts_node_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
658    let mut cursor = node.walk();
659    node.children(&mut cursor)
660        .find(|c| c.kind() == "identifier" && c.is_named())
661        .map(|c| &source[c.byte_range()])
662}
663
664/// Find the opening `{` position of a body node.
665fn ts_body_open_brace(node: Node, body_kind: &str) -> Option<Position> {
666    let mut cursor = node.walk();
667    let body = node.children(&mut cursor).find(|c| c.kind() == body_kind)?;
668    let start = body.start_position();
669    Some(Position::new(start.row as u32, start.column as u32))
670}
671
672/// Find the closing `}` position of a body node.
673fn ts_body_close_brace(node: Node, body_kind: &str) -> Option<Position> {
674    let mut cursor = node.walk();
675    let body = node.children(&mut cursor).find(|c| c.kind() == body_kind)?;
676    let end = body.end_position();
677    // end_position points one past the `}`, so column - 1
678    Some(Position::new(
679        end.row as u32,
680        end.column.saturating_sub(1) as u32,
681    ))
682}
683
684/// Find the enclosing contract name for a function_definition node.
685fn ts_enclosing_contract_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
686    let mut parent = node.parent();
687    while let Some(p) = parent {
688        if p.kind() == "contract_declaration"
689            || p.kind() == "library_declaration"
690            || p.kind() == "interface_declaration"
691        {
692            return ts_node_name(p, source);
693        }
694        parent = p.parent();
695    }
696    None
697}
698
699/// Find the gas index key matching a file path and contract name.
700fn find_gas_key<'a>(
701    gas_index: &'a gas::GasIndex,
702    abs_path: &str,
703    contract_name: &str,
704) -> Option<&'a str> {
705    let exact = format!("{abs_path}:{contract_name}");
706    if gas_index.contains_key(&exact) {
707        return Some(gas_index.get_key_value(&exact)?.0.as_str());
708    }
709    let file_name = std::path::Path::new(abs_path).file_name()?.to_str()?;
710    let suffix = format!("{file_name}:{contract_name}");
711    gas_index
712        .keys()
713        .find(|k| k.ends_with(&suffix))
714        .map(|k| k.as_str())
715}
716
717/// Create a gas inlay hint for a function definition using tree-sitter positions.
718fn ts_gas_hint_for_function(
719    node: Node,
720    source: &str,
721    range: &Range,
722    gas_index: &gas::GasIndex,
723    abs_path: &str,
724) -> Option<InlayHint> {
725    let fn_name = ts_node_name(node, source)?;
726    let contract_name = ts_enclosing_contract_name(node, source)?;
727    let gas_key = find_gas_key(gas_index, abs_path, contract_name)?;
728    let contract_gas = gas_index.get(gas_key)?;
729
730    let prefix = format!("{fn_name}(");
731    let cost = contract_gas
732        .external_by_sig
733        .iter()
734        .find(|(sig, _)| sig.as_str().starts_with(&prefix))
735        .map(|(_, c)| c.as_str())
736        .or_else(|| {
737            contract_gas
738                .internal
739                .iter()
740                .find(|(sig, _)| sig.starts_with(&prefix))
741                .map(|(_, c)| c.as_str())
742        })?;
743
744    // Position: opening or closing brace based on FN_GAS_HINT_POSITION
745    let (brace_pos, offset) = match FN_GAS_HINT_POSITION {
746        FnGasHintPosition::Opening => (ts_body_open_brace(node, "function_body")?, 1),
747        FnGasHintPosition::Closing => (ts_body_close_brace(node, "function_body")?, 1),
748    };
749    if brace_pos.line < range.start.line || brace_pos.line > range.end.line {
750        return None;
751    }
752
753    Some(InlayHint {
754        position: Position::new(brace_pos.line, brace_pos.character + offset),
755        kind: Some(InlayHintKind::TYPE),
756        label: InlayHintLabel::String(format!("{} {}", gas::GAS_ICON, gas::format_gas(cost))),
757        text_edits: None,
758        tooltip: Some(InlayHintTooltip::String("Estimated gas cost".to_string())),
759        padding_left: Some(true),
760        padding_right: None,
761        data: None,
762    })
763}
764
765/// Create a gas inlay hint for a contract/library/interface definition.
766/// Always uses the opening brace.
767fn ts_gas_hint_for_contract(
768    node: Node,
769    source: &str,
770    range: &Range,
771    gas_index: &gas::GasIndex,
772    abs_path: &str,
773) -> Option<InlayHint> {
774    let contract_name = ts_node_name(node, source)?;
775    let gas_key = find_gas_key(gas_index, abs_path, contract_name)?;
776    let contract_gas = gas_index.get(gas_key)?;
777
778    // Prefer totalCost, but when it's "infinite" show codeDepositCost instead
779    let display_cost = match contract_gas.creation.get("totalCost").map(|s| s.as_str()) {
780        Some("infinite") | None => contract_gas
781            .creation
782            .get("codeDepositCost")
783            .map(|s| s.as_str())?,
784        Some(total) => total,
785    };
786
787    let brace_pos = ts_body_open_brace(node, "contract_body")?;
788    if brace_pos.line < range.start.line || brace_pos.line > range.end.line {
789        return None;
790    }
791
792    Some(InlayHint {
793        position: Position::new(brace_pos.line, brace_pos.character + 1),
794        kind: Some(InlayHintKind::TYPE),
795        label: InlayHintLabel::String(format!(
796            "{} {} ",
797            gas::GAS_ICON,
798            gas::format_gas(display_cost)
799        )),
800        text_edits: None,
801        tooltip: Some(InlayHintTooltip::String(format!(
802            "Deploy cost — code deposit: {}, execution: {}",
803            gas::format_gas(
804                contract_gas
805                    .creation
806                    .get("codeDepositCost")
807                    .map(|s| s.as_str())
808                    .unwrap_or("?")
809            ),
810            gas::format_gas(
811                contract_gas
812                    .creation
813                    .get("executionCost")
814                    .map(|s| s.as_str())
815                    .unwrap_or("?")
816            )
817        ))),
818        padding_left: Some(true),
819        padding_right: None,
820        data: None,
821    })
822}
823
824// ── AST helpers ──────────────────────────────────────────────────────────
825
826/// Extract parameter names from a function/event/error/struct declaration.
827fn get_parameter_names(decl: &Value) -> Option<Vec<String>> {
828    // Functions, events, errors: parameters.parameters[]
829    // Structs: members[]
830    let items = decl
831        .get("parameters")
832        .and_then(|p| p.get("parameters"))
833        .and_then(|v| v.as_array())
834        .or_else(|| decl.get("members").and_then(|v| v.as_array()))?;
835    Some(
836        items
837            .iter()
838            .map(|p| {
839                p.get("name")
840                    .and_then(|v| v.as_str())
841                    .unwrap_or("")
842                    .to_string()
843            })
844            .collect(),
845    )
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    #[test]
853    fn test_get_parameter_names() {
854        let decl: Value = serde_json::json!({
855            "parameters": {
856                "parameters": [
857                    {"name": "to", "nodeType": "VariableDeclaration"},
858                    {"name": "amount", "nodeType": "VariableDeclaration"},
859                ]
860            }
861        });
862        let names = get_parameter_names(&decl).unwrap();
863        assert_eq!(names, vec!["to", "amount"]);
864    }
865
866    #[test]
867    fn test_ts_call_function_name() {
868        let source = r#"
869contract Foo {
870    function bar(uint x) public {}
871    function test() public {
872        bar(42);
873    }
874}
875"#;
876        let tree = ts_parse(source).unwrap();
877        let mut found = Vec::new();
878        find_calls(tree.root_node(), source, &mut found);
879        assert_eq!(found.len(), 1);
880        assert_eq!(found[0], "bar");
881    }
882
883    #[test]
884    fn test_ts_member_call_name() {
885        let source = r#"
886contract Foo {
887    function test() public {
888        PRICE.addTax(TAX, TAX_BASE);
889    }
890}
891"#;
892        let tree = ts_parse(source).unwrap();
893        let mut found = Vec::new();
894        find_calls(tree.root_node(), source, &mut found);
895        assert_eq!(found.len(), 1);
896        assert_eq!(found[0], "addTax");
897    }
898
899    #[test]
900    fn test_ts_emit_event_name() {
901        let source = r#"
902contract Foo {
903    event Purchase(address buyer, uint256 price);
904    function test() public {
905        emit Purchase(msg.sender, 100);
906    }
907}
908"#;
909        let tree = ts_parse(source).unwrap();
910        let mut found = Vec::new();
911        find_emits(tree.root_node(), source, &mut found);
912        assert_eq!(found.len(), 1);
913        assert_eq!(found[0], "Purchase");
914    }
915
916    #[test]
917    fn test_ts_call_arguments_count() {
918        let source = r#"
919contract Foo {
920    function bar(uint x, uint y) public {}
921    function test() public {
922        bar(1, 2);
923    }
924}
925"#;
926        let tree = ts_parse(source).unwrap();
927        let mut arg_counts = Vec::new();
928        find_call_arg_counts(tree.root_node(), &mut arg_counts);
929        assert_eq!(arg_counts, vec![2]);
930    }
931
932    #[test]
933    fn test_ts_argument_positions_follow_live_buffer() {
934        // Simulate an edited buffer with extra whitespace
935        let source = r#"
936contract Foo {
937    function bar(uint x, uint y) public {}
938    function test() public {
939        bar(
940            1,
941            2
942        );
943    }
944}
945"#;
946        let tree = ts_parse(source).unwrap();
947        let mut positions = Vec::new();
948        find_arg_positions(tree.root_node(), &mut positions);
949        // First arg "1" is on line 5 (0-indexed), second "2" on line 6
950        assert_eq!(positions.len(), 2);
951        assert_eq!(positions[0].0, 5); // row of "1"
952        assert_eq!(positions[1].0, 6); // row of "2"
953    }
954
955    // Test helpers
956
957    fn find_calls<'a>(node: Node<'a>, source: &'a str, out: &mut Vec<&'a str>) {
958        if node.kind() == "call_expression"
959            && let Some(name) = ts_call_function_name(node, source)
960        {
961            out.push(name);
962        }
963        let mut cursor = node.walk();
964        for child in node.children(&mut cursor) {
965            find_calls(child, source, out);
966        }
967    }
968
969    fn find_emits<'a>(node: Node<'a>, source: &'a str, out: &mut Vec<&'a str>) {
970        if node.kind() == "emit_statement"
971            && let Some(name) = ts_emit_event_name(node, source)
972        {
973            out.push(name);
974        }
975        let mut cursor = node.walk();
976        for child in node.children(&mut cursor) {
977            find_emits(child, source, out);
978        }
979    }
980
981    fn find_call_arg_counts(node: Node, out: &mut Vec<usize>) {
982        if node.kind() == "call_expression" {
983            out.push(ts_call_arguments(node).len());
984        }
985        let mut cursor = node.walk();
986        for child in node.children(&mut cursor) {
987            find_call_arg_counts(child, out);
988        }
989    }
990
991    fn find_arg_positions(node: Node, out: &mut Vec<(usize, usize)>) {
992        if node.kind() == "call_expression" {
993            for arg in ts_call_arguments(node) {
994                let p = arg.start_position();
995                out.push((p.row, p.column));
996            }
997        }
998        let mut cursor = node.walk();
999        for child in node.children(&mut cursor) {
1000            find_arg_positions(child, out);
1001        }
1002    }
1003
1004    #[test]
1005    fn test_ts_find_call_at_byte_first_arg() {
1006        let source = r#"
1007contract Foo {
1008    function bar(uint x, uint y) public {}
1009    function test() public {
1010        bar(42, 99);
1011    }
1012}
1013"#;
1014        let tree = ts_parse(source).unwrap();
1015        // "42" is the first argument — find its byte offset
1016        let pos_42 = source.find("42").unwrap();
1017        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_42).unwrap();
1018        assert_eq!(ctx.name, "bar");
1019        assert_eq!(ctx.arg_index, 0);
1020        assert_eq!(ctx.arg_count, 2);
1021    }
1022
1023    #[test]
1024    fn test_ts_find_call_at_byte_second_arg() {
1025        let source = r#"
1026contract Foo {
1027    function bar(uint x, uint y) public {}
1028    function test() public {
1029        bar(42, 99);
1030    }
1031}
1032"#;
1033        let tree = ts_parse(source).unwrap();
1034        let pos_99 = source.find("99").unwrap();
1035        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_99).unwrap();
1036        assert_eq!(ctx.name, "bar");
1037        assert_eq!(ctx.arg_index, 1);
1038        assert_eq!(ctx.arg_count, 2);
1039    }
1040
1041    #[test]
1042    fn test_ts_find_call_at_byte_outside_call_returns_none() {
1043        let source = r#"
1044contract Foo {
1045    function bar(uint x) public {}
1046    function test() public {
1047        uint z = 10;
1048        bar(42);
1049    }
1050}
1051"#;
1052        let tree = ts_parse(source).unwrap();
1053        // "10" is a local variable assignment, not a call argument
1054        let pos_10 = source.find("10").unwrap();
1055        assert!(ts_find_call_at_byte(tree.root_node(), source, pos_10).is_none());
1056    }
1057
1058    #[test]
1059    fn test_ts_find_call_at_byte_member_call() {
1060        let source = r#"
1061contract Foo {
1062    function test() public {
1063        PRICE.addTax(TAX, TAX_BASE);
1064    }
1065}
1066"#;
1067        let tree = ts_parse(source).unwrap();
1068        let pos_tax = source.find("TAX,").unwrap();
1069        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tax).unwrap();
1070        assert_eq!(ctx.name, "addTax");
1071        assert_eq!(ctx.arg_index, 0);
1072        assert_eq!(ctx.arg_count, 2);
1073    }
1074
1075    #[test]
1076    fn test_ts_find_call_at_byte_emit_statement() {
1077        let source = r#"
1078contract Foo {
1079    event Purchase(address buyer, uint256 price);
1080    function test() public {
1081        emit Purchase(msg.sender, 100);
1082    }
1083}
1084"#;
1085        let tree = ts_parse(source).unwrap();
1086        let pos_100 = source.find("100").unwrap();
1087        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_100).unwrap();
1088        assert_eq!(ctx.name, "Purchase");
1089        assert_eq!(ctx.arg_index, 1);
1090        assert_eq!(ctx.arg_count, 2);
1091    }
1092
1093    #[test]
1094    fn test_ts_find_call_at_byte_multiline() {
1095        let source = r#"
1096contract Foo {
1097    function bar(uint x, uint y, uint z) public {}
1098    function test() public {
1099        bar(
1100            1,
1101            2,
1102            3
1103        );
1104    }
1105}
1106"#;
1107        let tree = ts_parse(source).unwrap();
1108        // Find "2" — the second argument on its own line
1109        // Need to be careful since "2" appears in the source in multiple places
1110        let pos_2 = source.find("            2").unwrap() + 12; // skip whitespace
1111        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_2).unwrap();
1112        assert_eq!(ctx.name, "bar");
1113        assert_eq!(ctx.arg_index, 1);
1114        assert_eq!(ctx.arg_count, 3);
1115    }
1116
1117    #[test]
1118    fn test_resolve_callsite_param_basic() {
1119        // Build a HintLookup manually with a known call site
1120        let mut lookup = HintLookup {
1121            by_offset: HashMap::new(),
1122            by_name: HashMap::new(),
1123        };
1124        lookup.by_name.insert(
1125            ("transfer".to_string(), 2),
1126            CallSite {
1127                info: ParamInfo {
1128                    names: vec!["to".to_string(), "amount".to_string()],
1129                    skip: 0,
1130                },
1131                name: "transfer".to_string(),
1132                decl_id: 42,
1133            },
1134        );
1135
1136        // Resolve first argument
1137        let result = lookup.resolve_callsite_param(0, "transfer", 2, 0).unwrap();
1138        assert_eq!(result.param_name, "to");
1139        assert_eq!(result.decl_id, 42);
1140
1141        // Resolve second argument
1142        let result = lookup.resolve_callsite_param(0, "transfer", 2, 1).unwrap();
1143        assert_eq!(result.param_name, "amount");
1144        assert_eq!(result.decl_id, 42);
1145    }
1146
1147    #[test]
1148    fn test_resolve_callsite_param_with_skip() {
1149        // Simulate a using-for library call where skip=1
1150        let mut lookup = HintLookup {
1151            by_offset: HashMap::new(),
1152            by_name: HashMap::new(),
1153        };
1154        lookup.by_name.insert(
1155            ("addTax".to_string(), 2),
1156            CallSite {
1157                info: ParamInfo {
1158                    names: vec!["self".to_string(), "tax".to_string(), "base".to_string()],
1159                    skip: 1,
1160                },
1161                name: "addTax".to_string(),
1162                decl_id: 99,
1163            },
1164        );
1165
1166        // First arg maps to param index 1 (skip=1), which is "tax"
1167        let result = lookup.resolve_callsite_param(0, "addTax", 2, 0).unwrap();
1168        assert_eq!(result.param_name, "tax");
1169
1170        // Second arg maps to param index 2, which is "base"
1171        let result = lookup.resolve_callsite_param(0, "addTax", 2, 1).unwrap();
1172        assert_eq!(result.param_name, "base");
1173    }
1174
1175    #[test]
1176    fn test_resolve_callsite_param_out_of_bounds() {
1177        let mut lookup = HintLookup {
1178            by_offset: HashMap::new(),
1179            by_name: HashMap::new(),
1180        };
1181        lookup.by_name.insert(
1182            ("foo".to_string(), 1),
1183            CallSite {
1184                info: ParamInfo {
1185                    names: vec!["x".to_string()],
1186                    skip: 0,
1187                },
1188                name: "foo".to_string(),
1189                decl_id: 1,
1190            },
1191        );
1192
1193        // Arg index 1 is out of bounds for a single-param function
1194        assert!(lookup.resolve_callsite_param(0, "foo", 1, 1).is_none());
1195    }
1196
1197    #[test]
1198    fn test_resolve_callsite_param_unknown_function() {
1199        let lookup = HintLookup {
1200            by_offset: HashMap::new(),
1201            by_name: HashMap::new(),
1202        };
1203        assert!(lookup.resolve_callsite_param(0, "unknown", 1, 0).is_none());
1204    }
1205
1206    #[test]
1207    fn test_ts_find_call_at_byte_emit_member_access() {
1208        // Simulates: emit ModifyLiquidity(id, msg.sender, params.tickLower, ...);
1209        // Hovering on "tickLower" (the member name in params.tickLower) should
1210        // resolve to arg_index=2 of the ModifyLiquidity emit.
1211        let source = r#"
1212contract Foo {
1213    event ModifyLiquidity(uint id, address sender, int24 tickLower, int24 tickUpper);
1214    function test() public {
1215        emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper);
1216    }
1217}
1218"#;
1219        let tree = ts_parse(source).unwrap();
1220        // Find "tickLower" inside "params.tickLower" — the first occurrence after "params."
1221        let params_tick = source.find("params.tickLower,").unwrap();
1222        let tick_lower_pos = params_tick + "params.".len(); // points at "tickLower"
1223
1224        let ctx = ts_find_call_at_byte(tree.root_node(), source, tick_lower_pos).unwrap();
1225        assert_eq!(ctx.name, "ModifyLiquidity");
1226        assert_eq!(
1227            ctx.arg_index, 2,
1228            "params.tickLower is the 3rd argument (index 2)"
1229        );
1230        assert_eq!(ctx.arg_count, 4);
1231    }
1232
1233    #[test]
1234    fn test_ts_find_call_at_byte_member_access_on_property() {
1235        // Hovering on "sender" in "msg.sender" as an argument
1236        let source = r#"
1237contract Foo {
1238    event Transfer(address from, address to);
1239    function test() public {
1240        emit Transfer(msg.sender, addr);
1241    }
1242}
1243"#;
1244        let tree = ts_parse(source).unwrap();
1245        let sender_pos = source.find("sender").unwrap();
1246        let ctx = ts_find_call_at_byte(tree.root_node(), source, sender_pos).unwrap();
1247        assert_eq!(ctx.name, "Transfer");
1248        assert_eq!(ctx.arg_index, 0, "msg.sender is the 1st argument");
1249    }
1250
1251    #[test]
1252    fn test_ts_find_call_at_byte_emit_all_args() {
1253        // Verify each argument position in an emit with member accesses
1254        let source = r#"
1255contract Foo {
1256    event ModifyLiquidity(uint id, address sender, int24 tickLower, int24 tickUpper);
1257    function test() public {
1258        emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper);
1259    }
1260}
1261"#;
1262        let tree = ts_parse(source).unwrap();
1263
1264        // arg 0: "id"
1265        let pos_id = source.find("(id,").unwrap() + 1;
1266        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_id).unwrap();
1267        assert_eq!(ctx.name, "ModifyLiquidity");
1268        assert_eq!(ctx.arg_index, 0);
1269
1270        // arg 1: "msg.sender" — hover on "msg"
1271        let pos_msg = source.find("msg.sender").unwrap();
1272        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_msg).unwrap();
1273        assert_eq!(ctx.arg_index, 1);
1274
1275        // arg 2: "params.tickLower" — hover on "tickLower"
1276        let pos_tl = source.find("params.tickLower").unwrap() + "params.".len();
1277        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tl).unwrap();
1278        assert_eq!(ctx.arg_index, 2);
1279
1280        // arg 3: "params.tickUpper" — hover on "params"
1281        let pos_tu = source.find("params.tickUpper").unwrap();
1282        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tu).unwrap();
1283        assert_eq!(ctx.arg_index, 3);
1284    }
1285
1286    #[test]
1287    fn test_ts_find_call_at_byte_nested_call_arg() {
1288        // When an argument is itself a function call, hovering inside
1289        // the inner call should find the inner call, not the outer.
1290        let source = r#"
1291contract Foo {
1292    function inner(uint x) public returns (uint) {}
1293    function outer(uint a, uint b) public {}
1294    function test() public {
1295        outer(inner(42), 99);
1296    }
1297}
1298"#;
1299        let tree = ts_parse(source).unwrap();
1300
1301        // "42" is an arg to inner(), not outer()
1302        let pos_42 = source.find("42").unwrap();
1303        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_42).unwrap();
1304        assert_eq!(ctx.name, "inner");
1305        assert_eq!(ctx.arg_index, 0);
1306
1307        // "99" is an arg to outer()
1308        let pos_99 = source.find("99").unwrap();
1309        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_99).unwrap();
1310        assert_eq!(ctx.name, "outer");
1311        assert_eq!(ctx.arg_index, 1);
1312    }
1313}