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