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