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/// Walks up through `scope` to find the containing `ContractDefinition`,
161/// then further to the `SourceUnit` to get `absolutePath`.
162/// Returns the `"path:ContractName"` key used in the gas index.
163pub fn resolve_contract_key(
164    sources: &Value,
165    decl_node: &Value,
166    index: &GasIndex,
167) -> Option<String> {
168    let node_type = decl_node.get("nodeType").and_then(|v| v.as_str())?;
169
170    // If this IS a ContractDefinition, find its source path directly
171    let (contract_name, source_id) = if node_type == "ContractDefinition" {
172        let name = decl_node.get("name").and_then(|v| v.as_str())?;
173        let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
174        (name.to_string(), scope_id)
175    } else {
176        // Walk up to containing contract
177        let scope_id = decl_node.get("scope").and_then(|v| v.as_u64())?;
178        let scope_node = crate::hover::find_node_by_id(sources, crate::types::NodeId(scope_id))?;
179        let contract_name = scope_node.get("name").and_then(|v| v.as_str())?;
180        let source_id = scope_node.get("scope").and_then(|v| v.as_u64())?;
181        (contract_name.to_string(), source_id)
182    };
183
184    // Find the SourceUnit to get the absolute path
185    let source_unit = crate::hover::find_node_by_id(sources, crate::types::NodeId(source_id))?;
186    let abs_path = source_unit.get("absolutePath").and_then(|v| v.as_str())?;
187
188    // Build the exact key
189    let exact_key = format!("{abs_path}:{contract_name}");
190    if index.contains_key(&exact_key) {
191        return Some(exact_key);
192    }
193
194    // Fallback: the gas index may use a different path representation.
195    // Match by suffix — find a key ending with the filename:ContractName.
196    let file_name = std::path::Path::new(abs_path).file_name()?.to_str()?;
197    let suffix = format!("{file_name}:{contract_name}");
198    index.keys().find(|k| k.ends_with(&suffix)).cloned()
199}
200
201/// Format a gas cost for display.
202/// Numbers get comma-separated (e.g. "6924600" → "6,924,600").
203/// "infinite" stays as-is.
204pub fn format_gas(cost: &str) -> String {
205    if cost == "infinite" {
206        return "infinite".to_string();
207    }
208    // Try to parse as number and format with commas
209    if let Ok(n) = cost.parse::<u64>() {
210        let s = n.to_string();
211        let mut result = String::new();
212        for (i, c) in s.chars().rev().enumerate() {
213            if i > 0 && i % 3 == 0 {
214                result.push(',');
215            }
216            result.push(c);
217        }
218        result.chars().rev().collect()
219    } else {
220        cost.to_string()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use serde_json::json;
228
229    /// Load poolmanager.json (raw solc output) and normalize to canonical shape.
230    fn load_solc_fixture() -> Value {
231        let data = std::fs::read_to_string("poolmanager.json").expect("test fixture");
232        let raw: Value = serde_json::from_str(&data).expect("valid json");
233        crate::solc::normalize_solc_output(raw, None)
234    }
235
236    #[test]
237    fn test_format_gas_number() {
238        assert_eq!(format_gas("109"), "109");
239        assert_eq!(format_gas("2595"), "2,595");
240        assert_eq!(format_gas("6924600"), "6,924,600");
241        assert_eq!(format_gas("28088"), "28,088");
242    }
243
244    #[test]
245    fn test_format_gas_infinite() {
246        assert_eq!(format_gas("infinite"), "infinite");
247    }
248
249    #[test]
250    fn test_format_gas_unknown() {
251        assert_eq!(format_gas("unknown"), "unknown");
252    }
253
254    #[test]
255    fn test_build_gas_index_empty() {
256        let data = json!({});
257        let index = build_gas_index(&data);
258        assert!(index.is_empty());
259    }
260
261    #[test]
262    fn test_build_gas_index_no_contracts() {
263        let data = json!({ "sources": {}, "contracts": {} });
264        let index = build_gas_index(&data);
265        assert!(index.is_empty());
266    }
267
268    #[test]
269    fn test_build_gas_index_basic() {
270        let data = json!({
271            "contracts": {
272                "src/Foo.sol": {
273                    "Foo": {
274                        "evm": {
275                            "gasEstimates": {
276                                "creation": {
277                                    "codeDepositCost": "200",
278                                    "executionCost": "infinite",
279                                    "totalCost": "infinite"
280                                },
281                                "external": {
282                                    "bar(uint256)": "109"
283                                },
284                                "internal": {
285                                    "_baz(uint256)": "50"
286                                }
287                            },
288                            "methodIdentifiers": {
289                                "bar(uint256)": "abcd1234"
290                            }
291                        }
292                    }
293                }
294            }
295        });
296
297        let index = build_gas_index(&data);
298        assert_eq!(index.len(), 1);
299
300        let foo = index.get("src/Foo.sol:Foo").unwrap();
301
302        // Creation
303        assert_eq!(foo.creation.get("codeDepositCost").unwrap(), "200");
304        assert_eq!(foo.creation.get("executionCost").unwrap(), "infinite");
305
306        // External — by selector
307        assert_eq!(
308            foo.external_by_selector
309                .get(&FuncSelector::new("abcd1234"))
310                .unwrap(),
311            "109"
312        );
313        // External — by signature
314        assert_eq!(
315            foo.external_by_sig
316                .get(&MethodId::new("bar(uint256)"))
317                .unwrap(),
318            "109"
319        );
320
321        // Internal
322        assert_eq!(foo.internal.get("_baz(uint256)").unwrap(), "50");
323    }
324
325    #[test]
326    fn test_gas_by_selector() {
327        let data = json!({
328            "contracts": {
329                "src/Foo.sol": {
330                    "Foo": {
331                        "evm": {
332                            "gasEstimates": {
333                                "external": { "bar(uint256)": "109" }
334                            },
335                            "methodIdentifiers": {
336                                "bar(uint256)": "abcd1234"
337                            }
338                        }
339                    }
340                }
341            }
342        });
343
344        let index = build_gas_index(&data);
345        let (contract, cost) = gas_by_selector(&index, &FuncSelector::new("abcd1234")).unwrap();
346        assert_eq!(contract, "src/Foo.sol:Foo");
347        assert_eq!(cost, "109");
348    }
349
350    #[test]
351    fn test_gas_by_name() {
352        let data = json!({
353            "contracts": {
354                "src/Foo.sol": {
355                    "Foo": {
356                        "evm": {
357                            "gasEstimates": {
358                                "internal": {
359                                    "_baz(uint256)": "50",
360                                    "_baz(uint256,address)": "120"
361                                }
362                            }
363                        }
364                    }
365                }
366            }
367        });
368
369        let index = build_gas_index(&data);
370        let results = gas_by_name(&index, "_baz");
371        assert_eq!(results.len(), 2);
372    }
373
374    #[test]
375    fn test_gas_for_contract() {
376        let data = json!({
377            "contracts": {
378                "src/Foo.sol": {
379                    "Foo": {
380                        "evm": {
381                            "gasEstimates": {
382                                "creation": {
383                                    "codeDepositCost": "6924600"
384                                }
385                            }
386                        }
387                    }
388                }
389            }
390        });
391
392        let index = build_gas_index(&data);
393        let gas = gas_for_contract(&index, "src/Foo.sol", "Foo").unwrap();
394        assert_eq!(gas.creation.get("codeDepositCost").unwrap(), "6924600");
395    }
396
397    #[test]
398    fn test_build_gas_index_from_solc_fixture() {
399        let ast = load_solc_fixture();
400        let index = build_gas_index(&ast);
401
402        // poolmanager.json has gas estimates for PoolManager
403        assert!(!index.is_empty(), "solc fixture should have gas data");
404
405        // Find PoolManager — keys have absolute paths
406        let pm_key = index
407            .keys()
408            .find(|k| k.contains("PoolManager.sol:PoolManager"))
409            .expect("should have PoolManager gas data");
410
411        let pm = index.get(pm_key).unwrap();
412
413        // Creation costs
414        assert!(
415            pm.creation.contains_key("codeDepositCost"),
416            "should have codeDepositCost"
417        );
418        assert!(
419            pm.creation.contains_key("executionCost"),
420            "should have executionCost"
421        );
422        assert!(
423            pm.creation.contains_key("totalCost"),
424            "should have totalCost"
425        );
426
427        // External functions
428        assert!(
429            !pm.external_by_selector.is_empty(),
430            "should have external function gas estimates"
431        );
432
433        // Internal functions
434        assert!(
435            !pm.internal.is_empty(),
436            "should have internal function gas estimates"
437        );
438    }
439
440    #[test]
441    fn test_gas_by_selector_from_solc_fixture() {
442        let ast = load_solc_fixture();
443        let index = build_gas_index(&ast);
444
445        // owner() has selector "8da5cb5b" (well-known)
446        let result = gas_by_selector(&index, &FuncSelector::new("8da5cb5b"));
447        assert!(result.is_some(), "should find owner() by selector");
448        let (contract, cost) = result.unwrap();
449        assert!(
450            contract.contains("PoolManager"),
451            "should be PoolManager contract"
452        );
453        assert!(!cost.is_empty(), "should have a gas cost");
454    }
455
456    #[test]
457    fn test_gas_by_name_from_solc_fixture() {
458        let ast = load_solc_fixture();
459        let index = build_gas_index(&ast);
460
461        // _getPool is an internal function in PoolManager
462        let results = gas_by_name(&index, "_getPool");
463        assert!(!results.is_empty(), "should find _getPool internal gas");
464    }
465
466    #[test]
467    fn test_gas_for_contract_from_solc_fixture() {
468        let ast = load_solc_fixture();
469        let index = build_gas_index(&ast);
470
471        // Find the PoolManager key
472        let pm_key = index
473            .keys()
474            .find(|k| k.contains("PoolManager.sol:PoolManager"))
475            .expect("should have PoolManager");
476
477        // Parse the path and name from "path:Name"
478        let parts: Vec<&str> = pm_key.rsplitn(2, ':').collect();
479        let name = parts[0];
480        let path = parts[1];
481
482        let gas = gas_for_contract(&index, path, name);
483        assert!(gas.is_some(), "should find PoolManager contract gas");
484        assert_eq!(
485            gas.unwrap().creation.get("executionCost").unwrap(),
486            "infinite"
487        );
488    }
489}