Skip to main content

solidity_language_server/
goto.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4use tower_lsp::lsp_types::{Location, Position, Range, TextEdit, Url};
5use tree_sitter::{Node, Parser};
6
7use crate::types::{
8    AbsPath, FileId, NodeId, PathInterner, RelPath, SolcFileId, SourceLoc, SrcLocation,
9};
10use crate::utils::push_if_node_or_array;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct NodeInfo {
14    pub src: SrcLocation,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub name_location: Option<String>,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub name_locations: Vec<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub referenced_declaration: Option<NodeId>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub node_type: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub member_location: Option<String>,
25    /// The `memberName` field from `MemberAccess` AST nodes.
26    /// Used to detect low-level calls (`.call`, `.staticcall`, `.delegatecall`)
27    /// which have `referencedDeclaration: null` in the AST.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub member_name: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub absolute_path: Option<String>,
32    /// The AST `scope` field — the node ID of the containing declaration
33    /// (contract, library, interface, function, etc.). Used to resolve the
34    /// qualifier in qualified type paths like `Pool.State` where `scope`
35    /// on the `State` struct points to the `Pool` library.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub scope: Option<NodeId>,
38    /// The AST `baseFunctions` field — node IDs of interface/parent
39    /// function declarations that this function implements or overrides.
40    /// Present on `FunctionDefinition`, `ModifierDefinition`, and
41    /// `VariableDeclaration` nodes. Used to build the bidirectional
42    /// `base_function_implementation` index for interface ↔ implementation
43    /// equivalence in references and call hierarchy.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub base_functions: Vec<NodeId>,
46}
47
48/// All AST child keys to traverse (Solidity + Yul).
49pub const CHILD_KEYS: &[&str] = &[
50    "AST",
51    "arguments",
52    "baseContracts",
53    "baseExpression",
54    "baseName",
55    "baseType",
56    "block",
57    "body",
58    "clauses",
59    "components",
60    "condition",
61    "declarations",
62    "endExpression",
63    "errorCall",
64    "eventCall",
65    "expression",
66    "externalCall",
67    "falseBody",
68    "falseExpression",
69    "file",
70    "foreign",
71    "functionName",
72    "indexExpression",
73    "initialValue",
74    "initializationExpression",
75    "keyType",
76    "leftExpression",
77    "leftHandSide",
78    "libraryName",
79    "literals",
80    "loopExpression",
81    "members",
82    "modifierName",
83    "modifiers",
84    "name",
85    "names",
86    "nodes",
87    "options",
88    "overrides",
89    "parameters",
90    "pathNode",
91    "post",
92    "pre",
93    "returnParameters",
94    "rightExpression",
95    "rightHandSide",
96    "startExpression",
97    "statements",
98    "storageLayout",
99    "subExpression",
100    "subdenomination",
101    "symbolAliases",
102    "trueBody",
103    "trueExpression",
104    "typeName",
105    "unitAlias",
106    "value",
107    "valueType",
108    "variableNames",
109    "variables",
110];
111
112/// Maps `"offset:length:fileId"` src strings from Yul externalReferences
113/// to the Solidity declaration node id they refer to.
114pub type ExternalRefs = HashMap<SrcLocation, NodeId>;
115
116/// Storage slot information for a state variable.
117#[derive(Debug, Clone)]
118pub struct StorageSlotInfo {
119    /// Storage slot number (e.g. "0", "1", "6").
120    pub slot: String,
121    /// Byte offset within the slot (0 unless packed).
122    pub offset: u32,
123}
124
125/// Pre-computed AST index. Built once when an AST enters the cache,
126/// then reused on every goto/references/rename/hover request.
127///
128/// All data from the raw solc JSON is consumed during `new()` into
129/// pre-built indexes. The raw JSON is not retained.
130#[derive(Debug, Clone)]
131pub struct CachedBuild {
132    pub nodes: HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
133    pub path_to_abs: HashMap<RelPath, AbsPath>,
134    pub external_refs: ExternalRefs,
135    pub id_to_path_map: HashMap<crate::types::SolcFileId, String>,
136    /// O(1) typed declaration node lookup by AST node ID.
137    /// Built from the typed AST via visitor. Contains functions, variables,
138    /// contracts, events, errors, structs, enums, modifiers, and UDVTs.
139    pub decl_index: HashMap<NodeId, crate::solc_ast::DeclNode>,
140    /// O(1) lookup from any declaration/source-unit node ID to its source file path.
141    /// Built from `typed_ast` during construction. Replaces the O(N)
142    /// `find_source_path_for_node()` that walked raw JSON.
143    pub node_id_to_source_path: HashMap<NodeId, AbsPath>,
144    /// Pre-built hint lookup per file. Built once, reused on every
145    /// inlay hint request (avoids O(n²) declaration resolution per request).
146    pub hint_index: crate::inlay_hints::HintIndex,
147    /// Pre-built documentation index from solc userdoc/devdoc.
148    /// Merged and keyed by selector for fast hover lookup.
149    pub doc_index: crate::hover::DocIndex,
150    /// Pre-built completion cache. Built from sources during construction
151    /// before the sources key is stripped.
152    pub completion_cache: std::sync::Arc<crate::completion::CompletionCache>,
153    /// The text_cache version this build was created from.
154    /// Used to detect dirty files (unsaved edits since last build).
155    pub build_version: i32,
156    /// Qualifier reference index: maps a container declaration ID
157    /// (contract/library/interface) to `IdentifierPath` node IDs that use
158    /// it as a qualifier prefix in qualified type paths (e.g., `Pool.State`).
159    ///
160    /// Built at cache time by following `referencedDeclaration` on multi-segment
161    /// `IdentifierPath` nodes to their declaration, then reading the declaration's
162    /// `scope` field to find the container.
163    pub qualifier_refs: HashMap<NodeId, Vec<NodeId>>,
164    /// Bidirectional implementation index built from `baseFunctions`/`baseModifiers`.
165    ///
166    /// Maps each declaration ID to the set of IDs that are semantically
167    /// equivalent (interface ↔ implementation). For example, if
168    /// `PoolManager.swap` (616) has `baseFunctions: [2036]` (IPoolManager.swap),
169    /// this will contain `616 → [2036]` and `2036 → [616]`.
170    ///
171    /// Used by `textDocument/implementation`, `textDocument/references`, and
172    /// `callHierarchy/incomingCalls` to unify interface and implementation IDs.
173    /// Empty in warm-loaded builds (`from_reference_index`).
174    pub base_function_implementation: HashMap<NodeId, Vec<NodeId>>,
175    /// Storage layout index: maps state variable AST node IDs to their
176    /// storage slot and byte offset within that slot.
177    ///
178    /// Built from `contracts[path][name].storageLayout.storage[]` entries.
179    /// The `astId` in each entry matches the `NodeId` of the corresponding
180    /// `VariableDeclaration` in the AST (within the same compilation).
181    ///
182    /// Offset is non-zero only for packed variables (e.g. two `bool`s
183    /// sharing the same slot).
184    pub storage_layout: HashMap<NodeId, StorageSlotInfo>,
185    /// Low-level external calls from Yul inline assembly.
186    ///
187    /// Contains `call`, `staticcall`, `delegatecall` Yul opcodes which
188    /// have no `id` field and are not in the `nodes` index.
189    /// Solidity-level `MemberAccess` low-level calls are in `nodes` and
190    /// detected at query time via `NodeInfo.member_name`.
191    pub low_level_calls: Vec<LowLevelCall>,
192}
193
194impl CachedBuild {
195    /// Build the index from normalized AST output.
196    ///
197    /// Canonical shape:
198    /// - `sources[path] = { id, ast }`
199    /// - `contracts[path][name] = { evm, devdoc, ... }`
200    /// - `source_id_to_path = { "0": "path", ... }`
201    ///
202    /// When `interner` is provided, solc's per-compilation file IDs in all
203    /// `src` strings are translated into canonical IDs from the project-wide
204    /// [`PathInterner`].  This ensures all `CachedBuild` instances share the
205    /// same file-ID space regardless of which solc invocation produced them.
206    pub fn new(ast: Value, build_version: i32, interner: Option<&mut PathInterner>) -> Self {
207        let (mut nodes, path_to_abs, mut external_refs, mut low_level_calls) =
208            if let Some(sources) = ast.get("sources") {
209                cache_ids(sources)
210            } else {
211                (HashMap::new(), HashMap::new(), HashMap::new(), Vec::new())
212            };
213
214        // Parse solc's source_id_to_path from the AST output.
215        let solc_id_to_path: HashMap<SolcFileId, String> = ast
216            .get("source_id_to_path")
217            .and_then(|v| v.as_object())
218            .map(|obj| {
219                obj.iter()
220                    .map(|(k, v)| {
221                        (
222                            SolcFileId::new(k.clone()),
223                            v.as_str().unwrap_or("").to_string(),
224                        )
225                    })
226                    .collect()
227            })
228            .unwrap_or_default();
229
230        // When an interner is available, canonicalize file IDs in all src
231        // strings so that merges across different compilations are safe.
232        // `canonical_remap` is kept around so that `build_completion_cache`
233        // can translate its own file IDs in the same way.
234        let (id_to_path_map, canonical_remap) = if let Some(interner) = interner {
235            let remap = interner.build_remap(&solc_id_to_path);
236
237            // Rewrite all NodeInfo src strings.
238            for file_nodes in nodes.values_mut() {
239                for info in file_nodes.values_mut() {
240                    canonicalize_node_info(info, &remap);
241                }
242            }
243
244            // Rewrite external ref src strings.
245            let old_refs = std::mem::take(&mut external_refs);
246            for (src, decl_id) in old_refs {
247                let new_src = SrcLocation::new(remap_src_canonical(src.as_str(), &remap));
248                external_refs.insert(new_src, decl_id);
249            }
250
251            // Build id_to_path_map from canonical IDs.
252            (interner.to_id_to_path_map(), Some(remap))
253        } else {
254            // No interner — use solc's original mapping (legacy path).
255            (solc_id_to_path, None)
256        };
257
258        let doc_index = crate::hover::build_doc_index(&ast);
259
260        // Extract declaration nodes directly from the raw sources JSON.
261        // Instead of deserializing the entire typed AST (SourceUnit, all
262        // expressions, statements, Yul blocks), this walks the raw Value
263        // tree and only deserializes nodes whose nodeType matches one of the
264        // 9 declaration types. Heavy fields (body, modifiers, value, etc.)
265        // are stripped before deserialization.
266        let (decl_index, node_id_to_source_path) = if let Some(sources) = ast.get("sources") {
267            match crate::solc_ast::extract_decl_nodes(sources) {
268                Some(extracted) => (
269                    extracted
270                        .decl_index
271                        .into_iter()
272                        .map(|(id, decl)| (NodeId(id), decl))
273                        .collect(),
274                    extracted
275                        .node_id_to_source_path
276                        .into_iter()
277                        .map(|(id, path)| (NodeId(id), AbsPath::new(path)))
278                        .collect(),
279                ),
280                None => (HashMap::new(), HashMap::new()),
281            }
282        } else {
283            (HashMap::new(), HashMap::new())
284        };
285
286        // Build constructor index and hint index from the typed decl_index.
287        let constructor_index = crate::inlay_hints::build_constructor_index(&decl_index);
288        let hint_index = if let Some(sources) = ast.get("sources") {
289            crate::inlay_hints::build_hint_index(sources, &decl_index, &constructor_index)
290        } else {
291            HashMap::new()
292        };
293
294        // Build completion cache before stripping sources.
295        let completion_cache = {
296            let sources = ast.get("sources");
297            let contracts = ast.get("contracts");
298            let cc = if let Some(s) = sources {
299                crate::completion::build_completion_cache(s, contracts, canonical_remap.as_ref())
300            } else {
301                crate::completion::build_completion_cache(
302                    &serde_json::Value::Object(Default::default()),
303                    contracts,
304                    canonical_remap.as_ref(),
305                )
306            };
307            std::sync::Arc::new(cc)
308        };
309
310        // Build the qualifier reference index: for each multi-segment
311        // IdentifierPath (e.g., `Pool.State`), follow referencedDeclaration
312        // to the declaration node, then read its `scope` to find the
313        // container (contract/library/interface). Map container_id → [node_id].
314        let qualifier_refs = build_qualifier_refs(&nodes);
315
316        // Build the bidirectional implementation index from base_functions on NodeInfo.
317        // Works uniformly on both fresh and warm-loaded builds since NodeInfo
318        // now persists the base_functions field.
319        let base_function_implementation = build_base_function_implementation(&nodes);
320
321        // Build storage layout index from contracts[path][name].storageLayout.storage[].
322        let storage_layout = build_storage_layout(&ast);
323
324        // Canonicalize Yul low-level call src strings if interner is active.
325        if let Some(ref remap) = canonical_remap {
326            for llc in &mut low_level_calls {
327                llc.src = SrcLocation::new(remap_src_canonical(llc.src.as_str(), remap));
328                llc.name_src = SrcLocation::new(remap_src_canonical(llc.name_src.as_str(), remap));
329            }
330        }
331
332        // The raw AST JSON is fully consumed — all data has been extracted
333        // into the pre-built indexes above. `ast` is dropped here.
334
335        Self {
336            nodes,
337            path_to_abs,
338            external_refs,
339            id_to_path_map,
340            decl_index,
341            node_id_to_source_path,
342            hint_index,
343            doc_index,
344            completion_cache,
345            build_version,
346            qualifier_refs,
347            base_function_implementation,
348            storage_layout,
349            low_level_calls,
350        }
351    }
352
353    /// Absorb data from a previous build for files this build doesn't cover.
354    ///
355    /// For each file in `other.nodes` that is **not** already present in
356    /// `self.nodes`, copies the node map, path mapping, and any related
357    /// entries.  This ensures a freshly compiled project index never loses
358    /// coverage compared to the warm-loaded cache it replaces.
359    pub fn merge_missing_from(&mut self, other: &CachedBuild) {
360        for (abs_path, file_nodes) in &other.nodes {
361            if !self.nodes.contains_key(abs_path) {
362                self.nodes.insert(abs_path.clone(), file_nodes.clone());
363            }
364        }
365        for (k, v) in &other.path_to_abs {
366            self.path_to_abs
367                .entry(k.clone())
368                .or_insert_with(|| v.clone());
369        }
370        for (k, v) in &other.external_refs {
371            self.external_refs.entry(k.clone()).or_insert(*v);
372        }
373        for (k, v) in &other.id_to_path_map {
374            self.id_to_path_map
375                .entry(k.clone())
376                .or_insert_with(|| v.clone());
377        }
378        // Merge qualifier_refs: for each container, add any qualifier node
379        // IDs from `other` that aren't already present in `self`.
380        for (container_id, other_qrefs) in &other.qualifier_refs {
381            let entry = self.qualifier_refs.entry(*container_id).or_default();
382            for &qnode_id in other_qrefs {
383                if !entry.contains(&qnode_id) {
384                    entry.push(qnode_id);
385                }
386            }
387        }
388        // Merge base_function_implementation: add any equivalences from `other`
389        // that aren't already present in `self`.
390        for (node_id, other_impls) in &other.base_function_implementation {
391            let entry = self
392                .base_function_implementation
393                .entry(*node_id)
394                .or_default();
395            for &impl_id in other_impls {
396                if !entry.contains(&impl_id) {
397                    entry.push(impl_id);
398                }
399            }
400        }
401        // Merge storage layout: keep self's entries, add missing from other.
402        for (node_id, info) in &other.storage_layout {
403            self.storage_layout
404                .entry(*node_id)
405                .or_insert_with(|| info.clone());
406        }
407    }
408
409    /// Construct a minimal cached build from persisted reference/goto indexes.
410    ///
411    /// This is used for fast startup warm-cache restores where we only need
412    /// cross-file node/reference maps (not full gas/doc/hint indexes).
413    ///
414    /// When `interner` is provided, the `id_to_path_map` entries are
415    /// registered in the interner so that subsequent compilations will
416    /// assign the same canonical IDs to the same paths.
417    pub fn from_reference_index(
418        nodes: HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
419        path_to_abs: HashMap<RelPath, AbsPath>,
420        external_refs: ExternalRefs,
421        id_to_path_map: HashMap<SolcFileId, String>,
422        build_version: i32,
423        interner: Option<&mut PathInterner>,
424    ) -> Self {
425        // Seed the interner with paths from the persisted cache so that
426        // canonical IDs remain consistent across restart.
427        if let Some(interner) = interner {
428            for path in id_to_path_map.values() {
429                interner.intern(path);
430            }
431        }
432
433        let completion_cache = std::sync::Arc::new(crate::completion::build_completion_cache(
434            &serde_json::Value::Object(Default::default()),
435            None,
436            None,
437        ));
438
439        // Build qualifier refs from the warm-loaded nodes.
440        let qualifier_refs = build_qualifier_refs(&nodes);
441
442        // Build base_function_implementation from the warm-loaded nodes.
443        // NodeInfo now persists `base_functions`, so this works on warm loads.
444        let base_function_implementation = build_base_function_implementation(&nodes);
445
446        Self {
447            nodes,
448            path_to_abs,
449            external_refs,
450            id_to_path_map,
451            decl_index: HashMap::new(),
452            node_id_to_source_path: HashMap::new(),
453            hint_index: HashMap::new(),
454            doc_index: HashMap::new(),
455            completion_cache,
456            build_version,
457            qualifier_refs,
458            base_function_implementation,
459            storage_layout: HashMap::new(),
460            low_level_calls: Vec::new(),
461        }
462    }
463}
464
465/// Build the storage layout index from solc's `contracts` output.
466///
467/// Walks `contracts[path][name].storageLayout.storage[]` and maps each
468/// entry's `astId` (which matches the AST `VariableDeclaration` node ID)
469/// to its slot and byte offset.
470fn build_storage_layout(ast: &Value) -> HashMap<NodeId, StorageSlotInfo> {
471    let mut map = HashMap::new();
472    let contracts = match ast.get("contracts").and_then(|v| v.as_object()) {
473        Some(c) => c,
474        None => return map,
475    };
476    for (_path, contracts_in_file) in contracts {
477        let file_contracts = match contracts_in_file.as_object() {
478            Some(c) => c,
479            None => continue,
480        };
481        for (_name, contract_data) in file_contracts {
482            // Process both storageLayout and transientStorageLayout
483            for key in &["storageLayout", "transientStorageLayout"] {
484                let entries = match contract_data
485                    .get(*key)
486                    .and_then(|v| v.get("storage"))
487                    .and_then(|v| v.as_array())
488                {
489                    Some(arr) => arr,
490                    None => continue,
491                };
492                for entry in entries {
493                    let ast_id = match entry.get("astId").and_then(|v| v.as_i64()) {
494                        Some(id) => id,
495                        None => continue,
496                    };
497                    let slot = entry
498                        .get("slot")
499                        .and_then(|v| v.as_str())
500                        .unwrap_or("0")
501                        .to_string();
502                    let offset = entry.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
503                    map.insert(NodeId(ast_id), StorageSlotInfo { slot, offset });
504                }
505            }
506        }
507    }
508    map
509}
510
511/// Build the qualifier reference index from the cached node maps.
512///
513/// For each `IdentifierPath` node with `nameLocations.len() > 1` (i.e., a
514/// qualified path like `Pool.State`), follows `referencedDeclaration` to the
515/// declaration node, reads its `scope` field (the containing contract /
516/// library / interface), and records `scope_id → [identifierpath_node_id]`.
517///
518/// This allows "Find All References" on a container to include qualified
519/// type references where the container appears as a prefix.
520fn build_qualifier_refs(
521    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
522) -> HashMap<NodeId, Vec<NodeId>> {
523    // First pass: collect all nodes' scope fields into a lookup table.
524    // We need this to look up the scope of a referencedDeclaration target.
525    let mut node_scope: HashMap<NodeId, NodeId> = HashMap::new();
526    for file_nodes in nodes.values() {
527        for (id, info) in file_nodes {
528            if let Some(scope_id) = info.scope {
529                node_scope.insert(*id, scope_id);
530            }
531        }
532    }
533
534    // Second pass: for each multi-segment IdentifierPath, resolve the
535    // container via referencedDeclaration → scope.
536    let mut qualifier_refs: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
537    for file_nodes in nodes.values() {
538        for (id, info) in file_nodes {
539            if info.name_locations.len() > 1
540                && info.node_type.as_deref() == Some("IdentifierPath")
541                && let Some(ref_decl) = info.referenced_declaration
542                && let Some(&scope_id) = node_scope.get(&ref_decl)
543            {
544                qualifier_refs.entry(scope_id).or_default().push(*id);
545            }
546        }
547    }
548
549    qualifier_refs
550}
551
552/// Build the bidirectional `base_function_implementation` index from nodes.
553///
554/// Scans all nodes for entries with non-empty `base_functions` (these are
555/// implementing/overriding function declarations). Creates bidirectional
556/// mappings: `impl_id → [base_ids]` (forward) and `base_id → [impl_id]`
557/// (reverse), so lookups work in both directions.
558///
559/// This function works on both fresh and warm-loaded builds because
560/// `base_functions` is persisted as part of `NodeInfo`.
561fn build_base_function_implementation(
562    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
563) -> HashMap<NodeId, Vec<NodeId>> {
564    let mut index: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
565
566    for file_nodes in nodes.values() {
567        for (id, info) in file_nodes {
568            if info.base_functions.is_empty() {
569                continue;
570            }
571            // Forward: impl_id → base_ids
572            for &base_id in &info.base_functions {
573                index.entry(*id).or_default().push(base_id);
574            }
575            // Reverse: base_id → impl_id
576            for &base_id in &info.base_functions {
577                index.entry(base_id).or_default().push(*id);
578            }
579        }
580    }
581
582    // Deduplicate values.
583    for ids in index.values_mut() {
584        ids.sort_unstable();
585        ids.dedup();
586    }
587
588    index
589}
590
591/// A low-level call detected in the AST that has no `referencedDeclaration`.
592///
593/// Covers Yul opcodes (`call`, `staticcall`, `delegatecall`) from inline
594/// assembly. These `YulFunctionCall` nodes have NO `id` field, so they
595/// are NOT in the `nodes` index — they are collected here instead.
596///
597/// Solidity-level `MemberAccess` low-level calls (e.g. `addr.call(...)`)
598/// DO have `id` fields and ARE in `nodes`. They are detected at query
599/// time via `NodeInfo.member_name` and are NOT duplicated here.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct LowLevelCall {
602    /// `"call"`, `"staticcall"`, or `"delegatecall"`.
603    pub kind: String,
604    /// The `src` string of the `YulFunctionCall` node (`"offset:length:fileId"`).
605    /// Covers the entire call expression including arguments.
606    pub src: SrcLocation,
607    /// The `src` of the `functionName` identifier (e.g. just `call`).
608    /// Used for `selection_range` so the cursor targets the opcode name.
609    pub name_src: SrcLocation,
610    /// Absolute path of the file containing this call.
611    pub abs_path: AbsPath,
612}
613
614/// The set of low-level call member names on `address` that have no
615/// `referencedDeclaration` in solc's AST.
616pub const LOW_LEVEL_CALL_NAMES: &[&str] = &["call", "staticcall", "delegatecall"];
617
618/// The set of Yul opcodes that perform external calls.
619/// Only these are flagged from `YulFunctionCall`; other Yul builtins
620/// (mstore, sload, add, …) are not call edges.
621pub const YUL_CALL_OPCODES: &[&str] = &["call", "staticcall", "delegatecall"];
622
623/// Return type of [`cache_ids`]: `(nodes, path_to_abs, external_refs, low_level_calls)`.
624type CachedIds = (
625    HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
626    HashMap<RelPath, AbsPath>,
627    ExternalRefs,
628    Vec<LowLevelCall>,
629);
630
631/// Rewrite the file-ID component of a `"offset:length:fileId"` string using
632/// a canonical remap table.  Returns the original string unchanged if the
633/// file ID is not in the remap or if the format is invalid.
634pub fn remap_src_canonical(src: &str, remap: &HashMap<u64, FileId>) -> String {
635    let Some(last_colon) = src.rfind(':') else {
636        return src.to_owned();
637    };
638    let old_id_str = &src[last_colon + 1..];
639    let Ok(old_id) = old_id_str.parse::<u64>() else {
640        return src.to_owned();
641    };
642    let Some(canonical) = remap.get(&old_id) else {
643        return src.to_owned();
644    };
645    if canonical.0 == old_id {
646        return src.to_owned();
647    }
648    format!("{}{}", &src[..=last_colon], canonical.0)
649}
650
651/// Rewrite all file-ID references in a [`NodeInfo`] using the canonical
652/// remap table.
653fn canonicalize_node_info(info: &mut NodeInfo, remap: &HashMap<u64, FileId>) {
654    info.src = SrcLocation::new(remap_src_canonical(info.src.as_str(), remap));
655    if let Some(loc) = info.name_location.as_mut() {
656        *loc = remap_src_canonical(loc, remap);
657    }
658    for loc in &mut info.name_locations {
659        *loc = remap_src_canonical(loc, remap);
660    }
661    if let Some(loc) = info.member_location.as_mut() {
662        *loc = remap_src_canonical(loc, remap);
663    }
664}
665
666pub fn cache_ids(sources: &Value) -> CachedIds {
667    let source_count = sources.as_object().map_or(0, |obj| obj.len());
668
669    // Pre-size top-level maps based on source file count to avoid rehashing.
670    // Typical project: ~200 nodes/file, ~10 external refs/file.
671    let mut nodes: HashMap<AbsPath, HashMap<NodeId, NodeInfo>> =
672        HashMap::with_capacity(source_count);
673    let mut path_to_abs: HashMap<RelPath, AbsPath> = HashMap::with_capacity(source_count);
674    let mut external_refs: ExternalRefs = HashMap::with_capacity(source_count * 10);
675    let mut low_level_calls: Vec<LowLevelCall> = Vec::new();
676
677    if let Some(sources_obj) = sources.as_object() {
678        for (path, source_data) in sources_obj {
679            if let Some(ast) = source_data.get("ast") {
680                // Get the absolute path for this file
681                let abs_path = AbsPath::new(
682                    ast.get("absolutePath")
683                        .and_then(|v| v.as_str())
684                        .unwrap_or(path)
685                        .to_string(),
686                );
687
688                path_to_abs.insert(RelPath::new(path), abs_path.clone());
689
690                // Initialize the per-file node map with a size hint.
691                // Use the top-level `nodes` array length as a proxy for total
692                // AST node count (actual count is higher due to nesting, but
693                // this avoids the first few rehashes).
694                let size_hint = ast
695                    .get("nodes")
696                    .and_then(|v| v.as_array())
697                    .map_or(64, |arr| arr.len() * 8);
698                if !nodes.contains_key(&abs_path) {
699                    nodes.insert(abs_path.clone(), HashMap::with_capacity(size_hint));
700                }
701
702                if let Some(id) = ast.get("id").and_then(|v| v.as_i64())
703                    && let Some(src) = ast.get("src").and_then(|v| v.as_str())
704                {
705                    nodes.get_mut(&abs_path).unwrap().insert(
706                        NodeId(id),
707                        NodeInfo {
708                            src: SrcLocation::new(src),
709                            name_location: None,
710                            name_locations: vec![],
711                            referenced_declaration: None,
712                            node_type: ast
713                                .get("nodeType")
714                                .and_then(|v| v.as_str())
715                                .map(|s| s.to_string()),
716                            member_location: None,
717                            member_name: None,
718                            absolute_path: ast
719                                .get("absolutePath")
720                                .and_then(|v| v.as_str())
721                                .map(|s| s.to_string()),
722                            scope: ast.get("scope").and_then(|v| v.as_i64()).map(NodeId),
723                            base_functions: vec![],
724                        },
725                    );
726                }
727
728                let mut stack = vec![ast];
729
730                while let Some(tree) = stack.pop() {
731                    if let Some(raw_id) = tree.get("id").and_then(|v| v.as_i64())
732                        && let Some(src) = tree.get("src").and_then(|v| v.as_str())
733                    {
734                        let id = NodeId(raw_id);
735                        // Check for nameLocation first
736                        let mut name_location = tree
737                            .get("nameLocation")
738                            .and_then(|v| v.as_str())
739                            .map(|s| s.to_string());
740
741                        // Check for nameLocations array and use appropriate element
742                        // For IdentifierPath (qualified names like D.State), use the last element (the actual identifier)
743                        // For other nodes, use the first element
744                        if name_location.is_none()
745                            && let Some(name_locations) = tree.get("nameLocations")
746                            && let Some(locations_array) = name_locations.as_array()
747                            && !locations_array.is_empty()
748                        {
749                            let node_type = tree.get("nodeType").and_then(|v| v.as_str());
750                            if node_type == Some("IdentifierPath") {
751                                name_location = locations_array
752                                    .last()
753                                    .and_then(|v| v.as_str())
754                                    .map(|s| s.to_string());
755                            } else {
756                                name_location = locations_array[0].as_str().map(|s| s.to_string());
757                            }
758                        }
759
760                        let name_locations = if let Some(name_locations) = tree.get("nameLocations")
761                            && let Some(locations_array) = name_locations.as_array()
762                        {
763                            locations_array
764                                .iter()
765                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
766                                .collect()
767                        } else {
768                            vec![]
769                        };
770
771                        let mut final_name_location = name_location;
772                        if final_name_location.is_none()
773                            && let Some(member_loc) =
774                                tree.get("memberLocation").and_then(|v| v.as_str())
775                        {
776                            final_name_location = Some(member_loc.to_string());
777                        }
778
779                        let node_info = NodeInfo {
780                            src: SrcLocation::new(src),
781                            name_location: final_name_location,
782                            name_locations,
783                            referenced_declaration: tree
784                                .get("referencedDeclaration")
785                                .and_then(|v| v.as_i64())
786                                .map(NodeId),
787                            node_type: tree
788                                .get("nodeType")
789                                .and_then(|v| v.as_str())
790                                .map(|s| s.to_string()),
791                            member_location: tree
792                                .get("memberLocation")
793                                .and_then(|v| v.as_str())
794                                .map(|s| s.to_string()),
795                            member_name: tree
796                                .get("memberName")
797                                .and_then(|v| v.as_str())
798                                .map(|s| s.to_string()),
799                            absolute_path: tree
800                                .get("absolutePath")
801                                .and_then(|v| v.as_str())
802                                .map(|s| s.to_string()),
803                            scope: tree.get("scope").and_then(|v| v.as_i64()).map(NodeId),
804                            base_functions: tree
805                                .get("baseFunctions")
806                                .and_then(|v| v.as_array())
807                                .map(|arr| {
808                                    arr.iter().filter_map(|v| v.as_i64().map(NodeId)).collect()
809                                })
810                                .unwrap_or_default(),
811                        };
812
813                        nodes.get_mut(&abs_path).unwrap().insert(id, node_info);
814
815                        // Collect externalReferences from InlineAssembly nodes
816                        if tree.get("nodeType").and_then(|v| v.as_str()) == Some("InlineAssembly")
817                            && let Some(ext_refs) =
818                                tree.get("externalReferences").and_then(|v| v.as_array())
819                        {
820                            for ext_ref in ext_refs {
821                                if let Some(src_str) = ext_ref.get("src").and_then(|v| v.as_str())
822                                    && let Some(decl_id) =
823                                        ext_ref.get("declaration").and_then(|v| v.as_i64())
824                                {
825                                    external_refs
826                                        .insert(SrcLocation::new(src_str), NodeId(decl_id));
827                                }
828                            }
829                        }
830                    }
831
832                    // Detect Yul call opcodes (call, staticcall, delegatecall).
833                    // YulFunctionCall nodes have no `id` field so they aren't
834                    // in the `nodes` index.  We check `functionName.name` and
835                    // only collect actual external-call opcodes, not
836                    // arithmetic/memory builtins like mstore, sload, add, etc.
837                    if tree.get("nodeType").and_then(|v| v.as_str()) == Some("YulFunctionCall")
838                        && let Some(fn_obj) = tree.get("functionName")
839                        && let Some(fn_name) = fn_obj.get("name").and_then(|v| v.as_str())
840                        && YUL_CALL_OPCODES.contains(&fn_name)
841                        && let Some(src) = tree.get("src").and_then(|v| v.as_str())
842                    {
843                        let fn_src = fn_obj.get("src").and_then(|v| v.as_str()).unwrap_or(src);
844                        low_level_calls.push(LowLevelCall {
845                            kind: fn_name.to_string(),
846                            src: SrcLocation::new(src),
847                            name_src: SrcLocation::new(fn_src),
848                            abs_path: abs_path.clone(),
849                        });
850                    }
851
852                    for key in CHILD_KEYS {
853                        push_if_node_or_array(tree, key, &mut stack);
854                    }
855                }
856            }
857        }
858    }
859
860    (nodes, path_to_abs, external_refs, low_level_calls)
861}
862
863pub fn pos_to_bytes(source_bytes: &[u8], position: Position) -> usize {
864    let text = String::from_utf8_lossy(source_bytes);
865    crate::utils::position_to_byte_offset(&text, position)
866}
867
868pub fn bytes_to_pos(source_bytes: &[u8], byte_offset: usize) -> Option<Position> {
869    let text = String::from_utf8_lossy(source_bytes);
870    let pos = crate::utils::byte_offset_to_position(&text, byte_offset);
871    Some(pos)
872}
873
874/// Convert a `"offset:length:fileId"` src string to an LSP Location.
875pub fn src_to_location(
876    src: &str,
877    id_to_path: &HashMap<crate::types::SolcFileId, String>,
878) -> Option<Location> {
879    let loc = SourceLoc::parse(src)?;
880    let file_path = id_to_path.get(&loc.file_id_str())?;
881
882    let absolute_path = if std::path::Path::new(file_path).is_absolute() {
883        std::path::PathBuf::from(file_path)
884    } else {
885        std::env::current_dir().ok()?.join(file_path)
886    };
887
888    let source_bytes = std::fs::read(&absolute_path).ok()?;
889    let start_pos = bytes_to_pos(&source_bytes, loc.offset)?;
890    let end_pos = bytes_to_pos(&source_bytes, loc.end())?;
891    let uri = Url::from_file_path(&absolute_path).ok()?;
892
893    Some(Location {
894        uri,
895        range: Range {
896            start: start_pos,
897            end: end_pos,
898        },
899    })
900}
901
902pub fn goto_bytes(
903    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
904    path_to_abs: &HashMap<RelPath, AbsPath>,
905    id_to_path: &HashMap<crate::types::SolcFileId, String>,
906    external_refs: &ExternalRefs,
907    uri: &str,
908    position: usize,
909) -> Option<(String, usize, usize)> {
910    let path = match uri.starts_with("file://") {
911        true => &uri[7..],
912        false => uri,
913    };
914
915    // Get absolute path for this file
916    let abs_path = path_to_abs.get(path)?;
917
918    // Get nodes for the current file only
919    let current_file_nodes = nodes.get(abs_path)?;
920
921    // Build reverse map: file_path -> file_id for filtering external refs by current file
922    let path_to_file_id: HashMap<&str, &crate::types::SolcFileId> =
923        id_to_path.iter().map(|(id, p)| (p.as_str(), id)).collect();
924
925    // Determine the file id for the current file
926    // path_to_abs maps filesystem path -> absolutePath (e.g. "src/libraries/SwapMath.sol")
927    // id_to_path maps file_id -> relative path (e.g. "34" -> "src/libraries/SwapMath.sol")
928    let current_file_id = path_to_file_id.get(abs_path.as_str());
929
930    // Check if cursor is on a Yul external reference first
931    for (src_str, decl_id) in external_refs {
932        let Some(src_loc) = SourceLoc::parse(src_str.as_str()) else {
933            continue;
934        };
935
936        // Only consider external refs in the current file
937        if let Some(file_id) = current_file_id {
938            if src_loc.file_id_str() != **file_id {
939                continue;
940            }
941        } else {
942            continue;
943        }
944
945        if src_loc.offset <= position && position < src_loc.end() {
946            // Found a Yul external reference — resolve to the declaration target
947            let mut target_node: Option<&NodeInfo> = None;
948            for file_nodes in nodes.values() {
949                if let Some(node) = file_nodes.get(decl_id) {
950                    target_node = Some(node);
951                    break;
952                }
953            }
954            let node = target_node?;
955            let loc_str = node.name_location.as_deref().unwrap_or(node.src.as_str());
956            let loc = SourceLoc::parse(loc_str)?;
957            let file_path = id_to_path.get(&loc.file_id_str())?.clone();
958            return Some((file_path, loc.offset, loc.length));
959        }
960    }
961
962    let mut refs = HashMap::new();
963
964    // Only consider nodes from the current file that have references
965    for (id, content) in current_file_nodes {
966        if content.referenced_declaration.is_none() {
967            continue;
968        }
969
970        let Some(src_loc) = SourceLoc::parse(content.src.as_str()) else {
971            continue;
972        };
973
974        if src_loc.offset <= position && position < src_loc.end() {
975            let diff = src_loc.length;
976            if !refs.contains_key(&diff) || refs[&diff] <= *id {
977                refs.insert(diff, *id);
978            }
979        }
980    }
981
982    if refs.is_empty() {
983        // Check if we're on the string part of an import statement
984        // ImportDirective nodes have absolutePath pointing to the imported file
985        let tmp = current_file_nodes.iter();
986        for (_id, content) in tmp {
987            if content.node_type == Some("ImportDirective".to_string()) {
988                let Some(src_loc) = SourceLoc::parse(content.src.as_str()) else {
989                    continue;
990                };
991
992                if src_loc.offset <= position
993                    && position < src_loc.end()
994                    && let Some(import_path) = &content.absolute_path
995                {
996                    return Some((import_path.clone(), 0, 0));
997                }
998            }
999        }
1000        return None;
1001    }
1002
1003    // Find the reference with minimum diff (most specific)
1004    let min_diff = *refs.keys().min()?;
1005    let chosen_id = refs[&min_diff];
1006    let ref_id = current_file_nodes[&chosen_id].referenced_declaration?;
1007
1008    // Search for the referenced declaration across all files
1009    let mut target_node: Option<&NodeInfo> = None;
1010    for file_nodes in nodes.values() {
1011        if let Some(node) = file_nodes.get(&ref_id) {
1012            target_node = Some(node);
1013            break;
1014        }
1015    }
1016
1017    let node = target_node?;
1018
1019    // Get location from nameLocation or src
1020    let loc_str = node.name_location.as_deref().unwrap_or(node.src.as_str());
1021    let loc = SourceLoc::parse(loc_str)?;
1022    let file_path = id_to_path.get(&loc.file_id_str())?.clone();
1023
1024    Some((file_path, loc.offset, loc.length))
1025}
1026
1027/// Check if cursor is on the qualifier segment (first `nameLocations` entry)
1028/// of a multi-segment `IdentifierPath` (e.g., `Pool` in `Pool.State`).
1029/// If so, resolve the container declaration via `referencedDeclaration → scope`
1030/// and return a Location pointing to the container's name.
1031fn resolve_qualifier_goto(
1032    build: &CachedBuild,
1033    file_uri: &Url,
1034    byte_position: usize,
1035) -> Option<Location> {
1036    let path = file_uri.to_file_path().ok()?;
1037    let path_str = path.to_str()?;
1038    let abs_path = build.path_to_abs.get(path_str)?;
1039    let file_nodes = build.nodes.get(abs_path)?;
1040
1041    // Find the IdentifierPath node under the cursor.
1042    let node_id = crate::references::byte_to_id(&build.nodes, abs_path, byte_position)?;
1043    let node_info = file_nodes.get(&node_id)?;
1044
1045    // Must be a multi-segment IdentifierPath.
1046    if node_info.node_type.as_deref() != Some("IdentifierPath")
1047        || node_info.name_locations.len() <= 1
1048    {
1049        return None;
1050    }
1051
1052    // Check if cursor is on the first segment (the qualifier).
1053    let first_loc = SourceLoc::parse(&node_info.name_locations[0])?;
1054    if byte_position < first_loc.offset || byte_position >= first_loc.end() {
1055        return None;
1056    }
1057
1058    // Follow referencedDeclaration to the declaration node, then read scope.
1059    let ref_decl_id = node_info.referenced_declaration?;
1060    // Find the declaration node to get its scope.
1061    let decl_node = find_node_info(&build.nodes, ref_decl_id)?;
1062    let scope_id = decl_node.scope?;
1063
1064    // Resolve the container declaration's location.
1065    let container_node = find_node_info(&build.nodes, scope_id)?;
1066    let loc_str = container_node
1067        .name_location
1068        .as_deref()
1069        .unwrap_or(container_node.src.as_str());
1070    let loc = SourceLoc::parse(loc_str)?;
1071    let file_path = build.id_to_path_map.get(&loc.file_id_str())?;
1072
1073    let absolute_path = if std::path::Path::new(file_path).is_absolute() {
1074        std::path::PathBuf::from(file_path)
1075    } else {
1076        std::env::current_dir().ok()?.join(file_path)
1077    };
1078
1079    let target_bytes = std::fs::read(&absolute_path).ok()?;
1080    let start_pos = bytes_to_pos(&target_bytes, loc.offset)?;
1081    let end_pos = bytes_to_pos(&target_bytes, loc.end())?;
1082    let target_uri = Url::from_file_path(&absolute_path).ok()?;
1083
1084    Some(Location {
1085        uri: target_uri,
1086        range: Range {
1087            start: start_pos,
1088            end: end_pos,
1089        },
1090    })
1091}
1092
1093/// Find a `NodeInfo` by node ID across all files.
1094fn find_node_info(
1095    nodes: &HashMap<AbsPath, HashMap<NodeId, NodeInfo>>,
1096    node_id: NodeId,
1097) -> Option<&NodeInfo> {
1098    for file_nodes in nodes.values() {
1099        if let Some(node) = file_nodes.get(&node_id) {
1100            return Some(node);
1101        }
1102    }
1103    None
1104}
1105
1106/// Go-to-declaration using pre-built `CachedBuild` indices.
1107/// Avoids redundant O(N) AST traversal by reusing cached node maps.
1108pub fn goto_declaration_cached(
1109    build: &CachedBuild,
1110    file_uri: &Url,
1111    position: Position,
1112    source_bytes: &[u8],
1113) -> Option<Location> {
1114    let byte_position = pos_to_bytes(source_bytes, position);
1115
1116    // Check if cursor is on the qualifier segment of a multi-segment
1117    // IdentifierPath (e.g., cursor on `Pool` in `Pool.State`).
1118    // If so, navigate to the container declaration (via scope) instead
1119    // of the struct/enum that referencedDeclaration points to.
1120    if let Some(location) = resolve_qualifier_goto(build, file_uri, byte_position) {
1121        return Some(location);
1122    }
1123
1124    if let Some((file_path, location_bytes, length)) = goto_bytes(
1125        &build.nodes,
1126        &build.path_to_abs,
1127        &build.id_to_path_map,
1128        &build.external_refs,
1129        file_uri.as_ref(),
1130        byte_position,
1131    ) {
1132        let target_file_path = std::path::Path::new(&file_path);
1133        let absolute_path = if target_file_path.is_absolute() {
1134            target_file_path.to_path_buf()
1135        } else {
1136            // Resolve relative paths against the current file's directory,
1137            // not CWD. This handles solc standard-json output where
1138            // absolutePath is relative (e.g. "A.sol") and the server's CWD
1139            // differs from the project root.
1140            let base = file_uri
1141                .to_file_path()
1142                .ok()
1143                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1144                .or_else(|| std::env::current_dir().ok())
1145                .unwrap_or_default();
1146            base.join(target_file_path)
1147        };
1148
1149        if let Ok(target_source_bytes) = std::fs::read(&absolute_path)
1150            && let Some(start_pos) = bytes_to_pos(&target_source_bytes, location_bytes)
1151            && let Some(end_pos) = bytes_to_pos(&target_source_bytes, location_bytes + length)
1152            && let Ok(target_uri) = Url::from_file_path(&absolute_path)
1153        {
1154            return Some(Location {
1155                uri: target_uri,
1156                range: Range {
1157                    start: start_pos,
1158                    end: end_pos,
1159                },
1160            });
1161        }
1162    };
1163
1164    None
1165}
1166
1167/// Name-based AST goto — resolves by searching cached AST nodes for identifiers
1168/// matching `name` in the current file, then following `referencedDeclaration`.
1169///
1170/// Unlike `goto_declaration_cached` which matches by byte offset (breaks on dirty files),
1171/// this reads the identifier text from the **built source** (on disk) at each node's
1172/// `src` range and compares it to the cursor name. Works on dirty files because the
1173/// AST node relationships (referencedDeclaration) are still valid — only the byte
1174/// offsets in the current buffer are stale.
1175/// `byte_hint` is the cursor's byte offset in the dirty buffer, used to pick
1176/// the closest matching node when multiple nodes share the same name (overloads).
1177pub fn goto_declaration_by_name(
1178    cached_build: &CachedBuild,
1179    file_uri: &Url,
1180    name: &str,
1181    byte_hint: usize,
1182) -> Option<Location> {
1183    let path = match file_uri.as_ref().starts_with("file://") {
1184        true => &file_uri.as_ref()[7..],
1185        false => file_uri.as_ref(),
1186    };
1187    let abs_path = cached_build.path_to_abs.get(path)?;
1188    // Read the built source from disk to extract identifier text at src ranges
1189    let built_source = std::fs::read_to_string(abs_path).ok()?;
1190
1191    // Collect all matching nodes: (distance_to_hint, span_size, ref_id)
1192    let mut candidates: Vec<(usize, usize, NodeId)> = Vec::new();
1193
1194    let tmp = {
1195        let this = cached_build.nodes.get(abs_path)?;
1196        this.iter()
1197    };
1198    for (_id, node) in tmp {
1199        let ref_id = match node.referenced_declaration {
1200            Some(id) => id,
1201            None => continue,
1202        };
1203
1204        // Parse the node's src to get the byte range in the built source
1205        let Some(src_loc) = SourceLoc::parse(node.src.as_str()) else {
1206            continue;
1207        };
1208        let start = src_loc.offset;
1209        let length = src_loc.length;
1210
1211        // These offsets come from a previous solc build while `built_source` is
1212        // read fresh from disk, so they can be stale as well as out of range.
1213        // On a file with multi-byte text a stale offset can land inside a
1214        // character, and slicing there panics the whole server, so skip the
1215        // candidate the same way an unparseable `src` is skipped.
1216        let Some(node_text) = built_source.get(start..start + length) else {
1217            continue;
1218        };
1219
1220        // Check if this node's text matches the name we're looking for.
1221        // For simple identifiers, the text equals the name directly.
1222        // For member access (e.g. `x.toInt128()`), check if the text contains
1223        // `.name(` or ends with `.name`.
1224        let matches = node_text == name
1225            || node_text.contains(&format!(".{name}("))
1226            || node_text.ends_with(&format!(".{name}"));
1227
1228        if matches {
1229            // Distance from the byte_hint (cursor in dirty buffer) to the
1230            // node's src range. The closest node is most likely the one the
1231            // cursor is on, even if byte offsets shifted slightly.
1232            let distance = if byte_hint >= start && byte_hint < start + length {
1233                0 // cursor is inside this node's range
1234            } else if byte_hint < start {
1235                start - byte_hint
1236            } else {
1237                byte_hint - (start + length)
1238            };
1239            candidates.push((distance, length, ref_id));
1240        }
1241    }
1242
1243    // Sort by distance (closest to cursor hint), then by span size (narrowest)
1244    candidates.sort_by_key(|&(dist, span, _)| (dist, span));
1245    let ref_id = candidates.first()?.2;
1246
1247    // Find the declaration node across all files
1248    let mut target_node: Option<&NodeInfo> = None;
1249    for file_nodes in cached_build.nodes.values() {
1250        if let Some(node) = file_nodes.get(&ref_id) {
1251            target_node = Some(node);
1252            break;
1253        }
1254    }
1255
1256    let node = target_node?;
1257
1258    // Parse the target's nameLocation or src
1259    let loc_str = node.name_location.as_deref().unwrap_or(node.src.as_str());
1260    let loc = SourceLoc::parse(loc_str)?;
1261
1262    let file_path = cached_build.id_to_path_map.get(&loc.file_id_str())?;
1263    let location_bytes = loc.offset;
1264    let length = loc.length;
1265
1266    let target_file_path = std::path::Path::new(file_path);
1267    let absolute_path = if target_file_path.is_absolute() {
1268        target_file_path.to_path_buf()
1269    } else {
1270        let base = file_uri
1271            .to_file_path()
1272            .ok()
1273            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1274            .or_else(|| std::env::current_dir().ok())
1275            .unwrap_or_default();
1276        base.join(target_file_path)
1277    };
1278
1279    let target_source_bytes = std::fs::read(&absolute_path).ok()?;
1280    let start_pos = bytes_to_pos(&target_source_bytes, location_bytes)?;
1281    let end_pos = bytes_to_pos(&target_source_bytes, location_bytes + length)?;
1282    let target_uri = Url::from_file_path(&absolute_path).ok()?;
1283
1284    Some(Location {
1285        uri: target_uri,
1286        range: Range {
1287            start: start_pos,
1288            end: end_pos,
1289        },
1290    })
1291}
1292
1293// ── Tree-sitter enhanced goto ──────────────────────────────────────────────
1294
1295/// Context extracted from the cursor position via tree-sitter.
1296#[derive(Debug, Clone)]
1297pub struct CursorContext {
1298    /// The identifier text under the cursor.
1299    pub name: String,
1300    /// Enclosing function name (if any).
1301    pub function: Option<String>,
1302    /// Enclosing contract/interface/library name (if any).
1303    pub contract: Option<String>,
1304    /// Object in a member access expression (e.g. `SqrtPriceMath` in
1305    /// `SqrtPriceMath.getAmount0Delta`). Set when the cursor is on the
1306    /// property side of a dot expression.
1307    pub object: Option<String>,
1308    /// Number of arguments at the call site (for overload disambiguation).
1309    /// Set when the cursor is on a function name inside a `call_expression`.
1310    pub arg_count: Option<usize>,
1311    /// Inferred argument types at the call site (e.g. `["uint160", "uint160", "int128"]`).
1312    /// `None` entries mean the type couldn't be inferred for that argument.
1313    pub arg_types: Vec<Option<String>>,
1314}
1315
1316/// Parse Solidity source with tree-sitter.
1317fn ts_parse(source: &str) -> Option<tree_sitter::Tree> {
1318    let mut parser = Parser::new();
1319    parser
1320        .set_language(&tree_sitter_solidity::LANGUAGE.into())
1321        .expect("failed to load Solidity grammar");
1322    parser.parse(source, None)
1323}
1324
1325/// Validate that the text at a goto target location matches the expected name.
1326///
1327/// Used to reject tree-sitter results that land on the wrong identifier.
1328/// AST results are NOT validated because the AST can legitimately resolve
1329/// to a different name (e.g. `.selector` → error declaration).
1330pub fn validate_goto_target(target_source: &str, location: &Location, expected_name: &str) -> bool {
1331    let line = location.range.start.line as usize;
1332    let start_col = location.range.start.character as usize;
1333    let end_col = location.range.end.character as usize;
1334
1335    // `get` rather than indexing: these columns are LSP character offsets, so
1336    // on a line with multi-byte text they can land inside a character or past
1337    // its end, and slicing there panics — which takes the whole server down,
1338    // not just this request. An unusable range falls through to the same
1339    // "can't read target" answer as an unreadable file.
1340    if let Some(line_text) = target_source.lines().nth(line)
1341        && let Some(slice) = line_text.get(start_col..end_col)
1342    {
1343        return slice == expected_name;
1344    }
1345    // Can't read target — assume valid
1346    true
1347}
1348
1349/// Find the deepest named node at the given byte offset.
1350fn ts_node_at_byte(node: Node, byte: usize) -> Option<Node> {
1351    if byte < node.start_byte() || byte >= node.end_byte() {
1352        return None;
1353    }
1354    let mut cursor = node.walk();
1355    for child in node.children(&mut cursor) {
1356        if child.start_byte() <= byte
1357            && byte < child.end_byte()
1358            && let Some(deeper) = ts_node_at_byte(child, byte)
1359        {
1360            return Some(deeper);
1361        }
1362    }
1363    Some(node)
1364}
1365
1366/// Get the identifier name from a node (first `identifier` child or the node itself).
1367fn ts_child_id_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
1368    let mut cursor = node.walk();
1369    node.children(&mut cursor)
1370        .find(|c| c.kind() == "identifier" && c.is_named())
1371        .map(|c| &source[c.byte_range()])
1372}
1373
1374/// Infer the type of an expression node using tree-sitter.
1375///
1376/// For identifiers, walks up to find the variable declaration and extracts its type.
1377/// For literals, infers the type from the literal kind.
1378/// For function calls, returns None (would need return type resolution).
1379fn infer_argument_type<'a>(arg_node: Node<'a>, source: &'a str) -> Option<String> {
1380    // Unwrap call_argument → get inner expression
1381    let expr = if arg_node.kind() == "call_argument" {
1382        let mut c = arg_node.walk();
1383        arg_node.children(&mut c).find(|ch| ch.is_named())?
1384    } else {
1385        arg_node
1386    };
1387
1388    match expr.kind() {
1389        "identifier" => {
1390            let var_name = &source[expr.byte_range()];
1391            // Walk up scopes to find the variable declaration
1392            find_variable_type(expr, source, var_name)
1393        }
1394        "number_literal" | "decimal_number" | "hex_number" => Some("uint256".into()),
1395        "boolean_literal" => Some("bool".into()),
1396        "string_literal" | "hex_string_literal" => Some("string".into()),
1397        _ => None,
1398    }
1399}
1400
1401/// Find the type of a variable by searching upward through enclosing scopes.
1402///
1403/// Looks for `parameter`, `variable_declaration`, and `state_variable_declaration`
1404/// nodes whose identifier matches the variable name.
1405fn find_variable_type(from: Node, source: &str, var_name: &str) -> Option<String> {
1406    let mut scope = from.parent();
1407    while let Some(node) = scope {
1408        match node.kind() {
1409            "function_definition" | "modifier_definition" | "constructor_definition" => {
1410                // Check parameters
1411                let mut c = node.walk();
1412                for child in node.children(&mut c) {
1413                    if child.kind() == "parameter"
1414                        && let Some(id) = ts_child_id_text(child, source)
1415                        && id == var_name
1416                    {
1417                        // Extract the type from this parameter
1418                        let mut pc = child.walk();
1419                        return child
1420                            .children(&mut pc)
1421                            .find(|c| {
1422                                matches!(
1423                                    c.kind(),
1424                                    "type_name"
1425                                        | "primitive_type"
1426                                        | "user_defined_type"
1427                                        | "mapping"
1428                                )
1429                            })
1430                            .map(|t| source[t.byte_range()].trim().to_string());
1431                    }
1432                }
1433            }
1434            "function_body" | "block_statement" | "unchecked_block" => {
1435                // Check local variable declarations
1436                let mut c = node.walk();
1437                for child in node.children(&mut c) {
1438                    if (child.kind() == "variable_declaration_statement"
1439                        || child.kind() == "variable_declaration")
1440                        && let Some(id) = ts_child_id_text(child, source)
1441                        && id == var_name
1442                    {
1443                        let mut pc = child.walk();
1444                        return child
1445                            .children(&mut pc)
1446                            .find(|c| {
1447                                matches!(
1448                                    c.kind(),
1449                                    "type_name"
1450                                        | "primitive_type"
1451                                        | "user_defined_type"
1452                                        | "mapping"
1453                                )
1454                            })
1455                            .map(|t| source[t.byte_range()].trim().to_string());
1456                    }
1457                }
1458            }
1459            "contract_declaration" | "library_declaration" | "interface_declaration" => {
1460                // Check state variables
1461                if let Some(body) = ts_find_child(node, "contract_body") {
1462                    let mut c = body.walk();
1463                    for child in body.children(&mut c) {
1464                        if child.kind() == "state_variable_declaration"
1465                            && let Some(id) = ts_child_id_text(child, source)
1466                            && id == var_name
1467                        {
1468                            let mut pc = child.walk();
1469                            return child
1470                                .children(&mut pc)
1471                                .find(|c| {
1472                                    matches!(
1473                                        c.kind(),
1474                                        "type_name"
1475                                            | "primitive_type"
1476                                            | "user_defined_type"
1477                                            | "mapping"
1478                                    )
1479                                })
1480                                .map(|t| source[t.byte_range()].trim().to_string());
1481                        }
1482                    }
1483                }
1484            }
1485            _ => {}
1486        }
1487        scope = node.parent();
1488    }
1489    None
1490}
1491
1492/// Infer argument types at a call site by examining each `call_argument` child.
1493fn infer_call_arg_types(call_node: Node, source: &str) -> Vec<Option<String>> {
1494    let mut cursor = call_node.walk();
1495    call_node
1496        .children(&mut cursor)
1497        .filter(|c| c.kind() == "call_argument")
1498        .map(|arg| infer_argument_type(arg, source))
1499        .collect()
1500}
1501
1502/// Pick the best overload from multiple declarations based on argument types.
1503///
1504/// Strategy:
1505/// 1. If only one declaration, return it.
1506/// 2. Filter by argument count first.
1507/// 3. Among count-matched declarations, score by how many argument types match.
1508/// 4. Return the highest-scoring declaration.
1509fn best_overload<'a>(
1510    decls: &'a [TsDeclaration],
1511    arg_count: Option<usize>,
1512    arg_types: &[Option<String>],
1513) -> Option<&'a TsDeclaration> {
1514    if decls.len() == 1 {
1515        return decls.first();
1516    }
1517    if decls.is_empty() {
1518        return None;
1519    }
1520
1521    // Filter to only function declarations (skip parameters, variables, etc.)
1522    let func_decls: Vec<&TsDeclaration> =
1523        decls.iter().filter(|d| d.param_count.is_some()).collect();
1524
1525    if func_decls.is_empty() {
1526        return decls.first();
1527    }
1528
1529    // If we have arg_count, filter by it
1530    let count_matched: Vec<&&TsDeclaration> = if let Some(ac) = arg_count {
1531        let matched: Vec<_> = func_decls
1532            .iter()
1533            .filter(|d| d.param_count == Some(ac))
1534            .collect();
1535        if matched.len() == 1 {
1536            return Some(matched[0]);
1537        }
1538        if matched.is_empty() {
1539            // No count match — fall back to all
1540            func_decls.iter().collect()
1541        } else {
1542            matched
1543        }
1544    } else {
1545        func_decls.iter().collect()
1546    };
1547
1548    // Score each candidate by how many argument types match parameter types
1549    if !arg_types.is_empty() {
1550        let mut best: Option<(&TsDeclaration, usize)> = None;
1551        for &&decl in &count_matched {
1552            let score = arg_types
1553                .iter()
1554                .zip(decl.param_types.iter())
1555                .filter(|(arg_ty, param_ty)| {
1556                    if let Some(at) = arg_ty {
1557                        at == param_ty.as_str()
1558                    } else {
1559                        false
1560                    }
1561                })
1562                .count();
1563            if best.is_none() || score > best.unwrap().1 {
1564                best = Some((decl, score));
1565            }
1566        }
1567        if let Some((decl, _)) = best {
1568            return Some(decl);
1569        }
1570    }
1571
1572    // Fallback: return first count-matched or first overall
1573    count_matched.first().map(|d| **d).or(decls.first())
1574}
1575
1576/// Extract cursor context: the identifier under the cursor and its ancestor names.
1577///
1578/// Walks up the tree-sitter parse tree to find the enclosing function and contract.
1579pub fn cursor_context(source: &str, position: Position) -> Option<CursorContext> {
1580    let tree = ts_parse(source)?;
1581    let byte = pos_to_bytes(source.as_bytes(), position);
1582    let leaf = ts_node_at_byte(tree.root_node(), byte)?;
1583
1584    // The leaf should be an identifier (or we find the nearest identifier)
1585    let id_node = if leaf.kind() == "identifier" {
1586        leaf
1587    } else {
1588        // Check parent — cursor might be just inside a node that contains an identifier
1589        let parent = leaf.parent()?;
1590        if parent.kind() == "identifier" {
1591            parent
1592        } else {
1593            return None;
1594        }
1595    };
1596
1597    let name = source[id_node.byte_range()].to_string();
1598    let mut function = None;
1599    let mut contract = None;
1600
1601    // Detect member access: if the identifier is the `property` side of a
1602    // member_expression (e.g. `SqrtPriceMath.getAmount0Delta`), extract
1603    // the object name so the caller can resolve cross-file.
1604    let object = id_node.parent().and_then(|parent| {
1605        if parent.kind() == "member_expression" {
1606            let prop = parent.child_by_field_name("property")?;
1607            // Only set object when cursor is on the property, not the object side
1608            if prop.id() == id_node.id() {
1609                let obj = parent.child_by_field_name("object")?;
1610                Some(source[obj.byte_range()].to_string())
1611            } else {
1612                None
1613            }
1614        } else {
1615            None
1616        }
1617    });
1618
1619    // Count arguments and infer types at the call site for overload disambiguation.
1620    // Walk up from the identifier to find an enclosing `call_expression`,
1621    // then count its `call_argument` children and infer their types.
1622    let (arg_count, arg_types) = {
1623        let mut node = id_node.parent();
1624        let mut result = (None, vec![]);
1625        while let Some(n) = node {
1626            if n.kind() == "call_expression" {
1627                let types = infer_call_arg_types(n, source);
1628                result = (Some(types.len()), types);
1629                break;
1630            }
1631            node = n.parent();
1632        }
1633        result
1634    };
1635
1636    // Walk ancestors
1637    let mut current = id_node.parent();
1638    while let Some(node) = current {
1639        match node.kind() {
1640            "function_definition" | "modifier_definition" if function.is_none() => {
1641                function = ts_child_id_text(node, source).map(String::from);
1642            }
1643            "constructor_definition" if function.is_none() => {
1644                function = Some("constructor".into());
1645            }
1646            "contract_declaration" | "interface_declaration" | "library_declaration"
1647                if contract.is_none() =>
1648            {
1649                contract = ts_child_id_text(node, source).map(String::from);
1650            }
1651            _ => {}
1652        }
1653        current = node.parent();
1654    }
1655
1656    Some(CursorContext {
1657        name,
1658        function,
1659        contract,
1660        object,
1661        arg_count,
1662        arg_types,
1663    })
1664}
1665
1666/// Information about a declaration found by tree-sitter.
1667#[derive(Debug, Clone)]
1668pub struct TsDeclaration {
1669    /// Position range of the declaration identifier.
1670    pub range: Range,
1671    /// What kind of declaration (contract, function, state_variable, etc.).
1672    pub kind: &'static str,
1673    /// Container name (contract/struct that owns this declaration).
1674    pub container: Option<String>,
1675    /// Number of parameters (for function/modifier declarations).
1676    pub param_count: Option<usize>,
1677    /// Parameter type signature (e.g. `["uint160", "uint160", "int128"]`).
1678    /// Used for overload disambiguation.
1679    pub param_types: Vec<String>,
1680}
1681
1682/// Find all declarations of a name in a source file using tree-sitter.
1683///
1684/// Scans the parse tree for declaration nodes (state variables, functions, events,
1685/// errors, structs, enums, contracts, etc.) whose identifier matches `name`.
1686pub fn find_declarations_by_name(source: &str, name: &str) -> Vec<TsDeclaration> {
1687    let tree = match ts_parse(source) {
1688        Some(t) => t,
1689        None => return vec![],
1690    };
1691    let mut results = Vec::new();
1692    collect_declarations(tree.root_node(), source, name, None, &mut results);
1693    results
1694}
1695
1696fn collect_declarations(
1697    node: Node,
1698    source: &str,
1699    name: &str,
1700    container: Option<&str>,
1701    out: &mut Vec<TsDeclaration>,
1702) {
1703    let mut cursor = node.walk();
1704    for child in node.children(&mut cursor) {
1705        if !child.is_named() {
1706            continue;
1707        }
1708        match child.kind() {
1709            "contract_declaration" | "interface_declaration" | "library_declaration" => {
1710                if let Some(id_name) = ts_child_id_text(child, source) {
1711                    if id_name == name {
1712                        out.push(TsDeclaration {
1713                            range: id_range(child),
1714                            kind: child.kind(),
1715                            container: container.map(String::from),
1716                            param_count: None,
1717                            param_types: vec![],
1718                        });
1719                    }
1720                    // Recurse into contract body
1721                    if let Some(body) = ts_find_child(child, "contract_body") {
1722                        collect_declarations(body, source, name, Some(id_name), out);
1723                    }
1724                }
1725            }
1726            "function_definition" | "modifier_definition" => {
1727                if let Some(id_name) = ts_child_id_text(child, source) {
1728                    if id_name == name {
1729                        let types = parameter_type_signature(child, source);
1730                        out.push(TsDeclaration {
1731                            range: id_range(child),
1732                            kind: child.kind(),
1733                            container: container.map(String::from),
1734                            param_count: Some(types.len()),
1735                            param_types: types.into_iter().map(String::from).collect(),
1736                        });
1737                    }
1738                    // Check function parameters
1739                    collect_parameters(child, source, name, container, out);
1740                    // Recurse into function body for local variables
1741                    if let Some(body) = ts_find_child(child, "function_body") {
1742                        collect_declarations(body, source, name, container, out);
1743                    }
1744                }
1745            }
1746            "constructor_definition" => {
1747                if name == "constructor" {
1748                    let types = parameter_type_signature(child, source);
1749                    out.push(TsDeclaration {
1750                        range: ts_range(child),
1751                        kind: "constructor_definition",
1752                        container: container.map(String::from),
1753                        param_count: Some(types.len()),
1754                        param_types: types.into_iter().map(String::from).collect(),
1755                    });
1756                }
1757                // Check constructor parameters
1758                collect_parameters(child, source, name, container, out);
1759                if let Some(body) = ts_find_child(child, "function_body") {
1760                    collect_declarations(body, source, name, container, out);
1761                }
1762            }
1763            "state_variable_declaration" | "variable_declaration" => {
1764                if let Some(id_name) = ts_child_id_text(child, source)
1765                    && id_name == name
1766                {
1767                    out.push(TsDeclaration {
1768                        range: id_range(child),
1769                        kind: child.kind(),
1770                        container: container.map(String::from),
1771                        param_count: None,
1772                        param_types: vec![],
1773                    });
1774                }
1775            }
1776            "struct_declaration" => {
1777                if let Some(id_name) = ts_child_id_text(child, source) {
1778                    if id_name == name {
1779                        out.push(TsDeclaration {
1780                            range: id_range(child),
1781                            kind: "struct_declaration",
1782                            container: container.map(String::from),
1783                            param_count: None,
1784                            param_types: vec![],
1785                        });
1786                    }
1787                    if let Some(body) = ts_find_child(child, "struct_body") {
1788                        collect_declarations(body, source, name, Some(id_name), out);
1789                    }
1790                }
1791            }
1792            "enum_declaration" => {
1793                if let Some(id_name) = ts_child_id_text(child, source) {
1794                    if id_name == name {
1795                        out.push(TsDeclaration {
1796                            range: id_range(child),
1797                            kind: "enum_declaration",
1798                            container: container.map(String::from),
1799                            param_count: None,
1800                            param_types: vec![],
1801                        });
1802                    }
1803                    // Check enum values
1804                    if let Some(body) = ts_find_child(child, "enum_body") {
1805                        let mut ecur = body.walk();
1806                        for val in body.children(&mut ecur) {
1807                            if val.kind() == "enum_value" && &source[val.byte_range()] == name {
1808                                out.push(TsDeclaration {
1809                                    range: ts_range(val),
1810                                    kind: "enum_value",
1811                                    container: Some(id_name.to_string()),
1812                                    param_count: None,
1813                                    param_types: vec![],
1814                                });
1815                            }
1816                        }
1817                    }
1818                }
1819            }
1820            "event_definition" | "error_declaration" => {
1821                if let Some(id_name) = ts_child_id_text(child, source)
1822                    && id_name == name
1823                {
1824                    out.push(TsDeclaration {
1825                        range: id_range(child),
1826                        kind: child.kind(),
1827                        container: container.map(String::from),
1828                        param_count: None,
1829                        param_types: vec![],
1830                    });
1831                }
1832            }
1833            "user_defined_type_definition" => {
1834                if let Some(id_name) = ts_child_id_text(child, source)
1835                    && id_name == name
1836                {
1837                    out.push(TsDeclaration {
1838                        range: id_range(child),
1839                        kind: "user_defined_type_definition",
1840                        container: container.map(String::from),
1841                        param_count: None,
1842                        param_types: vec![],
1843                    });
1844                }
1845            }
1846            // Recurse into blocks, if-else, loops, etc.
1847            _ => {
1848                collect_declarations(child, source, name, container, out);
1849            }
1850        }
1851    }
1852}
1853
1854/// Extract the type signature from a function's parameters.
1855///
1856/// Returns a list of type strings, e.g. `["uint160", "uint160", "int128"]`.
1857/// For complex types (mappings, arrays, user-defined), returns the full
1858/// text of the type node.
1859fn parameter_type_signature<'a>(node: Node<'a>, source: &'a str) -> Vec<&'a str> {
1860    let mut cursor = node.walk();
1861    node.children(&mut cursor)
1862        .filter(|c| c.kind() == "parameter")
1863        .filter_map(|param| {
1864            let mut pc = param.walk();
1865            param
1866                .children(&mut pc)
1867                .find(|c| {
1868                    matches!(
1869                        c.kind(),
1870                        "type_name" | "primitive_type" | "user_defined_type" | "mapping"
1871                    )
1872                })
1873                .map(|t| source[t.byte_range()].trim())
1874        })
1875        .collect()
1876}
1877
1878/// Collect parameter declarations from a function/constructor node.
1879fn collect_parameters(
1880    node: Node,
1881    source: &str,
1882    name: &str,
1883    container: Option<&str>,
1884    out: &mut Vec<TsDeclaration>,
1885) {
1886    let mut cursor = node.walk();
1887    for child in node.children(&mut cursor) {
1888        if child.kind() == "parameter"
1889            && let Some(id_name) = ts_child_id_text(child, source)
1890            && id_name == name
1891        {
1892            out.push(TsDeclaration {
1893                range: id_range(child),
1894                kind: "parameter",
1895                container: container.map(String::from),
1896                param_count: None,
1897                param_types: vec![],
1898            });
1899        }
1900    }
1901}
1902
1903/// Tree-sitter range helper.
1904fn ts_range(node: Node) -> Range {
1905    let s = node.start_position();
1906    let e = node.end_position();
1907    Range {
1908        start: Position::new(s.row as u32, s.column as u32),
1909        end: Position::new(e.row as u32, e.column as u32),
1910    }
1911}
1912
1913/// Get the range of the identifier child within a declaration node.
1914fn id_range(node: Node) -> Range {
1915    let mut cursor = node.walk();
1916    node.children(&mut cursor)
1917        .find(|c| c.kind() == "identifier" && c.is_named())
1918        .map(|c| ts_range(c))
1919        .unwrap_or_else(|| ts_range(node))
1920}
1921
1922fn ts_find_child<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
1923    let mut cursor = node.walk();
1924    node.children(&mut cursor).find(|c| c.kind() == kind)
1925}
1926
1927/// Tree-sitter enhanced goto definition.
1928///
1929/// Uses tree-sitter to find the identifier under the cursor and its scope,
1930/// then resolves via the CompletionCache (for cross-file/semantic resolution),
1931/// and finally uses tree-sitter to find the declaration position in the target file.
1932///
1933/// Falls back to None if resolution fails — caller should try the existing AST-based path.
1934pub fn goto_definition_ts(
1935    source: &str,
1936    position: Position,
1937    file_uri: &Url,
1938    completion_cache: &crate::completion::CompletionCache,
1939    text_cache: &HashMap<crate::types::DocumentUri, (i32, String)>,
1940) -> Option<Location> {
1941    let ctx = cursor_context(source, position)?;
1942
1943    // Member access: cursor is on `getAmount0Delta` in `SqrtPriceMath.getAmount0Delta`.
1944    // Look up the object (SqrtPriceMath) in the completion cache to find its file,
1945    // then search that file for the member declaration.
1946    // When multiple overloads exist, disambiguate by argument count and types.
1947    if let Some(obj_name) = &ctx.object {
1948        if let Some(path) = find_file_for_contract(completion_cache, obj_name, file_uri) {
1949            let target_source = read_target_source(&path, text_cache)?;
1950            let target_uri = Url::from_file_path(&path).ok()?;
1951            let decls = find_declarations_by_name(&target_source, &ctx.name);
1952            if let Some(d) = best_overload(&decls, ctx.arg_count, &ctx.arg_types) {
1953                return Some(Location {
1954                    uri: target_uri,
1955                    range: d.range,
1956                });
1957            }
1958        }
1959        // Object might be in the same file (e.g. a struct or contract in this file)
1960        let decls = find_declarations_by_name(source, &ctx.name);
1961        if let Some(d) = best_overload(&decls, ctx.arg_count, &ctx.arg_types) {
1962            return Some(Location {
1963                uri: file_uri.clone(),
1964                range: d.range,
1965            });
1966        }
1967    }
1968
1969    // Step 1: Try to resolve via CompletionCache to find which file + name the declaration is in.
1970    // Use the scope chain by names: find the contract scope, then resolve the name.
1971    let resolved = resolve_via_cache(&ctx, file_uri, completion_cache);
1972
1973    match resolved {
1974        Some(ResolvedTarget::SameFile) => {
1975            // Declaration is in the same file — find it with tree-sitter
1976            find_best_declaration(source, &ctx, file_uri)
1977        }
1978        Some(ResolvedTarget::OtherFile { path, name }) => {
1979            // Declaration is in another file — read target source and find by name
1980            let target_source = read_target_source(&path, text_cache);
1981            let target_source = target_source?;
1982            let target_uri = Url::from_file_path(&path).ok()?;
1983            let decls = find_declarations_by_name(&target_source, &name);
1984            decls.first().map(|d| Location {
1985                uri: target_uri,
1986                range: d.range,
1987            })
1988        }
1989        None => {
1990            // CompletionCache couldn't resolve — try same-file tree-sitter lookup as fallback
1991            find_best_declaration(source, &ctx, file_uri)
1992        }
1993    }
1994}
1995
1996#[derive(Debug)]
1997enum ResolvedTarget {
1998    /// Declaration is in the same file as the usage.
1999    SameFile,
2000    /// Declaration is in a different file.
2001    OtherFile { path: String, name: String },
2002}
2003
2004/// Try to resolve an identifier using the CompletionCache.
2005///
2006/// Finds the scope by matching ancestor names (contract, function) against
2007/// the cache's scope data, then resolves the name to a type and traces
2008/// back to the declaring file.
2009fn resolve_via_cache(
2010    ctx: &CursorContext,
2011    file_uri: &Url,
2012    cache: &crate::completion::CompletionCache,
2013) -> Option<ResolvedTarget> {
2014    // Find the contract scope node_id by name
2015    let contract_scope = ctx
2016        .contract
2017        .as_ref()
2018        .and_then(|name| cache.name_to_node_id.get(name.as_str()))
2019        .copied();
2020
2021    // Try scope-based resolution: look in the contract's scope_declarations
2022    if let Some(contract_id) = contract_scope {
2023        // Check function scope if we're inside one
2024        if let Some(func_name) = &ctx.function {
2025            // Find the function scope: look for a scope whose parent is this contract
2026            // and which has a declaration for this function name
2027            if let Some(func_scope_id) = find_function_scope(cache, contract_id, func_name) {
2028                // Check declarations in this function scope first
2029                if let Some(decls) = cache.scope_declarations.get(&func_scope_id)
2030                    && decls.iter().any(|d| d.name == ctx.name)
2031                {
2032                    return Some(ResolvedTarget::SameFile);
2033                }
2034            }
2035        }
2036
2037        // Check contract scope declarations (state variables, functions)
2038        if let Some(decls) = cache.scope_declarations.get(&contract_id)
2039            && decls.iter().any(|d| d.name == ctx.name)
2040        {
2041            return Some(ResolvedTarget::SameFile);
2042        }
2043
2044        // Check inherited contracts (C3 linearization)
2045        if let Some(bases) = cache.linearized_base_contracts.get(&contract_id) {
2046            for &base_id in bases.iter().skip(1) {
2047                if let Some(decls) = cache.scope_declarations.get(&base_id)
2048                    && decls.iter().any(|d| d.name == ctx.name)
2049                {
2050                    // Found in a base contract — find which file it's in
2051                    // Reverse lookup: base_id → contract name → file
2052                    let base_name = cache
2053                        .name_to_node_id
2054                        .iter()
2055                        .find(|&(_, &id)| id == base_id)
2056                        .map(|(name, _)| name.clone());
2057
2058                    if let Some(base_name) = base_name
2059                        && let Some(path) =
2060                            find_file_for_contract(cache, base_name.as_str(), file_uri)
2061                    {
2062                        return Some(ResolvedTarget::OtherFile {
2063                            path,
2064                            name: ctx.name.clone(),
2065                        });
2066                    }
2067                    // Base contract might be in the same file
2068                    return Some(ResolvedTarget::SameFile);
2069                }
2070            }
2071        }
2072    }
2073
2074    // Check if the name is a contract/library/interface name
2075    if cache.name_to_node_id.contains_key(ctx.name.as_str()) {
2076        // Could be same file or different file — check if it's in the current file
2077        if let Some(path) = find_file_for_contract(cache, &ctx.name, file_uri) {
2078            let current_path = file_uri.to_file_path().ok()?;
2079            let current_str = current_path.to_str()?;
2080            if path == current_str || path.ends_with(current_str) || current_str.ends_with(&path) {
2081                return Some(ResolvedTarget::SameFile);
2082            }
2083            return Some(ResolvedTarget::OtherFile {
2084                path,
2085                name: ctx.name.clone(),
2086            });
2087        }
2088        return Some(ResolvedTarget::SameFile);
2089    }
2090
2091    // Flat fallback — name_to_type knows about it but we can't determine the file
2092    if cache.name_to_type.contains_key(ctx.name.as_str()) {
2093        return Some(ResolvedTarget::SameFile);
2094    }
2095
2096    None
2097}
2098
2099/// Find the scope node_id for a function within a contract.
2100fn find_function_scope(
2101    cache: &crate::completion::CompletionCache,
2102    contract_id: NodeId,
2103    func_name: &str,
2104) -> Option<NodeId> {
2105    // Look for a scope whose parent is the contract and which is a function scope.
2106    // The function name should appear as a declaration in the contract scope,
2107    // and the function's own scope is the one whose parent is the contract.
2108    for (&scope_id, &parent_id) in &cache.scope_parent {
2109        if parent_id == contract_id {
2110            // This scope's parent is our contract — it might be a function scope.
2111            // Check if this scope has declarations (functions/blocks do).
2112            // We also check if the contract declares a function with this name.
2113            if let Some(contract_decls) = cache.scope_declarations.get(&contract_id)
2114                && contract_decls.iter().any(|d| d.name == func_name)
2115            {
2116                // Found a child scope of the contract — could be the function.
2117                // Check if this scope_id has child scopes or declarations
2118                // that match what we'd expect for a function body.
2119                if cache.scope_declarations.contains_key(&scope_id)
2120                    || cache.scope_parent.values().any(|&p| p == scope_id)
2121                {
2122                    return Some(scope_id);
2123                }
2124            }
2125        }
2126    }
2127    None
2128}
2129
2130/// Find the file path for a contract by searching the CompletionCache's path_to_file_id.
2131fn find_file_for_contract(
2132    cache: &crate::completion::CompletionCache,
2133    contract_name: &str,
2134    _file_uri: &Url,
2135) -> Option<String> {
2136    // The completion cache doesn't directly map contract → file.
2137    // But scope_ranges + path_to_file_id can help.
2138    // For now, check if the contract's node_id appears in any scope_range,
2139    // then map file_id back to path.
2140    let node_id = cache.name_to_node_id.get(contract_name)?;
2141    let scope_range = cache.scope_ranges.iter().find(|r| r.node_id == *node_id)?;
2142    let file_id = scope_range.file_id;
2143
2144    // Reverse lookup: file_id → path
2145    cache
2146        .path_to_file_id
2147        .iter()
2148        .find(|&(_, &fid)| fid == file_id)
2149        .map(|(path, _)| path.to_string())
2150}
2151
2152/// Read source for a target file — prefer text_cache (open buffers), fallback to disk.
2153fn read_target_source(
2154    path: &str,
2155    text_cache: &HashMap<crate::types::DocumentUri, (i32, String)>,
2156) -> Option<String> {
2157    // Try text_cache by URI
2158    let uri = Url::from_file_path(path).ok()?;
2159    if let Some((_, content)) = text_cache.get(&uri.to_string()) {
2160        return Some(content.clone());
2161    }
2162    // Fallback to disk
2163    std::fs::read_to_string(path).ok()
2164}
2165
2166/// Find the best matching declaration in the same file.
2167fn find_best_declaration(source: &str, ctx: &CursorContext, file_uri: &Url) -> Option<Location> {
2168    let decls = find_declarations_by_name(source, &ctx.name);
2169    if decls.is_empty() {
2170        return None;
2171    }
2172
2173    // If there's only one declaration, use it
2174    if decls.len() == 1 {
2175        return Some(Location {
2176            uri: file_uri.clone(),
2177            range: decls[0].range,
2178        });
2179    }
2180
2181    // Multiple declarations — prefer the one in the same contract
2182    if let Some(contract_name) = &ctx.contract
2183        && let Some(d) = decls
2184            .iter()
2185            .find(|d| d.container.as_deref() == Some(contract_name))
2186    {
2187        return Some(Location {
2188            uri: file_uri.clone(),
2189            range: d.range,
2190        });
2191    }
2192
2193    // Fallback: return first declaration
2194    Some(Location {
2195        uri: file_uri.clone(),
2196        range: decls[0].range,
2197    })
2198}
2199
2200// ─────────────────────────────────────────────────────────────────────────────
2201// Code-action helpers (used by lsp.rs `code_action` handler)
2202// ─────────────────────────────────────────────────────────────────────────────
2203
2204/// What kind of edit a code action should produce.
2205#[derive(Debug, Clone, Copy)]
2206pub(crate) enum CodeActionKind<'a> {
2207    /// Insert fixed text at the very start of the file (line 0, col 0).
2208    InsertAtFileStart { text: &'a str },
2209
2210    /// Replace the token at `diag_range.start` with `replacement`.
2211    /// Used for deprecated-builtin fixes (now→block.timestamp, sha3→keccak256, …).
2212    /// When `walk_to` is `Some`, walk up to that ancestor node and replace its
2213    /// full span instead of just the leaf (e.g. `member_expression` for msg.gas).
2214    ReplaceToken {
2215        replacement: &'a str,
2216        walk_to: Option<&'a str>,
2217    },
2218
2219    /// Delete the token whose start byte falls inside `diag_range`
2220    /// (+ one trailing space when present).
2221    DeleteToken,
2222
2223    /// Delete the entire `variable_declaration_statement` containing the
2224    /// identifier at `diag_range.start`, including leading whitespace/newline.
2225    DeleteLocalVar,
2226
2227    /// Walk up the TS tree to the first ancestor whose kind matches `node_kind`,
2228    /// then delete that whole node including its preceding newline+indentation.
2229    /// Used for any "delete this whole statement/declaration" fix (e.g. unused import).
2230    DeleteNodeByKind { node_kind: &'a str },
2231
2232    /// Walk the TS tree up to `walk_to`, then delete the first child whose
2233    /// kind matches any entry in `child_kinds` (tried in order).
2234    DeleteChildNode {
2235        walk_to: &'a str,
2236        child_kinds: &'a [&'a str],
2237    },
2238
2239    /// Walk the TS tree up to `walk_to`, then replace the first child whose
2240    /// kind matches `child_kind` with `replacement`.
2241    ReplaceChildNode {
2242        walk_to: &'a str,
2243        child_kind: &'a str,
2244        replacement: &'a str,
2245    },
2246
2247    /// Walk the TS tree up to `walk_to`, then insert `text` immediately before
2248    /// the first child whose kind matches any entry in `before_child`.
2249    /// Used for 5424 (insert `virtual` before `returns`/`;`).
2250    InsertBeforeNode {
2251        walk_to: &'a str,
2252        before_child: &'a [&'a str],
2253        text: &'a str,
2254    },
2255}
2256
2257/// Compute the `TextEdit` for a code action using tree-sitter for precision.
2258///
2259/// Returns `None` when the tree cannot be parsed or the target node cannot be
2260/// located (caller should fall back to returning no action for that diagnostic).
2261pub(crate) fn code_action_edit(
2262    source: &str,
2263    diag_range: Range,
2264    kind: CodeActionKind<'_>,
2265) -> Option<TextEdit> {
2266    let source_bytes = source.as_bytes();
2267
2268    match kind {
2269        // ── Insert fixed text at the top of the file ──────────────────────────
2270        CodeActionKind::InsertAtFileStart { text } => Some(TextEdit {
2271            range: Range {
2272                start: Position {
2273                    line: 0,
2274                    character: 0,
2275                },
2276                end: Position {
2277                    line: 0,
2278                    character: 0,
2279                },
2280            },
2281            new_text: text.to_string(),
2282        }),
2283
2284        // ── Replace the token at diag_range.start ─────────────────────────────
2285        CodeActionKind::ReplaceToken {
2286            replacement,
2287            walk_to,
2288        } => {
2289            let tree = ts_parse(source)?;
2290            let byte = pos_to_bytes(source_bytes, diag_range.start);
2291            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2292            // When a walk_to node kind is specified, walk up to that ancestor
2293            // so we replace its full span (e.g. `member_expression` for
2294            // `msg.gas` or `block.blockhash`).
2295            if let Some(target_kind) = walk_to {
2296                loop {
2297                    if node.kind() == target_kind {
2298                        break;
2299                    }
2300                    node = node.parent()?;
2301                }
2302            }
2303            let start_pos = bytes_to_pos(source_bytes, node.start_byte())?;
2304            let end_pos = bytes_to_pos(source_bytes, node.end_byte())?;
2305            Some(TextEdit {
2306                range: Range {
2307                    start: start_pos,
2308                    end: end_pos,
2309                },
2310                new_text: replacement.to_string(),
2311            })
2312        }
2313
2314        // ── Delete the token at diag_range.start (+ optional trailing space) ──
2315        CodeActionKind::DeleteToken => {
2316            let tree = ts_parse(source)?;
2317            let byte = pos_to_bytes(source_bytes, diag_range.start);
2318            let node = ts_node_at_byte(tree.root_node(), byte)?;
2319            let start = node.start_byte();
2320            let end =
2321                if node.end_byte() < source_bytes.len() && source_bytes[node.end_byte()] == b' ' {
2322                    node.end_byte() + 1
2323                } else {
2324                    node.end_byte()
2325                };
2326            let start_pos = bytes_to_pos(source_bytes, start)?;
2327            let end_pos = bytes_to_pos(source_bytes, end)?;
2328            Some(TextEdit {
2329                range: Range {
2330                    start: start_pos,
2331                    end: end_pos,
2332                },
2333                new_text: String::new(),
2334            })
2335        }
2336
2337        // ── Delete the enclosing variable_declaration_statement ───────────────
2338        CodeActionKind::DeleteLocalVar => {
2339            let tree = ts_parse(source)?;
2340            let byte = pos_to_bytes(source_bytes, diag_range.start);
2341            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2342
2343            loop {
2344                if node.kind() == "variable_declaration_statement" {
2345                    break;
2346                }
2347                node = node.parent()?;
2348            }
2349
2350            // Consume the preceding newline + indentation so no blank line remains.
2351            let stmt_start = node.start_byte();
2352            let delete_from = if stmt_start > 0 {
2353                let mut i = stmt_start - 1;
2354                while i > 0 && (source_bytes[i] == b' ' || source_bytes[i] == b'\t') {
2355                    i -= 1;
2356                }
2357                if source_bytes[i] == b'\n' {
2358                    i
2359                } else {
2360                    stmt_start
2361                }
2362            } else {
2363                stmt_start
2364            };
2365
2366            let start_pos = bytes_to_pos(source_bytes, delete_from)?;
2367            let end_pos = bytes_to_pos(source_bytes, node.end_byte())?;
2368            Some(TextEdit {
2369                range: Range {
2370                    start: start_pos,
2371                    end: end_pos,
2372                },
2373                new_text: String::new(),
2374            })
2375        }
2376
2377        // ── Walk up to `node_kind`, delete that whole node (+ preceding newline) ─
2378        CodeActionKind::DeleteNodeByKind { node_kind } => {
2379            let tree = ts_parse(source)?;
2380            let byte = pos_to_bytes(source_bytes, diag_range.start);
2381            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2382            loop {
2383                if node.kind() == node_kind {
2384                    break;
2385                }
2386                node = node.parent()?;
2387            }
2388            // Consume the preceding newline + indentation so no blank line remains.
2389            let node_start = node.start_byte();
2390            let delete_from = if node_start > 0 {
2391                let mut i = node_start - 1;
2392                while i > 0 && (source_bytes[i] == b' ' || source_bytes[i] == b'\t') {
2393                    i -= 1;
2394                }
2395                if source_bytes[i] == b'\n' {
2396                    i
2397                } else {
2398                    node_start
2399                }
2400            } else {
2401                node_start
2402            };
2403            let start_pos = bytes_to_pos(source_bytes, delete_from)?;
2404            let end_pos = bytes_to_pos(source_bytes, node.end_byte())?;
2405            Some(TextEdit {
2406                range: Range {
2407                    start: start_pos,
2408                    end: end_pos,
2409                },
2410                new_text: String::new(),
2411            })
2412        }
2413
2414        // ── Walk up to `walk_to`, delete first child of `child_kind` ─────────
2415        //
2416        // Used for 4126 (free function visibility) and payable codes where the
2417        // diagnostic points to the whole function, not the bad token.
2418        CodeActionKind::DeleteChildNode {
2419            walk_to,
2420            child_kinds,
2421        } => {
2422            let tree = ts_parse(source)?;
2423            let byte = pos_to_bytes(source_bytes, diag_range.start);
2424            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2425            loop {
2426                if node.kind() == walk_to {
2427                    break;
2428                }
2429                node = node.parent()?;
2430            }
2431            let mut cursor = node.walk();
2432            let children: Vec<_> = node.children(&mut cursor).collect();
2433            let child = child_kinds
2434                .iter()
2435                .find_map(|k| children.iter().find(|c| c.kind() == *k))?;
2436            let start = child.start_byte();
2437            let end = if child.end_byte() < source_bytes.len()
2438                && source_bytes[child.end_byte()] == b' '
2439            {
2440                child.end_byte() + 1
2441            } else {
2442                child.end_byte()
2443            };
2444            let start_pos = bytes_to_pos(source_bytes, start)?;
2445            let end_pos = bytes_to_pos(source_bytes, end)?;
2446            Some(TextEdit {
2447                range: Range {
2448                    start: start_pos,
2449                    end: end_pos,
2450                },
2451                new_text: String::new(),
2452            })
2453        }
2454
2455        // ── Walk up to `walk_to`, replace first child of `child_kind` ─────────
2456        //
2457        // Used for 1560/1159/4095: replace wrong visibility with `external`.
2458        CodeActionKind::ReplaceChildNode {
2459            walk_to,
2460            child_kind,
2461            replacement,
2462        } => {
2463            let tree = ts_parse(source)?;
2464            let byte = pos_to_bytes(source_bytes, diag_range.start);
2465            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2466            loop {
2467                if node.kind() == walk_to {
2468                    break;
2469                }
2470                node = node.parent()?;
2471            }
2472            let mut cursor = node.walk();
2473            let child = node
2474                .children(&mut cursor)
2475                .find(|c| c.kind() == child_kind)?;
2476            let start_pos = bytes_to_pos(source_bytes, child.start_byte())?;
2477            let end_pos = bytes_to_pos(source_bytes, child.end_byte())?;
2478            Some(TextEdit {
2479                range: Range {
2480                    start: start_pos,
2481                    end: end_pos,
2482                },
2483                new_text: replacement.to_string(),
2484            })
2485        }
2486
2487        // ── Walk up to `walk_to`, insert `text` before first matching child ───
2488        //
2489        // Used for 5424 (insert `virtual` before `return_type_definition` / `;`).
2490        //
2491        // `before_child` is tried in order — the first matching child kind wins.
2492        // This lets callers express "prefer returns, fall back to semicolon".
2493        CodeActionKind::InsertBeforeNode {
2494            walk_to,
2495            before_child,
2496            text,
2497        } => {
2498            let tree = ts_parse(source)?;
2499            let byte = pos_to_bytes(source_bytes, diag_range.start);
2500            let mut node = ts_node_at_byte(tree.root_node(), byte)?;
2501
2502            loop {
2503                if node.kind() == walk_to {
2504                    break;
2505                }
2506                node = node.parent()?;
2507            }
2508
2509            let mut cursor = node.walk();
2510            let children: Vec<_> = node.children(&mut cursor).collect();
2511
2512            // Try each `before_child` kind in order.
2513            for target_kind in before_child {
2514                if let Some(child) = children.iter().find(|c| c.kind() == *target_kind) {
2515                    let insert_pos = bytes_to_pos(source_bytes, child.start_byte())?;
2516                    return Some(TextEdit {
2517                        range: Range {
2518                            start: insert_pos,
2519                            end: insert_pos,
2520                        },
2521                        new_text: text.to_string(),
2522                    });
2523                }
2524            }
2525            None
2526        }
2527    }
2528}
2529
2530#[cfg(test)]
2531mod ts_tests {
2532    use super::*;
2533
2534    #[test]
2535    fn test_cursor_context_state_var() {
2536        let source = r#"
2537contract Token {
2538    uint256 public totalSupply;
2539    function mint(uint256 amount) public {
2540        totalSupply += amount;
2541    }
2542}
2543"#;
2544        // Cursor on `totalSupply` inside mint (line 4, col 8)
2545        let ctx = cursor_context(source, Position::new(4, 8)).unwrap();
2546        assert_eq!(ctx.name, "totalSupply");
2547        assert_eq!(ctx.function.as_deref(), Some("mint"));
2548        assert_eq!(ctx.contract.as_deref(), Some("Token"));
2549    }
2550
2551    #[test]
2552    fn test_cursor_context_top_level() {
2553        let source = r#"
2554contract Foo {}
2555contract Bar {}
2556"#;
2557        // Cursor on `Foo` (line 1, col 9) — the identifier of the contract declaration
2558        let ctx = cursor_context(source, Position::new(1, 9)).unwrap();
2559        assert_eq!(ctx.name, "Foo");
2560        assert!(ctx.function.is_none());
2561        // The identifier `Foo` is a child of contract_declaration, so contract is set
2562        assert_eq!(ctx.contract.as_deref(), Some("Foo"));
2563    }
2564
2565    #[test]
2566    fn test_find_declarations() {
2567        let source = r#"
2568contract Token {
2569    uint256 public totalSupply;
2570    function mint(uint256 amount) public {
2571        totalSupply += amount;
2572    }
2573}
2574"#;
2575        let decls = find_declarations_by_name(source, "totalSupply");
2576        assert_eq!(decls.len(), 1);
2577        assert_eq!(decls[0].kind, "state_variable_declaration");
2578        assert_eq!(decls[0].container.as_deref(), Some("Token"));
2579    }
2580
2581    #[test]
2582    fn test_find_declarations_multiple_contracts() {
2583        let source = r#"
2584contract A {
2585    uint256 public value;
2586}
2587contract B {
2588    uint256 public value;
2589}
2590"#;
2591        let decls = find_declarations_by_name(source, "value");
2592        assert_eq!(decls.len(), 2);
2593        assert_eq!(decls[0].container.as_deref(), Some("A"));
2594        assert_eq!(decls[1].container.as_deref(), Some("B"));
2595    }
2596
2597    #[test]
2598    fn test_find_declarations_enum_value() {
2599        let source = "contract Foo { enum Status { Active, Paused } }";
2600        let decls = find_declarations_by_name(source, "Active");
2601        assert_eq!(decls.len(), 1);
2602        assert_eq!(decls[0].kind, "enum_value");
2603        assert_eq!(decls[0].container.as_deref(), Some("Status"));
2604    }
2605
2606    #[test]
2607    fn test_cursor_context_short_param() {
2608        let source = r#"
2609contract Shop {
2610    uint256 public TAX;
2611    constructor(uint256 price, uint16 tax, uint16 taxBase) {
2612        TAX = tax;
2613    }
2614}
2615"#;
2616        // Cursor on `tax` usage at line 4, col 14 (TAX = tax;)
2617        let ctx = cursor_context(source, Position::new(4, 14)).unwrap();
2618        assert_eq!(ctx.name, "tax");
2619        assert_eq!(ctx.contract.as_deref(), Some("Shop"));
2620
2621        // Cursor on `TAX` at line 4, col 8
2622        let ctx2 = cursor_context(source, Position::new(4, 8)).unwrap();
2623        assert_eq!(ctx2.name, "TAX");
2624
2625        // Parameters are found as declarations
2626        let decls = find_declarations_by_name(source, "tax");
2627        assert_eq!(decls.len(), 1);
2628        assert_eq!(decls[0].kind, "parameter");
2629
2630        let decls_tax_base = find_declarations_by_name(source, "taxBase");
2631        assert_eq!(decls_tax_base.len(), 1);
2632        assert_eq!(decls_tax_base[0].kind, "parameter");
2633
2634        let decls_price = find_declarations_by_name(source, "price");
2635        assert_eq!(decls_price.len(), 1);
2636        assert_eq!(decls_price[0].kind, "parameter");
2637
2638        // State variable is also found
2639        let decls_tax_upper = find_declarations_by_name(source, "TAX");
2640        assert_eq!(decls_tax_upper.len(), 1);
2641        assert_eq!(decls_tax_upper[0].kind, "state_variable_declaration");
2642    }
2643
2644    #[test]
2645    fn test_delete_child_node_2462_constructor_public() {
2646        // ConstructorVisibility.sol: `constructor() public {}`
2647        // Diagnostic range: start={line:9, char:4}, end={line:11, char:5}
2648        let source = "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\n// Warning 2462\n\ncontract ConstructorVisibility {\n    uint256 public value;\n\n    constructor() public {\n        value = 1;\n    }\n}\n";
2649        let diag_range = Range {
2650            start: Position {
2651                line: 8,
2652                character: 4,
2653            },
2654            end: Position {
2655                line: 10,
2656                character: 5,
2657            },
2658        };
2659        let source_bytes = source.as_bytes();
2660        let tree = ts_parse(source).expect("parse failed");
2661        let byte = pos_to_bytes(source_bytes, diag_range.start);
2662        eprintln!("2462 byte offset: {byte}");
2663        if let Some(mut n) = ts_node_at_byte(tree.root_node(), byte) {
2664            loop {
2665                eprintln!(
2666                    "  ancestor: kind={} start={} end={}",
2667                    n.kind(),
2668                    n.start_byte(),
2669                    n.end_byte()
2670                );
2671                if n.kind() == "constructor_definition" {
2672                    let mut cursor = n.walk();
2673                    for child in n.children(&mut cursor) {
2674                        eprintln!(
2675                            "    child: kind={:?} text={:?}",
2676                            child.kind(),
2677                            &source[child.start_byte()..child.end_byte()]
2678                        );
2679                    }
2680                    break;
2681                }
2682                match n.parent() {
2683                    Some(p) => n = p,
2684                    None => break,
2685                }
2686            }
2687        }
2688        let ck: Vec<&str> = vec!["public", "modifier_invocation"];
2689        let edit = code_action_edit(
2690            source,
2691            diag_range,
2692            CodeActionKind::DeleteChildNode {
2693                walk_to: "constructor_definition",
2694                child_kinds: &ck,
2695            },
2696        );
2697        assert!(edit.is_some(), "2462 fix returned None");
2698        let edit = edit.unwrap();
2699        assert_eq!(edit.new_text, "");
2700        let lines: Vec<&str> = source.lines().collect();
2701        let deleted = &lines[edit.range.start.line as usize]
2702            [edit.range.start.character as usize..edit.range.end.character as usize];
2703        assert!(deleted.contains("public"), "deleted: {:?}", deleted);
2704    }
2705
2706    #[test]
2707    fn test_delete_child_node_9239_constructor_private() {
2708        // Fixture mirrors example/codeaction/ConstructorInvalidVisibility.sol
2709        // Diagnostic range from server: start={line:9, char:4}, end={line:11, char:5}
2710        let source = "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\n// Error 9239: Constructor cannot have visibility.\n// Fix: remove the visibility specifier (private/external) from the constructor.\n\ncontract ConstructorPrivateVisibility {\n    uint256 public value;\n\n    constructor() private {\n        value = 1;\n    }\n}\n";
2711        let diag_range = Range {
2712            start: Position {
2713                line: 9,
2714                character: 4,
2715            },
2716            end: Position {
2717                line: 11,
2718                character: 5,
2719            },
2720        };
2721        let ck: Vec<&str> = vec!["modifier_invocation"];
2722        let edit = code_action_edit(
2723            source,
2724            diag_range,
2725            CodeActionKind::DeleteChildNode {
2726                walk_to: "constructor_definition",
2727                child_kinds: &ck,
2728            },
2729        );
2730        assert!(edit.is_some(), "expected Some(TextEdit) for 9239, got None");
2731        let edit = edit.unwrap();
2732        // The edit should delete 'private ' — new_text must be empty
2733        assert_eq!(edit.new_text, "", "expected deletion (empty new_text)");
2734        // The deleted text should contain 'private'
2735        let lines: Vec<&str> = source.lines().collect();
2736        let line_text = lines[edit.range.start.line as usize];
2737        let deleted =
2738            &line_text[edit.range.start.character as usize..edit.range.end.character as usize];
2739        assert!(
2740            deleted.contains("private"),
2741            "expected deleted text to contain 'private', got: {:?}",
2742            deleted
2743        );
2744    }
2745
2746    #[test]
2747    fn test_find_best_declaration_same_contract() {
2748        let source = r#"
2749contract A { uint256 public x; }
2750contract B { uint256 public x; }
2751"#;
2752        let ctx = CursorContext {
2753            name: "x".into(),
2754            function: None,
2755            contract: Some("B".into()),
2756            object: None,
2757            arg_count: None,
2758            arg_types: vec![],
2759        };
2760        let uri = Url::parse("file:///test.sol").unwrap();
2761        let loc = find_best_declaration(source, &ctx, &uri).unwrap();
2762        // Should pick B's x (line 2), not A's x (line 1)
2763        assert_eq!(loc.range.start.line, 2);
2764    }
2765
2766    /// Old cache format (explicit nulls/empty arrays) must still deserialize.
2767    #[test]
2768    fn node_info_deserialize_old_format_with_explicit_nulls() {
2769        let json = r#"{
2770            "src": "100:20:5",
2771            "name_location": null,
2772            "name_locations": [],
2773            "referenced_declaration": null,
2774            "node_type": "Block",
2775            "member_location": null,
2776            "absolute_path": null,
2777            "scope": null,
2778            "base_functions": []
2779        }"#;
2780        let info: NodeInfo = serde_json::from_str(json).unwrap();
2781        assert_eq!(info.src.as_str(), "100:20:5");
2782        assert!(info.name_location.is_none());
2783        assert!(info.name_locations.is_empty());
2784        assert!(info.referenced_declaration.is_none());
2785        assert_eq!(info.node_type.as_deref(), Some("Block"));
2786        assert!(info.member_location.is_none());
2787        assert!(info.absolute_path.is_none());
2788        assert!(info.scope.is_none());
2789        assert!(info.base_functions.is_empty());
2790    }
2791
2792    /// New compact format (absent fields) must deserialize identically.
2793    #[test]
2794    fn node_info_deserialize_new_format_with_absent_fields() {
2795        let json = r#"{
2796            "src": "100:20:5",
2797            "node_type": "Block"
2798        }"#;
2799        let info: NodeInfo = serde_json::from_str(json).unwrap();
2800        assert_eq!(info.src.as_str(), "100:20:5");
2801        assert!(info.name_location.is_none());
2802        assert!(info.name_locations.is_empty());
2803        assert!(info.referenced_declaration.is_none());
2804        assert_eq!(info.node_type.as_deref(), Some("Block"));
2805        assert!(info.member_location.is_none());
2806        assert!(info.absolute_path.is_none());
2807        assert!(info.scope.is_none());
2808        assert!(info.base_functions.is_empty());
2809    }
2810
2811    /// Round-trip: serialize a sparse NodeInfo, then deserialize — nulls/empties must be omitted.
2812    #[test]
2813    fn node_info_round_trip_skips_null_empty() {
2814        let info = NodeInfo {
2815            src: crate::types::SrcLocation::new("50:10:3".to_string()),
2816            name_location: None,
2817            name_locations: vec![],
2818            referenced_declaration: None,
2819            node_type: Some("Identifier".to_string()),
2820            member_location: None,
2821            member_name: None,
2822            absolute_path: None,
2823            scope: None,
2824            base_functions: vec![],
2825        };
2826        let serialized = serde_json::to_string(&info).unwrap();
2827        // Must NOT contain null-valued or empty-array keys
2828        assert!(!serialized.contains("name_location"));
2829        assert!(!serialized.contains("name_locations"));
2830        assert!(!serialized.contains("referenced_declaration"));
2831        assert!(!serialized.contains("member_location"));
2832        assert!(!serialized.contains("member_name"));
2833        assert!(!serialized.contains("absolute_path"));
2834        assert!(!serialized.contains("scope"));
2835        assert!(!serialized.contains("base_functions"));
2836        // Must contain the populated fields
2837        assert!(serialized.contains("\"src\""));
2838        assert!(serialized.contains("\"node_type\""));
2839
2840        // Deserialize back and verify equality
2841        let deserialized: NodeInfo = serde_json::from_str(&serialized).unwrap();
2842        assert_eq!(deserialized.src.as_str(), "50:10:3");
2843        assert!(deserialized.name_location.is_none());
2844        assert!(deserialized.base_functions.is_empty());
2845    }
2846
2847    /// Round-trip: serialize a fully populated NodeInfo — all fields must be present.
2848    #[test]
2849    fn node_info_round_trip_preserves_populated_fields() {
2850        use crate::types::NodeId;
2851        let info = NodeInfo {
2852            src: crate::types::SrcLocation::new("200:30:7".to_string()),
2853            name_location: Some("205:4:7".to_string()),
2854            name_locations: vec!["205:4:7".to_string(), "210:4:7".to_string()],
2855            referenced_declaration: Some(NodeId(42)),
2856            node_type: Some("FunctionDefinition".to_string()),
2857            member_location: Some("220:6:7".to_string()),
2858            member_name: Some("transfer".to_string()),
2859            absolute_path: Some("/src/Foo.sol".to_string()),
2860            scope: Some(NodeId(10)),
2861            base_functions: vec![NodeId(100), NodeId(200)],
2862        };
2863        let serialized = serde_json::to_string(&info).unwrap();
2864        // All fields must be present
2865        assert!(serialized.contains("name_location"));
2866        assert!(serialized.contains("name_locations"));
2867        assert!(serialized.contains("referenced_declaration"));
2868        assert!(serialized.contains("member_location"));
2869        assert!(serialized.contains("member_name"));
2870        assert!(serialized.contains("absolute_path"));
2871        assert!(serialized.contains("scope"));
2872        assert!(serialized.contains("base_functions"));
2873
2874        let deserialized: NodeInfo = serde_json::from_str(&serialized).unwrap();
2875        assert_eq!(deserialized.name_location.as_deref(), Some("205:4:7"));
2876        assert_eq!(deserialized.name_locations.len(), 2);
2877        assert_eq!(deserialized.referenced_declaration, Some(NodeId(42)));
2878        assert_eq!(deserialized.scope, Some(NodeId(10)));
2879        assert_eq!(deserialized.base_functions.len(), 2);
2880    }
2881
2882    // ── Non-ASCII slicing ──────────────────────────────────────────────
2883
2884    /// `validate_goto_target` slices the target line by LSP columns. Those are
2885    /// UTF-16 units, so on a line with multi-byte text they can land inside a
2886    /// character — which used to panic and kill the whole server process.
2887    #[test]
2888    fn validate_goto_target_survives_a_column_inside_a_character() {
2889        let source = "contract C {\n    // 状態\n    uint256 total;\n}";
2890        // Columns that fall inside the multi-byte comment on line 1.
2891        let location = Location {
2892            uri: Url::parse("file:///tmp/C.sol").unwrap(),
2893            range: Range {
2894                start: Position::new(1, 8),
2895                end: Position::new(1, 9),
2896            },
2897        };
2898
2899        // Must not panic. Either verdict is acceptable; surviving is the point.
2900        let _ = validate_goto_target(source, &location, "total");
2901    }
2902
2903    /// The same guard for a range that starts past its end, which the byte
2904    /// bound alone never rejected.
2905    #[test]
2906    fn validate_goto_target_survives_an_inverted_range() {
2907        let source = "contract C {\n    uint256 total;\n}";
2908        let location = Location {
2909            uri: Url::parse("file:///tmp/C.sol").unwrap(),
2910            range: Range {
2911                start: Position::new(1, 12),
2912                end: Position::new(1, 4),
2913            },
2914        };
2915
2916        let _ = validate_goto_target(source, &location, "total");
2917    }
2918
2919    /// `goto_declaration_by_name` slices the on-disk file with offsets from a
2920    /// previous build. Those can be stale, and on a file with multi-byte text a
2921    /// stale offset can land inside a character — which used to panic.
2922    #[test]
2923    fn goto_declaration_by_name_survives_a_stale_offset_inside_a_character() {
2924        let dir = tempfile::tempdir().expect("tempdir");
2925        let file = dir.path().join("C.sol");
2926        // `状` occupies bytes 21..24, so an offset of 22 is mid-character.
2927        std::fs::write(&file, "contract C {\n    // 状態\n}\n").expect("write");
2928        let abs = file.to_string_lossy().to_string();
2929
2930        let ast = serde_json::json!({
2931            "sources": {
2932                &abs: {
2933                    "id": 0,
2934                    "ast": {
2935                        "id": 1,
2936                        "nodeType": "SourceUnit",
2937                        "src": "0:30:0",
2938                        "absolutePath": &abs,
2939                        "nodes": [{
2940                            "id": 2,
2941                            "nodeType": "Identifier",
2942                            "name": "total",
2943                            // Deliberately mid-character and overlong.
2944                            "src": "22:5:0",
2945                            "referencedDeclaration": 3
2946                        }]
2947                    }
2948                }
2949            },
2950            "source_id_to_path": { "0": &abs }
2951        });
2952
2953        let build = CachedBuild::new(ast, 1, None);
2954        let uri = Url::from_file_path(&file).expect("uri");
2955
2956        // Must not panic. A None result is a perfectly good answer here.
2957        let _ = goto_declaration_by_name(&build, &uri, "total", 22);
2958    }
2959
2960    /// An ASCII line still validates exactly as before.
2961    #[test]
2962    fn validate_goto_target_still_matches_on_ascii() {
2963        let source = "contract C {\n    uint256 total;\n}";
2964        let location = Location {
2965            uri: Url::parse("file:///tmp/C.sol").unwrap(),
2966            range: Range {
2967                start: Position::new(1, 12),
2968                end: Position::new(1, 17),
2969            },
2970        };
2971
2972        assert!(validate_goto_target(source, &location, "total"));
2973        assert!(!validate_goto_target(source, &location, "other"));
2974    }
2975}
2976// temp