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 a callsite to get the declaration id and skip count.
63    ///
64    /// Returns `(decl_id, skip)` where skip is the number of leading
65    /// parameters to skip (1 for `using-for` library calls, 0 otherwise).
66    ///
67    /// For signature help, this uses a relaxed lookup: exact offset first,
68    /// then `(name, arg_count)`, then **name-only** fallback (ignoring arg
69    /// count, since the user may still be typing arguments).
70    pub fn resolve_callsite_with_skip(
71        &self,
72        call_offset: usize,
73        func_name: &str,
74        arg_count: usize,
75    ) -> Option<(u64, usize)> {
76        // Try exact match first
77        if let Some(site) = lookup_call_site(self, call_offset, func_name, arg_count) {
78            return Some((site.decl_id, site.info.skip));
79        }
80        // Fallback: match by name only (any arg count)
81        self.by_name
82            .iter()
83            .find(|((name, _), _)| name == func_name)
84            .map(|(_, site)| (site.decl_id, site.info.skip))
85    }
86
87    /// Resolve callsite parameter info for hover.
88    ///
89    /// Given a call's byte offset (from tree-sitter), the function name,
90    /// the argument count, and the 0-based argument index, returns a
91    /// `ResolvedCallSite` with the parameter name and declaration id.
92    pub fn resolve_callsite_param(
93        &self,
94        call_offset: usize,
95        func_name: &str,
96        arg_count: usize,
97        arg_index: usize,
98    ) -> Option<ResolvedCallSite> {
99        let site = lookup_call_site(self, call_offset, func_name, arg_count)?;
100        let param_idx = arg_index + site.info.skip;
101        if param_idx >= site.info.names.len() {
102            return None;
103        }
104        let param_name = &site.info.names[param_idx];
105        if param_name.is_empty() {
106            return None;
107        }
108        Some(ResolvedCallSite {
109            param_name: param_name.clone(),
110            decl_id: site.decl_id,
111        })
112    }
113}
114
115/// Pre-computed hint lookups for all files, keyed by absolutePath.
116/// Built once in `CachedBuild::new()`, reused on every inlay hint request.
117pub type HintIndex = HashMap<String, HintLookup>;
118
119/// Build the hint index for all files from the AST sources.
120/// Called once in `CachedBuild::new()`.
121pub fn build_hint_index(sources: &Value) -> HintIndex {
122    let id_index = build_id_index(sources);
123    let mut hint_index = HashMap::new();
124
125    if let Some(obj) = sources.as_object() {
126        for (_, source_data) in obj {
127            if let Some(ast) = source_data.get("ast")
128                && let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str())
129            {
130                let lookup = build_hint_lookup(ast, &id_index);
131                hint_index.insert(abs_path.to_string(), lookup);
132            }
133        }
134    }
135
136    hint_index
137}
138
139/// Generate inlay hints for a given range of source.
140///
141/// Uses tree-sitter on the **live buffer** for argument positions (so hints
142/// follow edits in real time) and the pre-cached hint index for semantic
143/// info (parameter names via `referencedDeclaration`).
144pub fn inlay_hints(
145    build: &CachedBuild,
146    uri: &Url,
147    range: Range,
148    live_source: &[u8],
149) -> Vec<InlayHint> {
150    let path_str = match uri.to_file_path() {
151        Ok(p) => p.to_str().unwrap_or("").to_string(),
152        Err(_) => return vec![],
153    };
154
155    let abs = match build
156        .path_to_abs
157        .iter()
158        .find(|(k, _)| path_str.ends_with(k.as_str()))
159    {
160        Some((_, v)) => v.clone(),
161        None => return vec![],
162    };
163
164    // Use the pre-cached hint lookup for this file
165    let lookup = match build.hint_index.get(&abs) {
166        Some(l) => l,
167        None => return vec![],
168    };
169
170    // Walk tree-sitter on the live buffer for real-time argument positions
171    let source_str = String::from_utf8_lossy(live_source);
172    let tree = match ts_parse(&source_str) {
173        Some(t) => t,
174        None => return vec![],
175    };
176
177    let mut hints = Vec::new();
178    collect_ts_hints(tree.root_node(), &source_str, &range, lookup, &mut hints);
179
180    // Gas inlay hints: use tree-sitter positions (tracks live buffer)
181    if !build.gas_index.is_empty() {
182        collect_ts_gas_hints(
183            tree.root_node(),
184            &source_str,
185            &range,
186            &build.gas_index,
187            &abs,
188            &mut hints,
189        );
190    }
191
192    hints
193}
194
195/// Build a flat node-id → AST-node index from all sources.
196/// This is O(total_nodes) and replaces the O(calls × total_nodes)
197/// `find_declaration` that walked the entire AST per lookup.
198fn build_id_index(sources: &Value) -> HashMap<u64, &Value> {
199    let mut index = HashMap::new();
200    if let Some(obj) = sources.as_object() {
201        for (_, source_data) in obj {
202            if let Some(ast) = source_data.get("ast") {
203                index_node_ids(ast, &mut index);
204            }
205        }
206    }
207    index
208}
209
210/// Recursively index all nodes that have an `id` field.
211fn index_node_ids<'a>(node: &'a Value, index: &mut HashMap<u64, &'a Value>) {
212    if let Some(id) = node.get("id").and_then(|v| v.as_u64()) {
213        index.insert(id, node);
214    }
215    for key in crate::goto::CHILD_KEYS {
216        if let Some(child) = node.get(*key) {
217            if child.is_array() {
218                if let Some(arr) = child.as_array() {
219                    for item in arr {
220                        index_node_ids(item, index);
221                    }
222                }
223            } else if child.is_object() {
224                index_node_ids(child, index);
225            }
226        }
227    }
228    if let Some(nodes) = node.get("nodes").and_then(|v| v.as_array()) {
229        for child in nodes {
230            index_node_ids(child, index);
231        }
232    }
233}
234
235/// Parse Solidity source with tree-sitter.
236pub fn ts_parse(source: &str) -> Option<tree_sitter::Tree> {
237    let mut parser = Parser::new();
238    parser
239        .set_language(&tree_sitter_solidity::LANGUAGE.into())
240        .expect("failed to load Solidity grammar");
241    parser.parse(source, None)
242}
243
244/// Build both lookup strategies from the AST.
245fn build_hint_lookup(file_ast: &Value, id_index: &HashMap<u64, &Value>) -> HintLookup {
246    let mut lookup = HintLookup {
247        by_offset: HashMap::new(),
248        by_name: HashMap::new(),
249    };
250    collect_ast_calls(file_ast, id_index, &mut lookup);
251    lookup
252}
253
254/// Parse the `src` field ("offset:length:fileId") and return the byte offset.
255fn parse_src_offset(node: &Value) -> Option<usize> {
256    let src = node.get("src").and_then(|v| v.as_str())?;
257    SourceLoc::parse(src).map(|loc| loc.offset)
258}
259
260/// Recursively walk AST nodes collecting call site info.
261fn collect_ast_calls(node: &Value, id_index: &HashMap<u64, &Value>, lookup: &mut HintLookup) {
262    let node_type = node.get("nodeType").and_then(|v| v.as_str()).unwrap_or("");
263
264    match node_type {
265        "FunctionCall" => {
266            if let Some(call_info) = extract_call_info(node, id_index) {
267                let arg_count = node
268                    .get("arguments")
269                    .and_then(|v| v.as_array())
270                    .map(|a| a.len())
271                    .unwrap_or(0);
272                let site = CallSite {
273                    info: ParamInfo {
274                        names: call_info.params.names,
275                        skip: call_info.params.skip,
276                    },
277                    name: call_info.name,
278                    decl_id: call_info.decl_id,
279                };
280                if let Some(offset) = parse_src_offset(node) {
281                    lookup.by_offset.insert(offset, site.clone());
282                }
283
284                lookup
285                    .by_name
286                    .entry((site.name.clone(), arg_count))
287                    .or_insert(site);
288            }
289        }
290        "EmitStatement" => {
291            if let Some(event_call) = node.get("eventCall")
292                && let Some(call_info) = extract_call_info(event_call, id_index)
293            {
294                let arg_count = event_call
295                    .get("arguments")
296                    .and_then(|v| v.as_array())
297                    .map(|a| a.len())
298                    .unwrap_or(0);
299                let site = CallSite {
300                    info: ParamInfo {
301                        names: call_info.params.names,
302                        skip: call_info.params.skip,
303                    },
304                    name: call_info.name,
305                    decl_id: call_info.decl_id,
306                };
307                if let Some(offset) = parse_src_offset(node) {
308                    lookup.by_offset.insert(offset, site.clone());
309                }
310
311                lookup
312                    .by_name
313                    .entry((site.name.clone(), arg_count))
314                    .or_insert(site);
315            }
316        }
317        _ => {}
318    }
319
320    // Recurse into children
321    for key in crate::goto::CHILD_KEYS {
322        if let Some(child) = node.get(*key) {
323            if child.is_array() {
324                if let Some(arr) = child.as_array() {
325                    for item in arr {
326                        collect_ast_calls(item, id_index, lookup);
327                    }
328                }
329            } else if child.is_object() {
330                collect_ast_calls(child, id_index, lookup);
331            }
332        }
333    }
334}
335
336/// Resolved call info including the declaration id of the called function/event.
337struct CallInfo {
338    /// Function/event name.
339    name: String,
340    /// Parameter names and skip count.
341    params: ParamInfo,
342    /// The AST node id of the referenced declaration (for DocIndex lookup).
343    decl_id: u64,
344}
345
346/// Extract function/event name and parameter info from an AST FunctionCall node.
347fn extract_call_info(node: &Value, id_index: &HashMap<u64, &Value>) -> Option<CallInfo> {
348    let args = node.get("arguments")?.as_array()?;
349    if args.is_empty() {
350        return None;
351    }
352
353    // Skip struct constructors with named args
354    let kind = node.get("kind").and_then(|v| v.as_str()).unwrap_or("");
355    if kind == "structConstructorCall"
356        && node
357            .get("names")
358            .and_then(|v| v.as_array())
359            .is_some_and(|n| !n.is_empty())
360    {
361        return None;
362    }
363
364    let expr = node.get("expression")?;
365    let decl_id = expr.get("referencedDeclaration").and_then(|v| v.as_u64())?;
366
367    let decl_node = id_index.get(&decl_id)?;
368    let names = get_parameter_names(decl_node)?;
369
370    // Extract the function name from the expression
371    let func_name = extract_function_name(expr)?;
372
373    // Using-for library calls pass the receiver as the implicit first param,
374    // so the AST has one fewer arg than the declaration has params.
375    // Direct library calls (Transaction.addTax) and struct constructors
376    // pass all params explicitly — arg count matches param count.
377    let arg_count = node
378        .get("arguments")
379        .and_then(|v| v.as_array())
380        .map(|a| a.len())
381        .unwrap_or(0);
382    let skip = if is_member_access(expr) && arg_count < names.len() {
383        1
384    } else {
385        0
386    };
387
388    Some(CallInfo {
389        name: func_name,
390        params: ParamInfo { names, skip },
391        decl_id,
392    })
393}
394
395/// Extract the function/event name from an AST expression node.
396fn extract_function_name(expr: &Value) -> Option<String> {
397    let node_type = expr.get("nodeType").and_then(|v| v.as_str())?;
398    match node_type {
399        "Identifier" => expr.get("name").and_then(|v| v.as_str()).map(String::from),
400        "MemberAccess" => expr
401            .get("memberName")
402            .and_then(|v| v.as_str())
403            .map(String::from),
404        _ => None,
405    }
406}
407
408/// Check if expression is a MemberAccess (potential using-for call).
409fn is_member_access(expr: &Value) -> bool {
410    expr.get("nodeType")
411        .and_then(|v| v.as_str())
412        .is_some_and(|t| t == "MemberAccess")
413}
414
415// ── Tree-sitter walk ──────────────────────────────────────────────────────
416
417/// Look up call site info: try exact byte-offset match first, fall back to (name, arg_count).
418fn lookup_call_site<'a>(
419    lookup: &'a HintLookup,
420    offset: usize,
421    name: &str,
422    arg_count: usize,
423) -> Option<&'a CallSite> {
424    // Exact match by byte offset (works when AST is fresh)
425    if let Some(site) = lookup.by_offset.get(&offset)
426        && site.name == name
427    {
428        return Some(site);
429    }
430    // Fallback by (name, arg_count) (works with stale offsets after edits)
431    lookup.by_name.get(&(name.to_string(), arg_count))
432}
433
434/// Recursively walk tree-sitter nodes, emitting hints for calls in the visible range.
435fn collect_ts_hints(
436    node: Node,
437    source: &str,
438    range: &Range,
439    lookup: &HintLookup,
440    hints: &mut Vec<InlayHint>,
441) {
442    // Quick range check — skip nodes entirely outside the visible range
443    let node_start = node.start_position();
444    let node_end = node.end_position();
445    if (node_end.row as u32) < range.start.line || (node_start.row as u32) > range.end.line {
446        return;
447    }
448
449    match node.kind() {
450        "call_expression" => {
451            emit_call_hints(node, source, lookup, hints);
452        }
453        "emit_statement" => {
454            emit_emit_hints(node, source, lookup, hints);
455        }
456        _ => {}
457    }
458
459    // Recurse into children
460    let mut cursor = node.walk();
461    for child in node.children(&mut cursor) {
462        collect_ts_hints(child, source, range, lookup, hints);
463    }
464}
465
466/// Emit parameter hints for a `call_expression` node.
467fn emit_call_hints(node: Node, source: &str, lookup: &HintLookup, hints: &mut Vec<InlayHint>) {
468    let func_name = match ts_call_function_name(node, source) {
469        Some(n) => n,
470        None => return,
471    };
472
473    let args = ts_call_arguments(node);
474    if args.is_empty() {
475        return;
476    }
477
478    let site = match lookup_call_site(lookup, node.start_byte(), func_name, args.len()) {
479        Some(s) => s,
480        None => return,
481    };
482
483    emit_param_hints(&args, &site.info, hints);
484}
485
486/// Emit parameter hints for an `emit_statement` node.
487fn emit_emit_hints(node: Node, source: &str, lookup: &HintLookup, hints: &mut Vec<InlayHint>) {
488    let event_name = match ts_emit_event_name(node, source) {
489        Some(n) => n,
490        None => return,
491    };
492
493    let args = ts_call_arguments(node);
494    if args.is_empty() {
495        return;
496    }
497
498    let site = match lookup_call_site(lookup, node.start_byte(), event_name, args.len()) {
499        Some(s) => s,
500        None => return,
501    };
502
503    emit_param_hints(&args, &site.info, hints);
504}
505
506/// Emit InlayHint items for each argument, using tree-sitter positions.
507fn emit_param_hints(args: &[Node], info: &ParamInfo, hints: &mut Vec<InlayHint>) {
508    for (i, arg) in args.iter().enumerate() {
509        let pi = i + info.skip;
510        if pi >= info.names.len() || info.names[pi].is_empty() {
511            continue;
512        }
513
514        let start = arg.start_position();
515        let position = Position::new(start.row as u32, start.column as u32);
516
517        hints.push(InlayHint {
518            position,
519            kind: Some(InlayHintKind::PARAMETER),
520            label: InlayHintLabel::String(format!("{}:", info.names[pi])),
521            text_edits: None,
522            tooltip: None,
523            padding_left: None,
524            padding_right: Some(true),
525            data: None,
526        });
527    }
528}
529
530// ── Tree-sitter helpers ───────────────────────────────────────────────────
531
532/// Get the function name from a `call_expression` node.
533///
534/// For `transfer(...)` → "transfer"
535/// For `PRICE.addTax(...)` → "addTax"
536fn ts_call_function_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
537    let func_expr = node.child_by_field_name("function")?;
538    // The expression wrapper has one named child
539    let inner = first_named_child(func_expr)?;
540    match inner.kind() {
541        "identifier" => Some(&source[inner.byte_range()]),
542        "member_expression" => {
543            let prop = inner.child_by_field_name("property")?;
544            Some(&source[prop.byte_range()])
545        }
546        _ => None,
547    }
548}
549
550/// Get the event name from an `emit_statement` node.
551fn ts_emit_event_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
552    let name_expr = node.child_by_field_name("name")?;
553    let inner = first_named_child(name_expr)?;
554    match inner.kind() {
555        "identifier" => Some(&source[inner.byte_range()]),
556        "member_expression" => {
557            let prop = inner.child_by_field_name("property")?;
558            Some(&source[prop.byte_range()])
559        }
560        _ => None,
561    }
562}
563
564/// Collect `call_argument` children from a node (works for both
565/// `call_expression` and `emit_statement` since `_call_arguments` is hidden).
566fn ts_call_arguments(node: Node) -> Vec<Node> {
567    let mut args = Vec::new();
568    let mut cursor = node.walk();
569    for child in node.children(&mut cursor) {
570        if child.kind() == "call_argument" {
571            args.push(child);
572        }
573    }
574    args
575}
576
577/// Get the first named child of a node.
578fn first_named_child(node: Node) -> Option<Node> {
579    let mut cursor = node.walk();
580    node.children(&mut cursor).find(|c| c.is_named())
581}
582
583/// Result of finding the enclosing call site at a byte position via tree-sitter.
584pub struct TsCallContext<'a> {
585    /// The function/event name.
586    pub name: &'a str,
587    /// 0-based index of the argument the cursor is on.
588    pub arg_index: usize,
589    /// Total number of arguments in the call.
590    pub arg_count: usize,
591    /// Start byte of the call_expression/emit_statement node (for HintIndex lookup).
592    pub call_start_byte: usize,
593    /// True when context is a mapping/array index access (`name[key]`)
594    /// rather than a function/event call (`name(args)`).
595    pub is_index_access: bool,
596}
597
598/// Find the enclosing `call_expression` or `emit_statement` for a given byte
599/// position in the live buffer using tree-sitter.
600///
601/// Returns `None` if the position is not inside a call argument.
602pub fn ts_find_call_at_byte<'a>(
603    root: tree_sitter::Node<'a>,
604    source: &'a str,
605    byte_pos: usize,
606) -> Option<TsCallContext<'a>> {
607    // Find the deepest node containing byte_pos
608    let mut node = root.descendant_for_byte_range(byte_pos, byte_pos)?;
609
610    // Walk up the tree to find a call_argument parent
611    loop {
612        if node.kind() == "call_argument" {
613            break;
614        }
615        node = node.parent()?;
616    }
617
618    // The call_argument's parent should be the call_expression or emit_statement
619    let call_node = node.parent()?;
620    let args = ts_call_arguments(call_node);
621    let arg_index = args.iter().position(|a| a.id() == node.id())?;
622
623    match call_node.kind() {
624        "call_expression" => {
625            let name = ts_call_function_name(call_node, source)?;
626            Some(TsCallContext {
627                name,
628                arg_index,
629                arg_count: args.len(),
630                call_start_byte: call_node.start_byte(),
631                is_index_access: false,
632            })
633        }
634        "emit_statement" => {
635            let name = ts_emit_event_name(call_node, source)?;
636            Some(TsCallContext {
637                name,
638                arg_index,
639                arg_count: args.len(),
640                call_start_byte: call_node.start_byte(),
641                is_index_access: false,
642            })
643        }
644        _ => None,
645    }
646}
647
648/// Find the enclosing call for signature help at a byte position.
649///
650/// Unlike `ts_find_call_at_byte`, this handles:
651/// - Cursor right after `(` with no arguments yet
652/// - Cursor between `,` and next argument
653/// - Incomplete calls without closing `)`
654///
655/// Falls back to text-based scanning when tree-sitter can't produce a
656/// `call_expression` (e.g. broken syntax during typing).
657pub fn ts_find_call_for_signature<'a>(
658    root: tree_sitter::Node<'a>,
659    source: &'a str,
660    byte_pos: usize,
661) -> Option<TsCallContext<'a>> {
662    // First try the normal path (cursor is on an argument)
663    if let Some(ctx) = ts_find_call_at_byte(root, source, byte_pos) {
664        return Some(ctx);
665    }
666
667    // Walk up from the deepest node looking for a call_expression or array_access
668    let mut node = root.descendant_for_byte_range(byte_pos, byte_pos)?;
669    loop {
670        match node.kind() {
671            "call_expression" => {
672                let name = ts_call_function_name(node, source)?;
673                let arg_index = count_commas_before(source, node.start_byte(), byte_pos);
674                let args = ts_call_arguments(node);
675                let arg_count = args.len().max(arg_index + 1);
676                return Some(TsCallContext {
677                    name,
678                    arg_index,
679                    arg_count,
680                    call_start_byte: node.start_byte(),
681                    is_index_access: false,
682                });
683            }
684            "emit_statement" => {
685                let name = ts_emit_event_name(node, source)?;
686                let arg_index = count_commas_before(source, node.start_byte(), byte_pos);
687                let args = ts_call_arguments(node);
688                let arg_count = args.len().max(arg_index + 1);
689                return Some(TsCallContext {
690                    name,
691                    arg_index,
692                    arg_count,
693                    call_start_byte: node.start_byte(),
694                    is_index_access: false,
695                });
696            }
697            "array_access" => {
698                // Mapping/array index access: `name[key]`
699                let base_node = node.child_by_field_name("base")?;
700                // For member_expression (e.g. self.orders), use property name;
701                // for plain identifier, use the identifier text.
702                let name_node = if base_node.kind() == "member_expression" {
703                    base_node
704                        .child_by_field_name("property")
705                        .unwrap_or(base_node)
706                } else {
707                    base_node
708                };
709                let name = &source[name_node.byte_range()];
710                return Some(TsCallContext {
711                    name,
712                    arg_index: 0,
713                    arg_count: 1,
714                    call_start_byte: node.start_byte(),
715                    is_index_access: true,
716                });
717            }
718            "source_file" => break,
719            _ => {
720                node = node.parent()?;
721            }
722        }
723    }
724
725    // Fallback: scan backwards from cursor for `identifier(` pattern
726    if let Some(ctx) = find_call_by_text_scan(source, byte_pos) {
727        return Some(ctx);
728    }
729
730    // Fallback: scan backwards for `identifier[` (mapping/array access)
731    find_index_by_text_scan(source, byte_pos)
732}
733
734/// Scan backwards from `byte_pos` to find an enclosing `name(` call.
735///
736/// Looks for the nearest unmatched `(` before the cursor, then extracts
737/// the function name preceding it. Counts commas at depth 1 to determine
738/// the active argument index.
739fn find_call_by_text_scan<'a>(source: &'a str, byte_pos: usize) -> Option<TsCallContext<'a>> {
740    let before = &source[..byte_pos.min(source.len())];
741
742    // Find the nearest unmatched `(` by scanning backwards
743    let mut depth: i32 = 0;
744    let mut paren_pos = None;
745    for (i, ch) in before.char_indices().rev() {
746        match ch {
747            ')' => depth += 1,
748            '(' => {
749                if depth == 0 {
750                    paren_pos = Some(i);
751                    break;
752                }
753                depth -= 1;
754            }
755            _ => {}
756        }
757    }
758    let paren_pos = paren_pos?;
759
760    // Extract the function name before the `(`
761    // Walk backwards from paren_pos to find the identifier
762    let before_paren = &source[..paren_pos];
763    let name_end = before_paren.trim_end().len();
764    let name_start = before_paren[..name_end]
765        .rfind(|c: char| !c.is_alphanumeric() && c != '_')
766        .map(|i| i + 1)
767        .unwrap_or(0);
768    let name = &source[name_start..name_end];
769
770    if name.is_empty() || !name.chars().next()?.is_alphabetic() {
771        return None;
772    }
773
774    // Count commas between `(` and cursor at depth 0
775    let arg_index = count_commas_before(source, paren_pos, byte_pos);
776
777    Some(TsCallContext {
778        name,
779        arg_index,
780        arg_count: arg_index + 1,
781        call_start_byte: name_start,
782        is_index_access: false,
783    })
784}
785
786/// Scan backwards from `byte_pos` to find an enclosing `name[` index access.
787///
788/// Similar to `find_call_by_text_scan` but for `[` brackets instead of `(`.
789/// Returns a context with `is_index_access = true`.
790fn find_index_by_text_scan<'a>(source: &'a str, byte_pos: usize) -> Option<TsCallContext<'a>> {
791    let before = &source[..byte_pos.min(source.len())];
792
793    // Find the nearest unmatched `[` by scanning backwards
794    let mut depth: i32 = 0;
795    let mut bracket_pos = None;
796    for (i, c) in before.char_indices().rev() {
797        match c {
798            ']' => depth += 1,
799            '[' => {
800                if depth == 0 {
801                    bracket_pos = Some(i);
802                    break;
803                }
804                depth -= 1;
805            }
806            _ => {}
807        }
808    }
809    let bracket_pos = bracket_pos?;
810
811    // Extract the identifier name before the `[`
812    let before_bracket = &source[..bracket_pos];
813    let name_end = before_bracket.trim_end().len();
814    let name_start = before_bracket[..name_end]
815        .rfind(|c: char| !c.is_alphanumeric() && c != '_')
816        .map(|i| i + 1)
817        .unwrap_or(0);
818    let name = &source[name_start..name_end];
819
820    if name.is_empty() || !name.chars().next()?.is_alphabetic() {
821        return None;
822    }
823
824    Some(TsCallContext {
825        name,
826        arg_index: 0,
827        arg_count: 1,
828        call_start_byte: name_start,
829        is_index_access: true,
830    })
831}
832
833/// Count commas at depth 1 between `start` and `byte_pos` to determine argument index.
834fn count_commas_before(source: &str, start: usize, byte_pos: usize) -> usize {
835    let end = byte_pos.min(source.len());
836    let text = &source[start..end];
837
838    let mut count = 0;
839    let mut depth = 0;
840    let mut found_open = false;
841    for ch in text.chars() {
842        match ch {
843            '(' if !found_open => {
844                found_open = true;
845                depth = 1;
846            }
847            '(' => depth += 1,
848            ')' => depth -= 1,
849            ',' if found_open && depth == 1 => count += 1,
850            _ => {}
851        }
852    }
853    count
854}
855
856// ── Gas inlay hints (tree-sitter based) ──────────────────────────────────
857
858/// Walk tree-sitter nodes for function/contract definitions, emitting gas
859/// cost hints using **live buffer positions** so they track edits in real time.
860fn collect_ts_gas_hints(
861    node: Node,
862    source: &str,
863    range: &Range,
864    gas_index: &gas::GasIndex,
865    abs_path: &str,
866    hints: &mut Vec<InlayHint>,
867) {
868    let node_start = node.start_position();
869    let node_end = node.end_position();
870    if (node_end.row as u32) < range.start.line || (node_start.row as u32) > range.end.line {
871        return;
872    }
873
874    match node.kind() {
875        "function_definition" => {
876            if let Some(hint) = ts_gas_hint_for_function(node, source, range, gas_index, abs_path) {
877                hints.push(hint);
878            }
879        }
880        "contract_declaration" | "library_declaration" | "interface_declaration" => {
881            if let Some(hint) = ts_gas_hint_for_contract(node, source, range, gas_index, abs_path) {
882                hints.push(hint);
883            }
884        }
885        _ => {}
886    }
887
888    let mut cursor = node.walk();
889    for child in node.children(&mut cursor) {
890        collect_ts_gas_hints(child, source, range, gas_index, abs_path, hints);
891    }
892}
893
894/// Extract the identifier (name) child from a tree-sitter node.
895fn ts_node_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
896    let mut cursor = node.walk();
897    node.children(&mut cursor)
898        .find(|c| c.kind() == "identifier" && c.is_named())
899        .map(|c| &source[c.byte_range()])
900}
901
902/// Find the opening `{` position of a body node.
903fn ts_body_open_brace(node: Node, body_kind: &str) -> Option<Position> {
904    let mut cursor = node.walk();
905    let body = node.children(&mut cursor).find(|c| c.kind() == body_kind)?;
906    let start = body.start_position();
907    Some(Position::new(start.row as u32, start.column as u32))
908}
909
910/// Find the closing `}` position of a body node.
911fn ts_body_close_brace(node: Node, body_kind: &str) -> Option<Position> {
912    let mut cursor = node.walk();
913    let body = node.children(&mut cursor).find(|c| c.kind() == body_kind)?;
914    let end = body.end_position();
915    // end_position points one past the `}`, so column - 1
916    Some(Position::new(
917        end.row as u32,
918        end.column.saturating_sub(1) as u32,
919    ))
920}
921
922/// Find the enclosing contract name for a function_definition node.
923fn ts_enclosing_contract_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
924    let mut parent = node.parent();
925    while let Some(p) = parent {
926        if p.kind() == "contract_declaration"
927            || p.kind() == "library_declaration"
928            || p.kind() == "interface_declaration"
929        {
930            return ts_node_name(p, source);
931        }
932        parent = p.parent();
933    }
934    None
935}
936
937/// Find the gas index key matching a file path and contract name.
938fn find_gas_key<'a>(
939    gas_index: &'a gas::GasIndex,
940    abs_path: &str,
941    contract_name: &str,
942) -> Option<&'a str> {
943    let exact = format!("{abs_path}:{contract_name}");
944    if gas_index.contains_key(&exact) {
945        return Some(gas_index.get_key_value(&exact)?.0.as_str());
946    }
947    let file_name = std::path::Path::new(abs_path).file_name()?.to_str()?;
948    let suffix = format!("{file_name}:{contract_name}");
949    gas_index
950        .keys()
951        .find(|k| k.ends_with(&suffix))
952        .map(|k| k.as_str())
953}
954
955/// Check if a tree-sitter node has a preceding comment containing the gas sentinel.
956///
957/// Looks at the previous named sibling for a comment node whose text contains
958/// `@lsp-enable gas-estimates`.
959fn has_gas_sentinel(node: Node, source: &str) -> bool {
960    let mut prev = node.prev_named_sibling();
961    while let Some(sibling) = prev {
962        if sibling.kind() == "comment" {
963            let text = &source[sibling.byte_range()];
964            if text.contains(gas::GAS_SENTINEL) {
965                return true;
966            }
967        } else {
968            break;
969        }
970        prev = sibling.prev_named_sibling();
971    }
972    false
973}
974
975/// Create a gas inlay hint for a function definition using tree-sitter positions.
976fn ts_gas_hint_for_function(
977    node: Node,
978    source: &str,
979    range: &Range,
980    gas_index: &gas::GasIndex,
981    abs_path: &str,
982) -> Option<InlayHint> {
983    // Only show gas hints for functions annotated with @lsp-enable gas-estimates
984    if !has_gas_sentinel(node, source) {
985        return None;
986    }
987    let fn_name = ts_node_name(node, source)?;
988    let contract_name = ts_enclosing_contract_name(node, source)?;
989    let gas_key = find_gas_key(gas_index, abs_path, contract_name)?;
990    let contract_gas = gas_index.get(gas_key)?;
991
992    let prefix = format!("{fn_name}(");
993    let cost = contract_gas
994        .external_by_sig
995        .iter()
996        .find(|(sig, _)| sig.as_str().starts_with(&prefix))
997        .map(|(_, c)| c.as_str())
998        .or_else(|| {
999            contract_gas
1000                .internal
1001                .iter()
1002                .find(|(sig, _)| sig.starts_with(&prefix))
1003                .map(|(_, c)| c.as_str())
1004        })?;
1005
1006    // Position: opening or closing brace based on FN_GAS_HINT_POSITION
1007    let (brace_pos, offset) = match FN_GAS_HINT_POSITION {
1008        FnGasHintPosition::Opening => (ts_body_open_brace(node, "function_body")?, 1),
1009        FnGasHintPosition::Closing => (ts_body_close_brace(node, "function_body")?, 1),
1010    };
1011    if brace_pos.line < range.start.line || brace_pos.line > range.end.line {
1012        return None;
1013    }
1014
1015    Some(InlayHint {
1016        position: Position::new(brace_pos.line, brace_pos.character + offset),
1017        kind: Some(InlayHintKind::TYPE),
1018        label: InlayHintLabel::String(format!("🔥 gas: {}", gas::format_gas(cost))),
1019        text_edits: None,
1020        tooltip: Some(InlayHintTooltip::String("Estimated gas cost".to_string())),
1021        padding_left: Some(true),
1022        padding_right: None,
1023        data: None,
1024    })
1025}
1026
1027/// Create a gas inlay hint for a contract/library/interface definition.
1028/// Always uses the opening brace.
1029fn ts_gas_hint_for_contract(
1030    node: Node,
1031    source: &str,
1032    range: &Range,
1033    gas_index: &gas::GasIndex,
1034    abs_path: &str,
1035) -> Option<InlayHint> {
1036    // Only show deploy cost for contracts annotated with @lsp-enable gas-estimates
1037    if !has_gas_sentinel(node, source) {
1038        return None;
1039    }
1040    let contract_name = ts_node_name(node, source)?;
1041    let gas_key = find_gas_key(gas_index, abs_path, contract_name)?;
1042    let contract_gas = gas_index.get(gas_key)?;
1043
1044    // Prefer totalCost, but when it's "infinite" show codeDepositCost instead
1045    let display_cost = match contract_gas.creation.get("totalCost").map(|s| s.as_str()) {
1046        Some("infinite") | None => contract_gas
1047            .creation
1048            .get("codeDepositCost")
1049            .map(|s| s.as_str())?,
1050        Some(total) => total,
1051    };
1052
1053    let brace_pos = ts_body_open_brace(node, "contract_body")?;
1054    if brace_pos.line < range.start.line || brace_pos.line > range.end.line {
1055        return None;
1056    }
1057
1058    Some(InlayHint {
1059        position: Position::new(brace_pos.line, brace_pos.character + 1),
1060        kind: Some(InlayHintKind::TYPE),
1061        label: InlayHintLabel::String(format!("🔥 deploy: {} ", gas::format_gas(display_cost))),
1062        text_edits: None,
1063        tooltip: Some(InlayHintTooltip::String(format!(
1064            "Deploy cost — code deposit: {}, execution: {}",
1065            gas::format_gas(
1066                contract_gas
1067                    .creation
1068                    .get("codeDepositCost")
1069                    .map(|s| s.as_str())
1070                    .unwrap_or("?")
1071            ),
1072            gas::format_gas(
1073                contract_gas
1074                    .creation
1075                    .get("executionCost")
1076                    .map(|s| s.as_str())
1077                    .unwrap_or("?")
1078            )
1079        ))),
1080        padding_left: Some(true),
1081        padding_right: None,
1082        data: None,
1083    })
1084}
1085
1086// ── AST helpers ──────────────────────────────────────────────────────────
1087
1088/// Extract parameter names from a function/event/error/struct declaration.
1089fn get_parameter_names(decl: &Value) -> Option<Vec<String>> {
1090    // Functions, events, errors: parameters.parameters[]
1091    // Structs: members[]
1092    let items = decl
1093        .get("parameters")
1094        .and_then(|p| p.get("parameters"))
1095        .and_then(|v| v.as_array())
1096        .or_else(|| decl.get("members").and_then(|v| v.as_array()))?;
1097    Some(
1098        items
1099            .iter()
1100            .map(|p| {
1101                p.get("name")
1102                    .and_then(|v| v.as_str())
1103                    .unwrap_or("")
1104                    .to_string()
1105            })
1106            .collect(),
1107    )
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn test_gas_sentinel_present() {
1116        let source = r#"
1117contract Foo {
1118    /// @custom:lsp-enable gas-estimates
1119    function bar() public {}
1120}
1121"#;
1122        let tree = ts_parse(source).unwrap();
1123        let root = tree.root_node();
1124        // Find the function_definition node
1125        let contract = root.child(0).unwrap();
1126        let body = contract.child_by_field_name("body").unwrap();
1127        let mut cursor = body.walk();
1128        let fn_node = body
1129            .children(&mut cursor)
1130            .find(|c| c.kind() == "function_definition")
1131            .unwrap();
1132        assert!(has_gas_sentinel(fn_node, source));
1133    }
1134
1135    #[test]
1136    fn test_gas_sentinel_absent() {
1137        let source = r#"
1138contract Foo {
1139    function bar() public {}
1140}
1141"#;
1142        let tree = ts_parse(source).unwrap();
1143        let root = tree.root_node();
1144        let contract = root.child(0).unwrap();
1145        let body = contract.child_by_field_name("body").unwrap();
1146        let mut cursor = body.walk();
1147        let fn_node = body
1148            .children(&mut cursor)
1149            .find(|c| c.kind() == "function_definition")
1150            .unwrap();
1151        assert!(!has_gas_sentinel(fn_node, source));
1152    }
1153
1154    #[test]
1155    fn test_gas_sentinel_with_other_natspec() {
1156        let source = r#"
1157contract Foo {
1158    /// @notice Does something
1159    /// @custom:lsp-enable gas-estimates
1160    function bar() public {}
1161}
1162"#;
1163        let tree = ts_parse(source).unwrap();
1164        let root = tree.root_node();
1165        let contract = root.child(0).unwrap();
1166        let body = contract.child_by_field_name("body").unwrap();
1167        let mut cursor = body.walk();
1168        let fn_node = body
1169            .children(&mut cursor)
1170            .find(|c| c.kind() == "function_definition")
1171            .unwrap();
1172        assert!(has_gas_sentinel(fn_node, source));
1173    }
1174
1175    #[test]
1176    fn test_get_parameter_names() {
1177        let decl: Value = serde_json::json!({
1178            "parameters": {
1179                "parameters": [
1180                    {"name": "to", "nodeType": "VariableDeclaration"},
1181                    {"name": "amount", "nodeType": "VariableDeclaration"},
1182                ]
1183            }
1184        });
1185        let names = get_parameter_names(&decl).unwrap();
1186        assert_eq!(names, vec!["to", "amount"]);
1187    }
1188
1189    #[test]
1190    fn test_ts_call_function_name() {
1191        let source = r#"
1192contract Foo {
1193    function bar(uint x) public {}
1194    function test() public {
1195        bar(42);
1196    }
1197}
1198"#;
1199        let tree = ts_parse(source).unwrap();
1200        let mut found = Vec::new();
1201        find_calls(tree.root_node(), source, &mut found);
1202        assert_eq!(found.len(), 1);
1203        assert_eq!(found[0], "bar");
1204    }
1205
1206    #[test]
1207    fn test_ts_member_call_name() {
1208        let source = r#"
1209contract Foo {
1210    function test() public {
1211        PRICE.addTax(TAX, TAX_BASE);
1212    }
1213}
1214"#;
1215        let tree = ts_parse(source).unwrap();
1216        let mut found = Vec::new();
1217        find_calls(tree.root_node(), source, &mut found);
1218        assert_eq!(found.len(), 1);
1219        assert_eq!(found[0], "addTax");
1220    }
1221
1222    #[test]
1223    fn test_ts_emit_event_name() {
1224        let source = r#"
1225contract Foo {
1226    event Purchase(address buyer, uint256 price);
1227    function test() public {
1228        emit Purchase(msg.sender, 100);
1229    }
1230}
1231"#;
1232        let tree = ts_parse(source).unwrap();
1233        let mut found = Vec::new();
1234        find_emits(tree.root_node(), source, &mut found);
1235        assert_eq!(found.len(), 1);
1236        assert_eq!(found[0], "Purchase");
1237    }
1238
1239    #[test]
1240    fn test_ts_call_arguments_count() {
1241        let source = r#"
1242contract Foo {
1243    function bar(uint x, uint y) public {}
1244    function test() public {
1245        bar(1, 2);
1246    }
1247}
1248"#;
1249        let tree = ts_parse(source).unwrap();
1250        let mut arg_counts = Vec::new();
1251        find_call_arg_counts(tree.root_node(), &mut arg_counts);
1252        assert_eq!(arg_counts, vec![2]);
1253    }
1254
1255    #[test]
1256    fn test_ts_argument_positions_follow_live_buffer() {
1257        // Simulate an edited buffer with extra whitespace
1258        let source = r#"
1259contract Foo {
1260    function bar(uint x, uint y) public {}
1261    function test() public {
1262        bar(
1263            1,
1264            2
1265        );
1266    }
1267}
1268"#;
1269        let tree = ts_parse(source).unwrap();
1270        let mut positions = Vec::new();
1271        find_arg_positions(tree.root_node(), &mut positions);
1272        // First arg "1" is on line 5 (0-indexed), second "2" on line 6
1273        assert_eq!(positions.len(), 2);
1274        assert_eq!(positions[0].0, 5); // row of "1"
1275        assert_eq!(positions[1].0, 6); // row of "2"
1276    }
1277
1278    // Test helpers
1279
1280    fn find_calls<'a>(node: Node<'a>, source: &'a str, out: &mut Vec<&'a str>) {
1281        if node.kind() == "call_expression"
1282            && let Some(name) = ts_call_function_name(node, source)
1283        {
1284            out.push(name);
1285        }
1286        let mut cursor = node.walk();
1287        for child in node.children(&mut cursor) {
1288            find_calls(child, source, out);
1289        }
1290    }
1291
1292    fn find_emits<'a>(node: Node<'a>, source: &'a str, out: &mut Vec<&'a str>) {
1293        if node.kind() == "emit_statement"
1294            && let Some(name) = ts_emit_event_name(node, source)
1295        {
1296            out.push(name);
1297        }
1298        let mut cursor = node.walk();
1299        for child in node.children(&mut cursor) {
1300            find_emits(child, source, out);
1301        }
1302    }
1303
1304    fn find_call_arg_counts(node: Node, out: &mut Vec<usize>) {
1305        if node.kind() == "call_expression" {
1306            out.push(ts_call_arguments(node).len());
1307        }
1308        let mut cursor = node.walk();
1309        for child in node.children(&mut cursor) {
1310            find_call_arg_counts(child, out);
1311        }
1312    }
1313
1314    fn find_arg_positions(node: Node, out: &mut Vec<(usize, usize)>) {
1315        if node.kind() == "call_expression" {
1316            for arg in ts_call_arguments(node) {
1317                let p = arg.start_position();
1318                out.push((p.row, p.column));
1319            }
1320        }
1321        let mut cursor = node.walk();
1322        for child in node.children(&mut cursor) {
1323            find_arg_positions(child, out);
1324        }
1325    }
1326
1327    #[test]
1328    fn test_ts_find_call_at_byte_first_arg() {
1329        let source = r#"
1330contract Foo {
1331    function bar(uint x, uint y) public {}
1332    function test() public {
1333        bar(42, 99);
1334    }
1335}
1336"#;
1337        let tree = ts_parse(source).unwrap();
1338        // "42" is the first argument — find its byte offset
1339        let pos_42 = source.find("42").unwrap();
1340        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_42).unwrap();
1341        assert_eq!(ctx.name, "bar");
1342        assert_eq!(ctx.arg_index, 0);
1343        assert_eq!(ctx.arg_count, 2);
1344    }
1345
1346    #[test]
1347    fn test_ts_find_call_at_byte_second_arg() {
1348        let source = r#"
1349contract Foo {
1350    function bar(uint x, uint y) public {}
1351    function test() public {
1352        bar(42, 99);
1353    }
1354}
1355"#;
1356        let tree = ts_parse(source).unwrap();
1357        let pos_99 = source.find("99").unwrap();
1358        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_99).unwrap();
1359        assert_eq!(ctx.name, "bar");
1360        assert_eq!(ctx.arg_index, 1);
1361        assert_eq!(ctx.arg_count, 2);
1362    }
1363
1364    #[test]
1365    fn test_ts_find_call_at_byte_outside_call_returns_none() {
1366        let source = r#"
1367contract Foo {
1368    function bar(uint x) public {}
1369    function test() public {
1370        uint z = 10;
1371        bar(42);
1372    }
1373}
1374"#;
1375        let tree = ts_parse(source).unwrap();
1376        // "10" is a local variable assignment, not a call argument
1377        let pos_10 = source.find("10").unwrap();
1378        assert!(ts_find_call_at_byte(tree.root_node(), source, pos_10).is_none());
1379    }
1380
1381    #[test]
1382    fn test_ts_find_call_at_byte_member_call() {
1383        let source = r#"
1384contract Foo {
1385    function test() public {
1386        PRICE.addTax(TAX, TAX_BASE);
1387    }
1388}
1389"#;
1390        let tree = ts_parse(source).unwrap();
1391        let pos_tax = source.find("TAX,").unwrap();
1392        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tax).unwrap();
1393        assert_eq!(ctx.name, "addTax");
1394        assert_eq!(ctx.arg_index, 0);
1395        assert_eq!(ctx.arg_count, 2);
1396    }
1397
1398    #[test]
1399    fn test_ts_find_call_at_byte_emit_statement() {
1400        let source = r#"
1401contract Foo {
1402    event Purchase(address buyer, uint256 price);
1403    function test() public {
1404        emit Purchase(msg.sender, 100);
1405    }
1406}
1407"#;
1408        let tree = ts_parse(source).unwrap();
1409        let pos_100 = source.find("100").unwrap();
1410        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_100).unwrap();
1411        assert_eq!(ctx.name, "Purchase");
1412        assert_eq!(ctx.arg_index, 1);
1413        assert_eq!(ctx.arg_count, 2);
1414    }
1415
1416    #[test]
1417    fn test_ts_find_call_at_byte_multiline() {
1418        let source = r#"
1419contract Foo {
1420    function bar(uint x, uint y, uint z) public {}
1421    function test() public {
1422        bar(
1423            1,
1424            2,
1425            3
1426        );
1427    }
1428}
1429"#;
1430        let tree = ts_parse(source).unwrap();
1431        // Find "2" — the second argument on its own line
1432        // Need to be careful since "2" appears in the source in multiple places
1433        let pos_2 = source.find("            2").unwrap() + 12; // skip whitespace
1434        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_2).unwrap();
1435        assert_eq!(ctx.name, "bar");
1436        assert_eq!(ctx.arg_index, 1);
1437        assert_eq!(ctx.arg_count, 3);
1438    }
1439
1440    #[test]
1441    fn test_resolve_callsite_param_basic() {
1442        // Build a HintLookup manually with a known call site
1443        let mut lookup = HintLookup {
1444            by_offset: HashMap::new(),
1445            by_name: HashMap::new(),
1446        };
1447        lookup.by_name.insert(
1448            ("transfer".to_string(), 2),
1449            CallSite {
1450                info: ParamInfo {
1451                    names: vec!["to".to_string(), "amount".to_string()],
1452                    skip: 0,
1453                },
1454                name: "transfer".to_string(),
1455                decl_id: 42,
1456            },
1457        );
1458
1459        // Resolve first argument
1460        let result = lookup.resolve_callsite_param(0, "transfer", 2, 0).unwrap();
1461        assert_eq!(result.param_name, "to");
1462        assert_eq!(result.decl_id, 42);
1463
1464        // Resolve second argument
1465        let result = lookup.resolve_callsite_param(0, "transfer", 2, 1).unwrap();
1466        assert_eq!(result.param_name, "amount");
1467        assert_eq!(result.decl_id, 42);
1468    }
1469
1470    #[test]
1471    fn test_resolve_callsite_param_with_skip() {
1472        // Simulate a using-for library call where skip=1
1473        let mut lookup = HintLookup {
1474            by_offset: HashMap::new(),
1475            by_name: HashMap::new(),
1476        };
1477        lookup.by_name.insert(
1478            ("addTax".to_string(), 2),
1479            CallSite {
1480                info: ParamInfo {
1481                    names: vec!["self".to_string(), "tax".to_string(), "base".to_string()],
1482                    skip: 1,
1483                },
1484                name: "addTax".to_string(),
1485                decl_id: 99,
1486            },
1487        );
1488
1489        // First arg maps to param index 1 (skip=1), which is "tax"
1490        let result = lookup.resolve_callsite_param(0, "addTax", 2, 0).unwrap();
1491        assert_eq!(result.param_name, "tax");
1492
1493        // Second arg maps to param index 2, which is "base"
1494        let result = lookup.resolve_callsite_param(0, "addTax", 2, 1).unwrap();
1495        assert_eq!(result.param_name, "base");
1496    }
1497
1498    #[test]
1499    fn test_resolve_callsite_param_out_of_bounds() {
1500        let mut lookup = HintLookup {
1501            by_offset: HashMap::new(),
1502            by_name: HashMap::new(),
1503        };
1504        lookup.by_name.insert(
1505            ("foo".to_string(), 1),
1506            CallSite {
1507                info: ParamInfo {
1508                    names: vec!["x".to_string()],
1509                    skip: 0,
1510                },
1511                name: "foo".to_string(),
1512                decl_id: 1,
1513            },
1514        );
1515
1516        // Arg index 1 is out of bounds for a single-param function
1517        assert!(lookup.resolve_callsite_param(0, "foo", 1, 1).is_none());
1518    }
1519
1520    #[test]
1521    fn test_resolve_callsite_param_unknown_function() {
1522        let lookup = HintLookup {
1523            by_offset: HashMap::new(),
1524            by_name: HashMap::new(),
1525        };
1526        assert!(lookup.resolve_callsite_param(0, "unknown", 1, 0).is_none());
1527    }
1528
1529    #[test]
1530    fn test_ts_find_call_at_byte_emit_member_access() {
1531        // Simulates: emit ModifyLiquidity(id, msg.sender, params.tickLower, ...);
1532        // Hovering on "tickLower" (the member name in params.tickLower) should
1533        // resolve to arg_index=2 of the ModifyLiquidity emit.
1534        let source = r#"
1535contract Foo {
1536    event ModifyLiquidity(uint id, address sender, int24 tickLower, int24 tickUpper);
1537    function test() public {
1538        emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper);
1539    }
1540}
1541"#;
1542        let tree = ts_parse(source).unwrap();
1543        // Find "tickLower" inside "params.tickLower" — the first occurrence after "params."
1544        let params_tick = source.find("params.tickLower,").unwrap();
1545        let tick_lower_pos = params_tick + "params.".len(); // points at "tickLower"
1546
1547        let ctx = ts_find_call_at_byte(tree.root_node(), source, tick_lower_pos).unwrap();
1548        assert_eq!(ctx.name, "ModifyLiquidity");
1549        assert_eq!(
1550            ctx.arg_index, 2,
1551            "params.tickLower is the 3rd argument (index 2)"
1552        );
1553        assert_eq!(ctx.arg_count, 4);
1554    }
1555
1556    #[test]
1557    fn test_ts_find_call_at_byte_member_access_on_property() {
1558        // Hovering on "sender" in "msg.sender" as an argument
1559        let source = r#"
1560contract Foo {
1561    event Transfer(address from, address to);
1562    function test() public {
1563        emit Transfer(msg.sender, addr);
1564    }
1565}
1566"#;
1567        let tree = ts_parse(source).unwrap();
1568        let sender_pos = source.find("sender").unwrap();
1569        let ctx = ts_find_call_at_byte(tree.root_node(), source, sender_pos).unwrap();
1570        assert_eq!(ctx.name, "Transfer");
1571        assert_eq!(ctx.arg_index, 0, "msg.sender is the 1st argument");
1572    }
1573
1574    #[test]
1575    fn test_ts_find_call_at_byte_emit_all_args() {
1576        // Verify each argument position in an emit with member accesses
1577        let source = r#"
1578contract Foo {
1579    event ModifyLiquidity(uint id, address sender, int24 tickLower, int24 tickUpper);
1580    function test() public {
1581        emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper);
1582    }
1583}
1584"#;
1585        let tree = ts_parse(source).unwrap();
1586
1587        // arg 0: "id"
1588        let pos_id = source.find("(id,").unwrap() + 1;
1589        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_id).unwrap();
1590        assert_eq!(ctx.name, "ModifyLiquidity");
1591        assert_eq!(ctx.arg_index, 0);
1592
1593        // arg 1: "msg.sender" — hover on "msg"
1594        let pos_msg = source.find("msg.sender").unwrap();
1595        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_msg).unwrap();
1596        assert_eq!(ctx.arg_index, 1);
1597
1598        // arg 2: "params.tickLower" — hover on "tickLower"
1599        let pos_tl = source.find("params.tickLower").unwrap() + "params.".len();
1600        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tl).unwrap();
1601        assert_eq!(ctx.arg_index, 2);
1602
1603        // arg 3: "params.tickUpper" — hover on "params"
1604        let pos_tu = source.find("params.tickUpper").unwrap();
1605        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_tu).unwrap();
1606        assert_eq!(ctx.arg_index, 3);
1607    }
1608
1609    #[test]
1610    fn test_ts_find_call_at_byte_nested_call_arg() {
1611        // When an argument is itself a function call, hovering inside
1612        // the inner call should find the inner call, not the outer.
1613        let source = r#"
1614contract Foo {
1615    function inner(uint x) public returns (uint) {}
1616    function outer(uint a, uint b) public {}
1617    function test() public {
1618        outer(inner(42), 99);
1619    }
1620}
1621"#;
1622        let tree = ts_parse(source).unwrap();
1623
1624        // "42" is an arg to inner(), not outer()
1625        let pos_42 = source.find("42").unwrap();
1626        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_42).unwrap();
1627        assert_eq!(ctx.name, "inner");
1628        assert_eq!(ctx.arg_index, 0);
1629
1630        // "99" is an arg to outer()
1631        let pos_99 = source.find("99").unwrap();
1632        let ctx = ts_find_call_at_byte(tree.root_node(), source, pos_99).unwrap();
1633        assert_eq!(ctx.name, "outer");
1634        assert_eq!(ctx.arg_index, 1);
1635    }
1636
1637    #[test]
1638    fn test_ts_find_call_for_signature_incomplete_call() {
1639        // Cursor right after `(` with no arguments yet
1640        let source = r#"
1641contract Foo {
1642    function bar(uint x, uint y) public {}
1643    function test() public {
1644        bar(
1645    }
1646}
1647"#;
1648        let tree = ts_parse(source).unwrap();
1649        let pos = source.find("bar(").unwrap() + 4;
1650        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1651        assert_eq!(ctx.name, "bar");
1652        assert_eq!(ctx.arg_index, 0);
1653    }
1654
1655    #[test]
1656    fn test_ts_find_call_for_signature_after_comma() {
1657        // Cursor right after `,` — on second argument
1658        let source = r#"
1659contract Foo {
1660    function bar(uint x, uint y) public {}
1661    function test() public {
1662        bar(42,
1663    }
1664}
1665"#;
1666        let tree = ts_parse(source).unwrap();
1667        let pos = source.find("42,").unwrap() + 3;
1668        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1669        assert_eq!(ctx.name, "bar");
1670        assert_eq!(ctx.arg_index, 1);
1671    }
1672
1673    #[test]
1674    fn test_ts_find_call_for_signature_complete_call() {
1675        // Normal complete call still works
1676        let source = r#"
1677contract Foo {
1678    function bar(uint x, uint y) public {}
1679    function test() public {
1680        bar(42, 99);
1681    }
1682}
1683"#;
1684        let tree = ts_parse(source).unwrap();
1685        let pos = source.find("42").unwrap();
1686        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1687        assert_eq!(ctx.name, "bar");
1688        assert_eq!(ctx.arg_index, 0);
1689    }
1690
1691    #[test]
1692    fn test_ts_find_call_for_signature_member_call() {
1693        // Member access call like PRICE.addTax(
1694        let source = r#"
1695contract Foo {
1696    function test() public {
1697        PRICE.addTax(
1698    }
1699}
1700"#;
1701        let tree = ts_parse(source).unwrap();
1702        let pos = source.find("addTax(").unwrap() + 7;
1703        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1704        assert_eq!(ctx.name, "addTax");
1705        assert_eq!(ctx.arg_index, 0);
1706    }
1707
1708    #[test]
1709    fn test_ts_find_call_for_signature_array_access() {
1710        // Mapping index access like orders[orderId]
1711        let source = r#"
1712contract Foo {
1713    mapping(bytes32 => uint256) public orders;
1714    function test() public {
1715        orders[orderId];
1716    }
1717}
1718"#;
1719        let tree = ts_parse(source).unwrap();
1720        // Cursor inside the brackets, on "orderId"
1721        let pos = source.find("[orderId]").unwrap() + 1;
1722        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1723        assert_eq!(ctx.name, "orders");
1724        assert_eq!(ctx.arg_index, 0);
1725        assert!(ctx.is_index_access);
1726    }
1727
1728    #[test]
1729    fn test_ts_find_call_for_signature_array_access_empty() {
1730        // Cursor right after `[` with no key yet
1731        let source = r#"
1732contract Foo {
1733    mapping(bytes32 => uint256) public orders;
1734    function test() public {
1735        orders[
1736    }
1737}
1738"#;
1739        let tree = ts_parse(source).unwrap();
1740        let pos = source.find("orders[").unwrap() + 7;
1741        let ctx = ts_find_call_for_signature(tree.root_node(), source, pos).unwrap();
1742        assert_eq!(ctx.name, "orders");
1743        assert!(ctx.is_index_access);
1744    }
1745}