Skip to main content

foundry_config/inline/
mod.rs

1use std::collections::BTreeSet;
2
3use crate::Config;
4use alloy_primitives::map::HashMap;
5use figment::{
6    Figment, Profile, Provider,
7    value::{Dict, Map, Value},
8};
9use foundry_compilers::ProjectCompileOutput;
10use foundry_evm_networks::NetworkVariant;
11use itertools::Itertools;
12
13mod natspec;
14pub use natspec::*;
15
16const INLINE_CONFIG_PREFIX: &str = "forge-config:";
17
18type DataMap = Map<Profile, Dict>;
19
20/// Errors returned when parsing inline config.
21#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22pub enum InlineConfigErrorKind {
23    /// Failed to parse inline config as TOML.
24    #[error(transparent)]
25    Parse(#[from] toml::de::Error),
26    /// An invalid profile has been provided.
27    #[error("invalid profile `{0}`; valid profiles: {1}")]
28    InvalidProfile(String, String),
29    /// A legacy Halmos inline annotation could not be translated.
30    #[error("invalid @custom:halmos annotation: {0}")]
31    InvalidHalmosConfig(String),
32}
33
34/// Wrapper error struct that catches config parsing errors, enriching them with context information
35/// reporting the misconfigured line.
36#[derive(Debug, thiserror::Error)]
37#[error("Inline config error at {location}: {kind}")]
38pub struct InlineConfigError {
39    /// The span of the error in the format:
40    /// `dir/TestContract.t.sol:FuzzContract:10:12:111`
41    pub location: String,
42    /// The inner error
43    pub kind: InlineConfigErrorKind,
44}
45
46/// Represents per-test configurations, declared inline
47/// as structured comments in Solidity test files. This allows
48/// to create configs directly bound to a solidity test.
49#[derive(Clone, Debug, Default)]
50pub struct InlineConfig {
51    /// Contract-level configuration.
52    contract_level: HashMap<String, DataMap>,
53    /// Function-level configuration.
54    fn_level: HashMap<(String, String), DataMap>,
55}
56
57impl InlineConfig {
58    /// Creates a new, empty [`InlineConfig`].
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Tries to create a new instance by detecting inline configurations from the project compile
64    /// output.
65    pub fn new_parsed(output: &ProjectCompileOutput, config: &Config) -> eyre::Result<Self> {
66        let natspecs: Vec<NatSpec> = NatSpec::parse(output, &config.root);
67        let profiles = &config.profiles;
68        let mut inline = Self::new();
69        for natspec in &natspecs {
70            inline.insert(natspec)?;
71            // Validate after parsing as TOML.
72            natspec.validate_profiles(profiles)?;
73        }
74        Ok(inline)
75    }
76
77    /// Inserts a new [`NatSpec`] into the [`InlineConfig`].
78    pub fn insert(&mut self, natspec: &NatSpec) -> Result<(), InlineConfigError> {
79        let map = if let Some(function) = &natspec.function {
80            self.fn_level.entry((natspec.contract.clone(), function.clone())).or_default()
81        } else {
82            self.contract_level.entry(natspec.contract.clone()).or_default()
83        };
84        if let Some(data) = parse_config_values(natspec, natspec.halmos_config_values()?)? {
85            extend_data_map(map, &data);
86        }
87        if let Some(data) = parse_config_values(natspec, natspec.config_values())? {
88            extend_data_map(map, &data);
89        }
90        Ok(())
91    }
92
93    /// Returns a [`figment::Provider`] for this [`InlineConfig`] at the given contract and function
94    /// level.
95    pub const fn provide<'a>(
96        &'a self,
97        contract: &'a str,
98        function: &'a str,
99    ) -> InlineConfigProvider<'a> {
100        InlineConfigProvider { inline: self, contract, function }
101    }
102
103    /// Merges the inline configuration at the given contract and function level with the provided
104    /// base configuration.
105    pub fn merge(&self, contract: &str, function: &str, base: &Config) -> Figment {
106        Figment::from(base).merge(self.provide(contract, function))
107    }
108
109    /// Returns `true` if a configuration is present at the given contract level.
110    pub fn contains_contract(&self, contract: &str) -> bool {
111        self.get_contract(contract).is_some_and(|map| !map.is_empty())
112    }
113
114    /// Returns `true` if a configuration is present at the function level.
115    ///
116    /// Does not include contract-level configurations.
117    pub fn contains_function(&self, contract: &str, function: &str) -> bool {
118        self.get_function(contract, function).is_some_and(|map| !map.is_empty())
119    }
120
121    /// Returns the configured [`NetworkVariant`] for a given test, checking function-level first
122    /// then contract-level. Returns `None` if no network annotation is present.
123    pub fn network_for(
124        &self,
125        profile: &Profile,
126        contract: &str,
127        function: &str,
128    ) -> Option<NetworkVariant> {
129        inline_value_for_profile(
130            profile,
131            &[self.get_function(contract, function), self.get_contract(contract)],
132            |dict| {
133                if let Some(Value::Dict(_, networks)) = dict.get("networks")
134                    && let Some(Value::String(_, s)) = networks.get("network")
135                {
136                    return s.parse().ok();
137                }
138                None
139            },
140        )
141    }
142
143    /// Returns whether contract-level inline config enables symbolic execution.
144    pub fn contract_symbolic_enabled(
145        &self,
146        profile: &Profile,
147        contract: &str,
148        default: bool,
149    ) -> bool {
150        inline_value_for_profile(profile, &[self.get_contract(contract)], |dict| {
151            if let Some(Value::Dict(_, symbolic)) = dict.get("symbolic")
152                && let Some(Value::Bool(_, enabled)) = symbolic.get("enabled")
153            {
154                return Some(*enabled);
155            }
156            None
157        })
158        .unwrap_or(default)
159    }
160
161    /// Returns all distinct [`NetworkVariant`]s referenced in any inline config annotation.
162    ///
163    /// This is used to determine whether a multi-network test pass is needed.
164    pub fn referenced_override_networks(&self, profile: &Profile) -> Vec<NetworkVariant> {
165        let mut seen = BTreeSet::new();
166        for (contract, function) in self.fn_level.keys() {
167            if let Some(v) = self.network_for(profile, contract, function) {
168                seen.insert(v);
169            }
170        }
171        for contract in self.contract_level.keys() {
172            if let Some(v) = self.network_for(profile, contract, "") {
173                seen.insert(v);
174            }
175        }
176        seen.into_iter().collect()
177    }
178
179    fn get_contract(&self, contract: &str) -> Option<&DataMap> {
180        self.contract_level.get(contract)
181    }
182
183    fn get_function(&self, contract: &str, function: &str) -> Option<&DataMap> {
184        let key = (contract.to_string(), function.to_string());
185        self.fn_level.get(&key)
186    }
187}
188
189fn inline_value_for_profile<T>(
190    profile: &Profile,
191    levels: &[Option<&DataMap>],
192    value_from_dict: impl Fn(&Dict) -> Option<T> + Copy,
193) -> Option<T> {
194    inline_value_for_exact_profile(profile, levels, value_from_dict).or_else(|| {
195        (profile != Profile::Default)
196            .then(|| inline_value_for_exact_profile(&Profile::Default, levels, value_from_dict))
197            .flatten()
198    })
199}
200
201fn inline_value_for_exact_profile<T>(
202    profile: &Profile,
203    levels: &[Option<&DataMap>],
204    value_from_dict: impl Fn(&Dict) -> Option<T> + Copy,
205) -> Option<T> {
206    levels.iter().find_map(|data| data.and_then(|data| data.get(profile)).and_then(value_from_dict))
207}
208
209fn parse_config_values<'a>(
210    natspec: &NatSpec,
211    values: impl IntoIterator<Item = impl std::borrow::Borrow<str> + 'a>,
212) -> Result<Option<DataMap>, InlineConfigError> {
213    let joined = values
214        .into_iter()
215        .map(|s| {
216            let s = s.borrow();
217            // Replace `-` with `_` for backwards compatibility with the old parser.
218            if let Some(idx) = s.find('=') {
219                s[..idx].replace('-', "_") + &s[idx..]
220            } else {
221                s.to_string()
222            }
223        })
224        .format("\n")
225        .to_string();
226    if joined.is_empty() {
227        return Ok(None);
228    }
229    let data = toml::from_str::<DataMap>(&joined).map_err(|e| InlineConfigError {
230        location: natspec.location_string(),
231        kind: InlineConfigErrorKind::Parse(e),
232    })?;
233    Ok(Some(data))
234}
235
236/// [`figment::Provider`] for [`InlineConfig`] at a given contract and function level.
237///
238/// Created by [`InlineConfig::provide`].
239#[derive(Clone, Debug)]
240pub struct InlineConfigProvider<'a> {
241    inline: &'a InlineConfig,
242    contract: &'a str,
243    function: &'a str,
244}
245
246impl Provider for InlineConfigProvider<'_> {
247    fn metadata(&self) -> figment::Metadata {
248        figment::Metadata::named("inline config")
249    }
250
251    fn data(&self) -> figment::Result<DataMap> {
252        let mut map = DataMap::new();
253        if let Some(new) = self.inline.get_contract(self.contract) {
254            extend_data_map(&mut map, new);
255        }
256        if let Some(new) = self.inline.get_function(self.contract, self.function) {
257            extend_data_map(&mut map, new);
258        }
259        Ok(map)
260    }
261}
262
263fn extend_data_map(map: &mut DataMap, new: &DataMap) {
264    for (profile, data) in new {
265        extend_dict(map.entry(profile.clone()).or_default(), data);
266    }
267}
268
269fn extend_dict(dict: &mut Dict, new: &Dict) {
270    for (k, v) in new {
271        match dict.entry(k.clone()) {
272            std::collections::btree_map::Entry::Vacant(entry) => {
273                entry.insert(v.clone());
274            }
275            std::collections::btree_map::Entry::Occupied(entry) => {
276                extend_value(entry.into_mut(), v);
277            }
278        }
279    }
280}
281
282fn extend_value(value: &mut Value, new: &Value) {
283    match (value, new) {
284        (Value::Dict(tag, dict), Value::Dict(new_tag, new_dict)) => {
285            *tag = *new_tag;
286            extend_dict(dict, new_dict);
287        }
288        (value, new) => *value = new.clone(),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn natspec(docs: &str) -> NatSpec {
297        NatSpec {
298            contract: "test/Symbolic.t.sol:Symbolic".to_string(),
299            function: Some("check".to_string()),
300            line: "10:5".to_string(),
301            docs: docs.to_string(),
302        }
303    }
304
305    #[test]
306    fn legacy_halmos_array_lengths_feed_symbolic_inline_config() {
307        let mut inline = InlineConfig::new();
308        inline
309            .insert(&natspec(
310                "@custom:halmos --array-lengths 2,4 --invariant-depth 12 --width 8 --depth 99",
311            ))
312            .unwrap();
313
314        let config = Config::default()
315            .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check"))
316            .unwrap();
317
318        assert_eq!(config.symbolic.array_lengths, vec![2, 4]);
319        assert_eq!(config.symbolic.invariant_depth, 12);
320        assert_eq!(config.symbolic.width, Some(8));
321        assert_eq!(config.symbolic.depth, Some(99));
322    }
323
324    #[test]
325    fn legacy_halmos_named_and_default_lengths_feed_symbolic_inline_config() {
326        let mut inline = InlineConfig::new();
327        inline
328            .insert(&natspec(
329                "@custom:halmos --array-lengths values={2,4},data=8 --default-array-lengths 0,1 --default-bytes-lengths 0,65",
330            ))
331            .unwrap();
332
333        let config = Config::default()
334            .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check"))
335            .unwrap();
336
337        assert_eq!(
338            config.symbolic.dynamic_lengths,
339            std::collections::BTreeMap::from([
340                ("data".to_string(), vec![8]),
341                ("values".to_string(), vec![2, 4]),
342            ])
343        );
344        assert_eq!(config.symbolic.default_array_lengths, vec![0, 1]);
345        assert_eq!(config.symbolic.default_bytes_lengths, vec![0, 65]);
346    }
347
348    #[test]
349    fn native_symbolic_inline_config_overrides_legacy_halmos_translation() {
350        let mut inline = InlineConfig::new();
351        inline
352            .insert(&natspec(
353                r#"
354@custom:halmos --array-lengths 2
355forge-config: default.symbolic.array_lengths = [3]
356forge-config: default.symbolic.default_dynamic_length = 4
357"#,
358            ))
359            .unwrap();
360
361        let config = Config::default()
362            .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check"))
363            .unwrap();
364
365        assert_eq!(config.symbolic.array_lengths, vec![3]);
366        assert_eq!(config.symbolic.default_dynamic_length, 4);
367    }
368
369    #[test]
370    fn merge_inline_provider_uses_selected_profile() {
371        let mut inline = InlineConfig::new();
372        inline
373            .insert(&natspec(
374                r#"
375forge-config: default.fuzz.runs = 1
376forge-config: ci.fuzz.runs = 2
377"#,
378            ))
379            .unwrap();
380
381        let profile = Profile::new("ci");
382        let config = Config {
383            profile: profile.clone(),
384            profiles: vec![Profile::Default, profile],
385            ..Default::default()
386        }
387        .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check"))
388        .unwrap();
389
390        assert_eq!(config.fuzz.runs, 2);
391    }
392
393    #[test]
394    fn merge_inline_provider_preserves_root_for_default_profile_fallback() {
395        let mut inline = InlineConfig::new();
396        inline.insert(&natspec("forge-config: default.isolate = false")).unwrap();
397
398        let profile = Profile::new("ci");
399        let root = std::path::PathBuf::from("project-root");
400        let config = Config {
401            profile: profile.clone(),
402            profiles: vec![Profile::Default, profile],
403            root: root.clone(),
404            ..Default::default()
405        }
406        .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check"))
407        .unwrap();
408
409        assert_eq!(config.root, root);
410        assert!(!config.isolate);
411    }
412
413    #[test]
414    fn contract_symbolic_enabled_reads_contract_inline_config() {
415        let mut inline = InlineConfig::new();
416        inline
417            .insert(&NatSpec {
418                contract: "test/Symbolic.t.sol:Symbolic".to_string(),
419                function: None,
420                line: "10:5".to_string(),
421                docs: r#"
422forge-config: default.symbolic.enabled = true
423forge-config: ci.symbolic.enabled = false
424"#
425                .to_string(),
426            })
427            .unwrap();
428
429        assert!(inline.contract_symbolic_enabled(
430            &Profile::new("default"),
431            "test/Symbolic.t.sol:Symbolic",
432            false,
433        ));
434        assert!(!inline.contract_symbolic_enabled(
435            &Profile::new("ci"),
436            "test/Symbolic.t.sol:Symbolic",
437            true,
438        ));
439        assert!(inline.contract_symbolic_enabled(
440            &Profile::new("nightly"),
441            "test/Symbolic.t.sol:Symbolic",
442            false,
443        ));
444        assert!(!inline.contract_symbolic_enabled(
445            &Profile::new("default"),
446            "test/Other.t.sol:Other",
447            false,
448        ));
449    }
450
451    #[test]
452    fn network_for_preserves_profile_then_level_precedence() {
453        let mut inline = InlineConfig::new();
454        inline
455            .insert(&NatSpec {
456                contract: "test/Network.t.sol:Network".to_string(),
457                function: None,
458                line: "10:5".to_string(),
459                docs: r#"
460forge-config: default.networks.network = "optimism"
461forge-config: ci.networks.network = "tempo"
462"#
463                .to_string(),
464            })
465            .unwrap();
466        inline
467            .insert(&NatSpec {
468                contract: "test/Network.t.sol:Network".to_string(),
469                function: Some("testNetwork".to_string()),
470                line: "20:5".to_string(),
471                docs: r#"forge-config: default.networks.network = "ethereum""#.to_string(),
472            })
473            .unwrap();
474
475        assert_eq!(
476            inline.network_for(
477                &Profile::new("default"),
478                "test/Network.t.sol:Network",
479                "testNetwork"
480            ),
481            Some(NetworkVariant::Ethereum),
482        );
483        assert_eq!(
484            inline.network_for(&Profile::new("ci"), "test/Network.t.sol:Network", "testNetwork"),
485            Some(NetworkVariant::Tempo),
486        );
487    }
488}