Skip to main content

solidity_language_server/
gas.rs

1//! Gas estimate extraction from solc contract output.
2//!
3//! Builds lookup tables from `contracts[path][name].contract.evm.gasEstimates`
4//! and `contracts[path][name].contract.evm.methodIdentifiers` so that hover,
5//! inlay hints, and code lenses can display gas costs.
6
7use serde_json::Value;
8use std::collections::HashMap;
9
10use crate::types::{FuncSelector, MethodId};
11
12/// Sentinel comment to enable gas estimates for a function.
13/// Place `/// @custom:lsp-enable gas-estimates` above a function definition.
14/// Also matches the shorter `/// lsp-enable gas-estimates` form.
15pub const GAS_SENTINEL: &str = "lsp-enable gas-estimates";
16
17/// Gas estimates for a single contract.
18#[derive(Debug, Clone, Default)]
19pub struct ContractGas {
20    /// Deploy costs: `codeDepositCost`, `executionCost`, `totalCost`.
21    pub creation: HashMap<String, String>,
22    /// External function gas keyed by 4-byte selector.
23    pub external_by_selector: HashMap<FuncSelector, String>,
24    /// External function gas keyed by ABI signature (for display).
25    pub external_by_sig: HashMap<MethodId, String>,
26    /// Internal function gas: signature → gas cost.
27    pub internal: HashMap<String, String>,
28}
29
30/// All gas estimates indexed by (source_path, contract_name).
31pub type GasIndex = HashMap<String, ContractGas>;
32
33/// Build a gas index from normalized AST output.
34///
35/// The index key is `"path:ContractName"` (e.g. `"src/PoolManager.sol:PoolManager"`).
36/// For external functions, gas is also indexed by 4-byte selector for fast lookup
37/// from AST nodes that have `functionSelector`.
38///
39/// Expects the canonical shape: `contracts[path][name] = { abi, evm, ... }`.
40pub fn build_gas_index(ast_data: &Value) -> GasIndex {
41    let mut index = GasIndex::new();
42
43    let contracts = match ast_data.get("contracts").and_then(|c| c.as_object()) {
44        Some(c) => c,
45        None => return index,
46    };
47
48    for (path, names) in contracts {
49        let names_obj = match names.as_object() {
50            Some(n) => n,
51            None => continue,
52        };
53
54        for (name, contract) in names_obj {
55            let evm = match contract.get("evm") {
56                Some(e) => e,
57                None => continue,
58            };
59
60            let gas_estimates = match evm.get("gasEstimates") {
61                Some(g) => g,
62                None => continue,
63            };
64
65            let mut contract_gas = ContractGas::default();
66
67            // Creation costs
68            if let Some(creation) = gas_estimates.get("creation").and_then(|c| c.as_object()) {
69                for (key, value) in creation {
70                    let cost = value.as_str().unwrap_or("").to_string();
71                    contract_gas.creation.insert(key.clone(), cost);
72                }
73            }
74
75            // External function gas — also build selector → gas mapping
76            let method_ids = evm.get("methodIdentifiers").and_then(|m| m.as_object());
77
78            if let Some(external) = gas_estimates.get("external").and_then(|e| e.as_object()) {
79                // Build signature → selector reverse map
80                let sig_to_selector: HashMap<&str, &str> = method_ids
81                    .map(|mi| {
82                        mi.iter()
83                            .filter_map(|(sig, sel)| sel.as_str().map(|s| (sig.as_str(), s)))
84                            .collect()
85                    })
86                    .unwrap_or_default();
87
88                for (sig, value) in external {
89                    let cost = value.as_str().unwrap_or("").to_string();
90                    // Store by selector for fast AST node lookup
91                    if let Some(selector) = sig_to_selector.get(sig.as_str()) {
92                        contract_gas
93                            .external_by_selector
94                            .insert(FuncSelector::new(*selector), cost.clone());
95                    }
96                    // Also store by signature for display
97                    contract_gas
98                        .external_by_sig
99                        .insert(MethodId::new(sig.clone()), cost);
100                }
101            }
102
103            // Internal function gas
104            if let Some(internal) = gas_estimates.get("internal").and_then(|i| i.as_object()) {
105                for (sig, value) in internal {
106                    let cost = value.as_str().unwrap_or("").to_string();
107                    contract_gas.internal.insert(sig.clone(), cost);
108                }
109            }
110
111            let key = format!("{path}:{name}");
112            index.insert(key, contract_gas);
113        }
114    }
115
116    index
117}
118
119/// Look up gas cost for a function by its [`FuncSelector`] (external functions).
120pub fn gas_by_selector<'a>(
121    index: &'a GasIndex,
122    selector: &FuncSelector,
123) -> Option<(&'a str, &'a str)> {
124    for (contract_key, gas) in index {
125        if let Some(cost) = gas.external_by_selector.get(selector) {
126            return Some((contract_key.as_str(), cost.as_str()));
127        }
128    }
129    None
130}
131
132/// Look up gas cost for an internal function by name.
133///
134/// Matches if the gas estimate key starts with `name(`.
135pub fn gas_by_name<'a>(index: &'a GasIndex, name: &str) -> Vec<(&'a str, &'a str, &'a str)> {
136    let prefix = format!("{name}(");
137    let mut results = Vec::new();
138    for (contract_key, gas) in index {
139        for (sig, cost) in &gas.internal {
140            if sig.starts_with(&prefix) {
141                results.push((contract_key.as_str(), sig.as_str(), cost.as_str()));
142            }
143        }
144    }
145    results
146}
147
148/// Look up creation/deploy gas for a contract.
149pub fn gas_for_contract<'a>(
150    index: &'a GasIndex,
151    path: &str,
152    name: &str,
153) -> Option<&'a ContractGas> {
154    let key = format!("{path}:{name}");
155    index.get(&key)
156}
157
158/// Resolve the gas index key for a declaration node.
159///
160/// Typed version of `resolve_contract_key` using `DeclNode` and typed indices.
161///
162/// Resolves a declaration node to its `"{abs_path}:{contract_name}"` gas index key
163/// using typed field access instead of raw `Value` chains.
164pub fn resolve_contract_key_typed(
165    decl: &crate::solc_ast::DeclNode,
166    index: &GasIndex,
167    decl_index: &HashMap<i64, crate::solc_ast::DeclNode>,
168    node_id_to_source_path: &HashMap<i64, String>,
169) -> Option<String> {
170    use crate::solc_ast::DeclNode;
171
172    // Get the contract name and source unit scope
173    let (contract_name, source_unit_scope) = match decl {
174        DeclNode::ContractDefinition(c) => (c.name.as_str(), c.scope?),
175        _ => {
176            // Walk up to containing contract via scope
177            let scope_id = decl.scope()?;
178            let scope_decl = decl_index.get(&scope_id)?;
179            let contract = match scope_decl {
180                DeclNode::ContractDefinition(c) => c,
181                _ => return None,
182            };
183            (contract.name.as_str(), contract.scope?)
184        }
185    };
186
187    // Find the absolute path via node_id_to_source_path (O(1) lookup)
188    let abs_path = node_id_to_source_path.get(&source_unit_scope)?;
189
190    // Build the exact key
191    let exact_key = format!("{abs_path}:{contract_name}");
192    if index.contains_key(&exact_key) {
193        return Some(exact_key);
194    }
195
196    // Fallback: match by suffix
197    let file_name = std::path::Path::new(abs_path.as_str())
198        .file_name()?
199        .to_str()?;
200    let suffix = format!("{file_name}:{contract_name}");
201    index.keys().find(|k| k.ends_with(&suffix)).cloned()
202}
203
204/// Format a gas cost for display.
205/// Numbers get comma-separated (e.g. "6924600" → "6,924,600").
206/// "infinite" stays as-is.
207pub fn format_gas(cost: &str) -> String {
208    if cost == "infinite" {
209        return "infinite".to_string();
210    }
211    // Try to parse as number and format with commas
212    if let Ok(n) = cost.parse::<u64>() {
213        let s = n.to_string();
214        let mut result = String::new();
215        for (i, c) in s.chars().rev().enumerate() {
216            if i > 0 && i % 3 == 0 {
217                result.push(',');
218            }
219            result.push(c);
220        }
221        result.chars().rev().collect()
222    } else {
223        cost.to_string()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    /// Load poolmanager.json (raw solc output) and normalize to canonical shape.
233    fn load_solc_fixture() -> Value {
234        let data = std::fs::read_to_string("poolmanager.json").expect("test fixture");
235        let raw: Value = serde_json::from_str(&data).expect("valid json");
236        crate::solc::normalize_solc_output(raw, None)
237    }
238
239    #[test]
240    fn test_format_gas_number() {
241        assert_eq!(format_gas("109"), "109");
242        assert_eq!(format_gas("2595"), "2,595");
243        assert_eq!(format_gas("6924600"), "6,924,600");
244        assert_eq!(format_gas("28088"), "28,088");
245    }
246
247    #[test]
248    fn test_format_gas_infinite() {
249        assert_eq!(format_gas("infinite"), "infinite");
250    }
251
252    #[test]
253    fn test_format_gas_unknown() {
254        assert_eq!(format_gas("unknown"), "unknown");
255    }
256
257    #[test]
258    fn test_build_gas_index_empty() {
259        let data = json!({});
260        let index = build_gas_index(&data);
261        assert!(index.is_empty());
262    }
263
264    #[test]
265    fn test_build_gas_index_no_contracts() {
266        let data = json!({ "sources": {}, "contracts": {} });
267        let index = build_gas_index(&data);
268        assert!(index.is_empty());
269    }
270
271    #[test]
272    fn test_build_gas_index_basic() {
273        let data = json!({
274            "contracts": {
275                "src/Foo.sol": {
276                    "Foo": {
277                        "evm": {
278                            "gasEstimates": {
279                                "creation": {
280                                    "codeDepositCost": "200",
281                                    "executionCost": "infinite",
282                                    "totalCost": "infinite"
283                                },
284                                "external": {
285                                    "bar(uint256)": "109"
286                                },
287                                "internal": {
288                                    "_baz(uint256)": "50"
289                                }
290                            },
291                            "methodIdentifiers": {
292                                "bar(uint256)": "abcd1234"
293                            }
294                        }
295                    }
296                }
297            }
298        });
299
300        let index = build_gas_index(&data);
301        assert_eq!(index.len(), 1);
302
303        let foo = index.get("src/Foo.sol:Foo").unwrap();
304
305        // Creation
306        assert_eq!(foo.creation.get("codeDepositCost").unwrap(), "200");
307        assert_eq!(foo.creation.get("executionCost").unwrap(), "infinite");
308
309        // External — by selector
310        assert_eq!(
311            foo.external_by_selector
312                .get(&FuncSelector::new("abcd1234"))
313                .unwrap(),
314            "109"
315        );
316        // External — by signature
317        assert_eq!(
318            foo.external_by_sig
319                .get(&MethodId::new("bar(uint256)"))
320                .unwrap(),
321            "109"
322        );
323
324        // Internal
325        assert_eq!(foo.internal.get("_baz(uint256)").unwrap(), "50");
326    }
327
328    #[test]
329    fn test_gas_by_selector() {
330        let data = json!({
331            "contracts": {
332                "src/Foo.sol": {
333                    "Foo": {
334                        "evm": {
335                            "gasEstimates": {
336                                "external": { "bar(uint256)": "109" }
337                            },
338                            "methodIdentifiers": {
339                                "bar(uint256)": "abcd1234"
340                            }
341                        }
342                    }
343                }
344            }
345        });
346
347        let index = build_gas_index(&data);
348        let (contract, cost) = gas_by_selector(&index, &FuncSelector::new("abcd1234")).unwrap();
349        assert_eq!(contract, "src/Foo.sol:Foo");
350        assert_eq!(cost, "109");
351    }
352
353    #[test]
354    fn test_gas_by_name() {
355        let data = json!({
356            "contracts": {
357                "src/Foo.sol": {
358                    "Foo": {
359                        "evm": {
360                            "gasEstimates": {
361                                "internal": {
362                                    "_baz(uint256)": "50",
363                                    "_baz(uint256,address)": "120"
364                                }
365                            }
366                        }
367                    }
368                }
369            }
370        });
371
372        let index = build_gas_index(&data);
373        let results = gas_by_name(&index, "_baz");
374        assert_eq!(results.len(), 2);
375    }
376
377    #[test]
378    fn test_gas_for_contract() {
379        let data = json!({
380            "contracts": {
381                "src/Foo.sol": {
382                    "Foo": {
383                        "evm": {
384                            "gasEstimates": {
385                                "creation": {
386                                    "codeDepositCost": "6924600"
387                                }
388                            }
389                        }
390                    }
391                }
392            }
393        });
394
395        let index = build_gas_index(&data);
396        let gas = gas_for_contract(&index, "src/Foo.sol", "Foo").unwrap();
397        assert_eq!(gas.creation.get("codeDepositCost").unwrap(), "6924600");
398    }
399
400    #[test]
401    fn test_build_gas_index_from_solc_fixture() {
402        let ast = load_solc_fixture();
403        let index = build_gas_index(&ast);
404
405        // poolmanager.json has gas estimates for PoolManager
406        assert!(!index.is_empty(), "solc fixture should have gas data");
407
408        // Find PoolManager — keys have absolute paths
409        let pm_key = index
410            .keys()
411            .find(|k| k.contains("PoolManager.sol:PoolManager"))
412            .expect("should have PoolManager gas data");
413
414        let pm = index.get(pm_key).unwrap();
415
416        // Creation costs
417        assert!(
418            pm.creation.contains_key("codeDepositCost"),
419            "should have codeDepositCost"
420        );
421        assert!(
422            pm.creation.contains_key("executionCost"),
423            "should have executionCost"
424        );
425        assert!(
426            pm.creation.contains_key("totalCost"),
427            "should have totalCost"
428        );
429
430        // External functions
431        assert!(
432            !pm.external_by_selector.is_empty(),
433            "should have external function gas estimates"
434        );
435
436        // Internal functions
437        assert!(
438            !pm.internal.is_empty(),
439            "should have internal function gas estimates"
440        );
441    }
442
443    #[test]
444    fn test_gas_by_selector_from_solc_fixture() {
445        let ast = load_solc_fixture();
446        let index = build_gas_index(&ast);
447
448        // owner() has selector "8da5cb5b" (well-known)
449        let result = gas_by_selector(&index, &FuncSelector::new("8da5cb5b"));
450        assert!(result.is_some(), "should find owner() by selector");
451        let (contract, cost) = result.unwrap();
452        assert!(
453            contract.contains("PoolManager"),
454            "should be PoolManager contract"
455        );
456        assert!(!cost.is_empty(), "should have a gas cost");
457    }
458
459    #[test]
460    fn test_gas_by_name_from_solc_fixture() {
461        let ast = load_solc_fixture();
462        let index = build_gas_index(&ast);
463
464        // _getPool is an internal function in PoolManager
465        let results = gas_by_name(&index, "_getPool");
466        assert!(!results.is_empty(), "should find _getPool internal gas");
467    }
468
469    #[test]
470    fn test_gas_for_contract_from_solc_fixture() {
471        let ast = load_solc_fixture();
472        let index = build_gas_index(&ast);
473
474        // Find the PoolManager key
475        let pm_key = index
476            .keys()
477            .find(|k| k.contains("PoolManager.sol:PoolManager"))
478            .expect("should have PoolManager");
479
480        // Parse the path and name from "path:Name"
481        let parts: Vec<&str> = pm_key.rsplitn(2, ':').collect();
482        let name = parts[0];
483        let path = parts[1];
484
485        let gas = gas_for_contract(&index, path, name);
486        assert!(gas.is_some(), "should find PoolManager contract gas");
487        assert_eq!(
488            gas.unwrap().creation.get("executionCost").unwrap(),
489            "infinite"
490        );
491    }
492}