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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22pub enum InlineConfigErrorKind {
23 #[error(transparent)]
25 Parse(#[from] toml::de::Error),
26 #[error("invalid profile `{0}`; valid profiles: {1}")]
28 InvalidProfile(String, String),
29 #[error("invalid @custom:halmos annotation: {0}")]
31 InvalidHalmosConfig(String),
32}
33
34#[derive(Debug, thiserror::Error)]
37#[error("Inline config error at {location}: {kind}")]
38pub struct InlineConfigError {
39 pub location: String,
42 pub kind: InlineConfigErrorKind,
44}
45
46#[derive(Clone, Debug, Default)]
50pub struct InlineConfig {
51 contract_level: HashMap<String, DataMap>,
53 fn_level: HashMap<(String, String), DataMap>,
55}
56
57impl InlineConfig {
58 pub fn new() -> Self {
60 Self::default()
61 }
62
63 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 natspec.validate_profiles(profiles)?;
73 }
74 Ok(inline)
75 }
76
77 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 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 pub fn merge(&self, contract: &str, function: &str, base: &Config) -> Figment {
106 Figment::from(base).merge(self.provide(contract, function))
107 }
108
109 pub fn contains_contract(&self, contract: &str) -> bool {
111 self.get_contract(contract).is_some_and(|map| !map.is_empty())
112 }
113
114 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 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 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 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 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#[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}