1use super::{INLINE_CONFIG_PREFIX, InlineConfigError, InlineConfigErrorKind};
2use figment::Profile;
3use foundry_compilers::{
4 ProjectCompileOutput,
5 artifacts::{Node, ast::NodeType},
6};
7use itertools::Itertools;
8use serde_json::Value;
9use solar::{
10 ast::{self, Span},
11 interface::Session,
12};
13use std::{collections::BTreeMap, path::Path};
14
15const HALMOS_CONFIG_PREFIX: &str = "@custom:halmos";
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct NatSpec {
20 pub contract: String,
22 pub function: Option<String>,
24 pub line: String,
26 pub docs: String,
28}
29
30impl NatSpec {
31 #[instrument(name = "NatSpec::parse", skip_all)]
35 pub fn parse(output: &ProjectCompileOutput, root: &Path) -> Vec<Self> {
36 let mut natspecs: Vec<Self> = vec![];
37
38 let compiler = output.parser().solc().compiler();
39 let solar = SolarParser::new(compiler.sess());
40 let solc = SolcParser::new();
41 for (id, artifact) in output.artifact_ids() {
42 let path = id.source.as_path();
43 let path = path.strip_prefix(root).unwrap_or(path);
44 let abs_path = &*root.join(path);
45 let contract_name = id.name.split('.').next().unwrap();
46 let contract = format!("{}:{}", path.display(), id.name);
48
49 let mut used_solar = false;
50 compiler.enter_sequential(|compiler| {
51 if let Some((_, source)) = compiler.gcx().get_ast_source(abs_path)
52 && let Some(ast) = &source.ast
53 {
54 solar.parse_ast(&mut natspecs, ast, &contract, contract_name);
55 used_solar = true;
56 }
57 });
58
59 if !used_solar {
60 warn!(?abs_path, %contract, "could not parse natspec with solar");
61 }
62
63 let used_solc = if !used_solar
64 && let Some(ast) = &artifact.ast
65 && let Some(node) = solc.contract_root_node(&ast.nodes, &contract)
66 {
67 solc.parse(&mut natspecs, &contract, node, true);
68 true
69 } else {
70 false
71 };
72
73 if !used_solar && !used_solc {
74 warn!(?abs_path, %contract, "could not parse natspec");
75 }
76 }
77
78 natspecs
79 }
80
81 pub fn validate_profiles(&self, profiles: &[Profile]) -> eyre::Result<()> {
90 for config in self.config_values() {
91 if !profiles.iter().any(|p| {
92 config
93 .strip_prefix(p.as_str().as_str())
94 .is_some_and(|rest| rest.trim_start().starts_with('.'))
95 }) {
96 Err(InlineConfigError {
97 location: self.location_string(),
98 kind: InlineConfigErrorKind::InvalidProfile(
99 config.to_string(),
100 profiles.iter().format(", ").to_string(),
101 ),
102 })?
103 }
104 }
105 Ok(())
106 }
107
108 pub fn path(&self) -> &str {
110 match self.contract.split_once(':') {
111 Some((path, _)) => path,
112 None => self.contract.as_str(),
113 }
114 }
115
116 pub fn location_string(&self) -> String {
118 format!("{}:{}", self.path(), self.line)
119 }
120
121 pub fn config_values(&self) -> impl Iterator<Item = &str> {
123 self.docs.lines().filter_map(|line| {
124 line.find(INLINE_CONFIG_PREFIX)
125 .map(|idx| line[idx + INLINE_CONFIG_PREFIX.len()..].trim())
126 })
127 }
128
129 pub fn halmos_config_values(&self) -> Result<Vec<String>, InlineConfigError> {
134 let mut values = Vec::new();
135 for line in self.docs.lines() {
136 let Some(idx) = line.find(HALMOS_CONFIG_PREFIX) else { continue };
137 let args = line[idx + HALMOS_CONFIG_PREFIX.len()..].trim();
138 translate_halmos_config(args, &mut values).map_err(|message| InlineConfigError {
139 location: self.location_string(),
140 kind: InlineConfigErrorKind::InvalidHalmosConfig(message),
141 })?;
142 }
143 Ok(values)
144 }
145}
146
147fn translate_halmos_config(args: &str, values: &mut Vec<String>) -> Result<(), String> {
148 let tokens = split_halmos_args(args)?;
149 let mut idx = 0;
150 while idx < tokens.len() {
151 let token = &tokens[idx];
152 if let Some((arg, value)) = halmos_arg(token) {
153 let value = if let Some(value) = value {
154 value
155 } else {
156 idx += 1;
157 tokens.get(idx).ok_or_else(|| format!("missing value for {}", arg.flag))?
158 };
159 emit_halmos_arg(values, arg, value)?;
160 } else if token.starts_with("--")
161 && tokens.get(idx + 1).is_some_and(|next| !next.starts_with("--"))
162 {
163 idx += 1;
164 }
165 idx += 1;
166 }
167 Ok(())
168}
169
170fn split_halmos_args(args: &str) -> Result<Vec<String>, String> {
171 shlex::split(args).ok_or_else(|| "invalid shell quoting in @custom:halmos config".to_string())
172}
173
174#[derive(Clone, Copy)]
175struct HalmosArg {
176 flag: &'static str,
177 parser: HalmosArgParser,
178 foundry_field: &'static str,
179}
180
181#[derive(Clone, Copy)]
182enum HalmosArgParser {
183 ArrayLengths,
184 Lengths,
185 U32,
186 String,
187}
188
189const HALMOS_ARGS: &[HalmosArg] = &[
190 HalmosArg {
191 flag: "--array-lengths",
192 parser: HalmosArgParser::ArrayLengths,
193 foundry_field: "array_lengths",
194 },
195 HalmosArg {
196 flag: "--default-array-lengths",
197 parser: HalmosArgParser::Lengths,
198 foundry_field: "default_array_lengths",
199 },
200 HalmosArg {
201 flag: "--default-bytes-lengths",
202 parser: HalmosArgParser::Lengths,
203 foundry_field: "default_bytes_lengths",
204 },
205 HalmosArg { flag: "--loop", parser: HalmosArgParser::U32, foundry_field: "loop" },
206 HalmosArg {
207 flag: "--invariant-depth",
208 parser: HalmosArgParser::U32,
209 foundry_field: "invariant_depth",
210 },
211 HalmosArg { flag: "--width", parser: HalmosArgParser::U32, foundry_field: "width" },
212 HalmosArg { flag: "--depth", parser: HalmosArgParser::U32, foundry_field: "depth" },
213 HalmosArg { flag: "--solver-timeout", parser: HalmosArgParser::U32, foundry_field: "timeout" },
214 HalmosArg {
215 flag: "--solver-timeout-branching",
216 parser: HalmosArgParser::U32,
217 foundry_field: "timeout",
218 },
219 HalmosArg {
220 flag: "--solver-timeout-assertion",
221 parser: HalmosArgParser::U32,
222 foundry_field: "timeout",
223 },
224 HalmosArg { flag: "--solver", parser: HalmosArgParser::String, foundry_field: "solver" },
225 HalmosArg {
226 flag: "--solver-command",
227 parser: HalmosArgParser::String,
228 foundry_field: "solver_command",
229 },
230];
231
232fn halmos_arg(token: &str) -> Option<(HalmosArg, Option<&str>)> {
233 let (flag, value) =
234 token.split_once('=').map_or((token, None), |(flag, value)| (flag, Some(value)));
235 HALMOS_ARGS.iter().copied().find(|arg| arg.flag == flag).map(|arg| (arg, value))
236}
237
238fn emit_halmos_arg(values: &mut Vec<String>, arg: HalmosArg, value: &str) -> Result<(), String> {
239 match arg.parser {
240 HalmosArgParser::ArrayLengths => push_halmos_array_lengths(values, value),
241 HalmosArgParser::Lengths => push_halmos_lengths(values, arg.foundry_field, value, arg.flag),
242 HalmosArgParser::U32 => {
243 let value = value
244 .parse::<u32>()
245 .map_err(|_| format!("invalid value `{value}` for {}", arg.flag))?;
246 values.push(format!("default.symbolic.{} = {value}", arg.foundry_field));
247 Ok(())
248 }
249 HalmosArgParser::String => {
250 values.push(format!("default.symbolic.{} = {}", arg.foundry_field, toml_string(value)));
251 Ok(())
252 }
253 }
254}
255
256fn push_halmos_array_lengths(values: &mut Vec<String>, value: &str) -> Result<(), String> {
257 match parse_halmos_array_lengths(value)? {
258 HalmosArrayLengths::Positional(lengths) => {
259 values.push(format!(
260 "default.symbolic.array_lengths = [{}]",
261 lengths.iter().format(", ")
262 ));
263 }
264 HalmosArrayLengths::Named(lengths) => {
265 values.push(format!(
266 "default.symbolic.dynamic_lengths = {{ {} }}",
267 lengths
268 .iter()
269 .map(|(name, lengths)| format!("{name} = [{}]", lengths.iter().format(", ")))
270 .format(", ")
271 ));
272 }
273 }
274 Ok(())
275}
276
277fn push_halmos_lengths(
278 values: &mut Vec<String>,
279 field: &str,
280 value: &str,
281 flag: &str,
282) -> Result<(), String> {
283 values.push(format!(
284 "default.symbolic.{field} = [{}]",
285 parse_halmos_lengths(value, flag)?.iter().format(", ")
286 ));
287 Ok(())
288}
289
290fn toml_string(value: &str) -> String {
291 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
292}
293
294enum HalmosArrayLengths {
295 Positional(Vec<u32>),
296 Named(BTreeMap<String, Vec<u32>>),
297}
298
299fn parse_halmos_array_lengths(value: &str) -> Result<HalmosArrayLengths, String> {
300 if value.contains('=') {
301 let mut named = BTreeMap::new();
302 for entry in split_halmos_lengths_entries(value)? {
303 let Some((name, lengths)) = entry.split_once('=') else {
304 return Err(format!(
305 "mixed named and positional lengths in --array-lengths `{value}`"
306 ));
307 };
308 let name = name.trim();
309 if name.is_empty() {
310 return Err(format!("missing name in --array-lengths `{value}`"));
311 }
312 named.insert(
313 name.to_string(),
314 parse_halmos_length_set(lengths.trim(), "--array-lengths")?,
315 );
316 }
317 if named.is_empty() {
318 return Err("missing value for --array-lengths".to_string());
319 }
320 Ok(HalmosArrayLengths::Named(named))
321 } else {
322 Ok(HalmosArrayLengths::Positional(parse_halmos_lengths(value, "--array-lengths")?))
323 }
324}
325
326fn parse_halmos_lengths(value: &str, flag: &str) -> Result<Vec<u32>, String> {
327 parse_halmos_length_set(value, flag)
328}
329
330fn parse_halmos_length_set(value: &str, flag: &str) -> Result<Vec<u32>, String> {
331 let value = value.trim();
332 let value = value.strip_prefix('{').and_then(|value| value.strip_suffix('}')).unwrap_or(value);
333 let mut lengths = Vec::new();
334 for length in value.split(',') {
335 let length = length.trim();
336 if length.is_empty() {
337 return Err(format!("invalid empty length in {flag} `{value}`"));
338 }
339 let length = length
340 .parse::<u32>()
341 .map_err(|_| format!("invalid length `{length}` in {flag} `{value}`"))?;
342 lengths.push(length);
343 }
344 if lengths.is_empty() {
345 return Err(format!("missing value for {flag}"));
346 }
347 Ok(lengths)
348}
349
350fn split_halmos_lengths_entries(value: &str) -> Result<Vec<&str>, String> {
351 let mut entries = Vec::new();
352 let mut start = 0usize;
353 let mut brace_depth = 0u8;
354 for (idx, ch) in value.char_indices() {
355 match ch {
356 '{' => brace_depth = brace_depth.saturating_add(1),
357 '}' => {
358 brace_depth = brace_depth
359 .checked_sub(1)
360 .ok_or_else(|| format!("unmatched `}}` in --array-lengths `{value}`"))?;
361 }
362 ',' if brace_depth == 0 => {
363 entries.push(value[start..idx].trim());
364 start = idx + 1;
365 }
366 _ => {}
367 }
368 }
369 if brace_depth != 0 {
370 return Err(format!("unmatched `{{` in --array-lengths `{value}`"));
371 }
372 entries.push(value[start..].trim());
373 Ok(entries)
374}
375
376struct SolcParser {
377 _private: (),
378}
379
380impl SolcParser {
381 const fn new() -> Self {
382 Self { _private: () }
383 }
384
385 fn contract_root_node<'a>(&self, nodes: &'a [Node], contract_id: &str) -> Option<&'a Node> {
388 for n in nodes {
389 if n.node_type == NodeType::ContractDefinition {
390 let contract_data = &n.other;
391 if let Value::String(contract_name) = contract_data.get("name")?
392 && contract_id.ends_with(contract_name)
393 {
394 return Some(n);
395 }
396 }
397 }
398 None
399 }
400
401 fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
404 if root && let Some((docs, line)) = self.get_node_docs(&node.other) {
406 natspecs.push(NatSpec { contract: contract.into(), function: None, docs, line })
407 }
408 for n in &node.nodes {
409 if let Some((function, docs, line)) = self.get_fn_data(n) {
410 natspecs.push(NatSpec {
411 contract: contract.into(),
412 function: Some(function),
413 line,
414 docs,
415 })
416 }
417 self.parse(natspecs, contract, n, false);
418 }
419 }
420
421 fn get_fn_data(&self, node: &Node) -> Option<(String, String, String)> {
429 if node.node_type == NodeType::FunctionDefinition {
430 let fn_data = &node.other;
431 let fn_name: String = self.get_fn_name(fn_data)?;
432 let (fn_docs, docs_src_line) = self.get_node_docs(fn_data)?;
433 return Some((fn_name, fn_docs, docs_src_line));
434 }
435
436 None
437 }
438
439 fn get_fn_name(&self, fn_data: &BTreeMap<String, Value>) -> Option<String> {
441 match fn_data.get("name")? {
442 Value::String(fn_name) => Some(fn_name.into()),
443 _ => None,
444 }
445 }
446
447 fn get_node_docs(&self, data: &BTreeMap<String, Value>) -> Option<(String, String)> {
453 if let Value::Object(fn_docs) = data.get("documentation")?
454 && let Value::String(comment) = fn_docs.get("text")?
455 && contains_inline_config(comment)
456 {
457 let mut src_line = fn_docs
458 .get("src")
459 .map(|src| src.to_string())
460 .unwrap_or_else(|| String::from("<no-src-line-available>"));
461
462 src_line.retain(|c| c != '"');
463 return Some((comment.into(), src_line));
464 }
465 None
466 }
467}
468
469struct SolarParser<'a> {
470 sess: &'a Session,
471}
472
473impl<'a> SolarParser<'a> {
474 const fn new(sess: &'a Session) -> Self {
475 Self { sess }
476 }
477
478 fn parse_ast(
479 &self,
480 natspecs: &mut Vec<NatSpec>,
481 source_unit: &ast::SourceUnit<'_>,
482 contract_id: &str,
483 contract_name: &str,
484 ) {
485 let mut handle_docs = |item: &ast::Item<'_>| {
486 if item.docs.is_empty() {
487 return;
488 }
489 let mut span = Span::DUMMY;
490 let lines = item
491 .docs
492 .iter()
493 .filter_map(|d| {
494 let s = d.symbol.as_str();
495 if !contains_inline_config(s) {
496 return None;
497 }
498 span = if span.is_dummy() { d.span } else { span.to(d.span) };
499 match d.kind {
500 ast::CommentKind::Line => Some(s.trim().to_string()),
501 ast::CommentKind::Block => Some(
502 s.lines()
503 .filter(|line| contains_inline_config(line))
504 .map(|line| line.trim_start().trim_start_matches('*').trim())
505 .collect::<Vec<_>>()
506 .join("\n"),
507 ),
508 }
509 })
510 .join("\n");
511 if lines.is_empty() {
512 return;
513 }
514 natspecs.push(NatSpec {
515 contract: contract_id.to_string(),
516 function: if let ast::ItemKind::Function(f) = &item.kind {
517 Some(
518 f.header
519 .name
520 .map(|sym| sym.to_string())
521 .unwrap_or_else(|| f.kind.to_string()),
522 )
523 } else {
524 None
525 },
526 line: {
527 let (_, loc) = self.sess.source_map().span_to_location_info(span);
528 format!("{}:{}", loc.lo.line, loc.lo.col.0 + 1)
529 },
530 docs: lines,
531 });
532 };
533
534 for item in source_unit.items.iter() {
535 let ast::ItemKind::Contract(c) = &item.kind else { continue };
536 if c.name.as_str() != contract_name {
537 continue;
538 }
539
540 handle_docs(item);
542
543 for item in c.body.iter() {
545 let ast::ItemKind::Function(_) = &item.kind else { continue };
546 handle_docs(item);
547 }
548 }
549 }
550}
551
552fn contains_inline_config(s: &str) -> bool {
553 s.contains(INLINE_CONFIG_PREFIX) || s.contains(HALMOS_CONFIG_PREFIX)
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use serde_json::json;
560 use snapbox::{assert_data_eq, str};
561 use solar::parse::{
562 Parser,
563 ast::{Arena, interface},
564 };
565
566 fn parse(natspecs: &mut Vec<NatSpec>, src: &str, contract_id: &str, contract_name: &str) {
567 if !contains_inline_config(src) {
569 return;
570 }
571
572 let sess = Session::builder()
573 .with_silent_emitter(Some("Inline config parsing failed".to_string()))
574 .build();
575 let solar = SolarParser::new(&sess);
576 let _ = sess.enter(|| -> interface::Result<()> {
577 let arena = Arena::new();
578
579 let mut parser = Parser::from_source_code(
580 &sess,
581 &arena,
582 interface::source_map::FileName::Custom(contract_id.to_string()),
583 src.to_string(),
584 )?;
585
586 let source_unit = parser.parse_file().map_err(|e| e.emit())?;
587
588 solar.parse_ast(natspecs, &source_unit, contract_id, contract_name);
589
590 Ok(())
591 });
592 }
593
594 #[test]
595 fn can_reject_invalid_profiles() {
596 let profiles = ["ci".into(), "default".into()];
597 let natspec = NatSpec {
598 contract: Default::default(),
599 function: Default::default(),
600 line: Default::default(),
601 docs: r"
602 forge-config: ciii.invariant.depth = 1
603 forge-config: default.invariant.depth = 1
604 "
605 .into(),
606 };
607
608 let result = natspec.validate_profiles(&profiles);
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn can_accept_valid_profiles() {
614 let profiles = ["ci".into(), "default".into()];
615 let natspec = NatSpec {
616 contract: Default::default(),
617 function: Default::default(),
618 line: Default::default(),
619 docs: r"
620 forge-config: ci.invariant.depth = 1
621 forge-config: default.invariant.depth = 1
622 "
623 .into(),
624 };
625
626 let result = natspec.validate_profiles(&profiles);
627 assert!(result.is_ok());
628 }
629
630 #[test]
631 fn parse_solar() {
632 let src = "
633contract C { /// forge-config: default.fuzz.runs = 600
634
635\t\t\t\t /// forge-config: default.fuzz.runs = 601
636
637 function f1() {}
638 /** forge-config: default.fuzz.runs = 700 */
639function f2() {} /** forge-config: default.fuzz.runs = 800 */ function f3() {}
640
641/**
642 * forge-config: default.fuzz.runs = 1024
643 * forge-config: default.fuzz.max-test-rejects = 500
644 */
645 function f4() {}
646}
647";
648 let mut natspecs = vec![];
649 parse(&mut natspecs, src, "path.sol:C", "C");
650 assert_data_eq!(
651 format!("{natspecs:#?}"),
652 str![[r#"
653[
654 NatSpec {
655 contract: "path.sol:C",
656 function: Some(
657 "f1",
658 ),
659 line: "2:14",
660 docs: "forge-config: default.fuzz.runs = 600/nforge-config: default.fuzz.runs = 601",
661 },
662 NatSpec {
663 contract: "path.sol:C",
664 function: Some(
665 "f2",
666 ),
667 line: "7:8",
668 docs: "forge-config: default.fuzz.runs = 700",
669 },
670 NatSpec {
671 contract: "path.sol:C",
672 function: Some(
673 "f3",
674 ),
675 line: "8:18",
676 docs: "forge-config: default.fuzz.runs = 800",
677 },
678 NatSpec {
679 contract: "path.sol:C",
680 function: Some(
681 "f4",
682 ),
683 line: "10:1",
684 docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500",
685 },
686]
687"#]]
688 );
689 }
690
691 #[test]
692 fn parse_solar_2() {
693 let src = r#"
694// SPDX-License-Identifier: MIT OR Apache-2.0
695pragma solidity >=0.8.0;
696
697import "ds-test/test.sol";
698
699contract FuzzInlineConf is DSTest {
700 /**
701 * forge-config: default.fuzz.runs = 1024
702 * forge-config: default.fuzz.max-test-rejects = 500
703 */
704 function testInlineConfFuzz(uint8 x) public {
705 require(true, "this is not going to revert");
706 }
707}
708 "#;
709 let mut natspecs = vec![];
710 parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
711 assert_data_eq!(
712 format!("{natspecs:#?}"),
713 str![[r#"
714[
715 NatSpec {
716 contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
717 function: Some(
718 "testInlineConfFuzz",
719 ),
720 line: "8:5",
721 docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500",
722 },
723]
724"#]]
725 );
726 }
727
728 #[test]
729 fn config_lines() {
730 let natspec = natspec();
731 let config_lines = natspec.config_values();
732 assert_eq!(
733 config_lines.collect::<Vec<_>>(),
734 [
735 "default.fuzz.runs = 600".to_string(),
736 "ci.fuzz.runs = 500".to_string(),
737 "default.invariant.runs = 1".to_string()
738 ]
739 )
740 }
741
742 #[test]
743 fn can_handle_unavailable_src_line_with_fallback() {
744 let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
745 let doc_without_src_field = json!({ "text": "forge-config:default.fuzz.runs=600" });
746 fn_data.insert("documentation".into(), doc_without_src_field);
747 let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
748 assert_eq!(src_line, "<no-src-line-available>".to_string());
749 }
750
751 #[test]
752 fn can_handle_available_src_line() {
753 let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
754 let doc_without_src_field =
755 json!({ "text": "forge-config:default.fuzz.runs=600", "src": "73:21:12" });
756 fn_data.insert("documentation".into(), doc_without_src_field);
757 let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
758 assert_eq!(src_line, "73:21:12".to_string());
759 }
760
761 fn natspec() -> NatSpec {
762 let conf = r"
763 forge-config: default.fuzz.runs = 600
764 forge-config: ci.fuzz.runs = 500
765 ========= SOME NOISY TEXT =============
766 䩹𧀫Jx닧Ʀ̳盅K擷Ɂw첊}ꏻk86ᖪk-檻ܴ렝[Dz𐤬oᘓƤ
767 ꣖ۻ%Ƅ㪕ς:(饁av/烲ڻ̛߉橞㗡𥺃̹M봓䀖ؿ̄)d
768 ϊ&»ϿЏ2鞷砕eߥHJ粊머?槿ᴴጅϖ뀓Ӽ츙4
769 醤㭊r ܖ̹灱녗V*竅⒪苏贗=숽ؓбݧʹ園Ьi
770 =======================================
771 forge-config: default.invariant.runs = 1
772 ";
773
774 NatSpec {
775 contract: "dir/TestContract.t.sol:FuzzContract".to_string(),
776 function: Some("test_myFunction".to_string()),
777 line: "10:12:111".to_string(),
778 docs: conf.to_string(),
779 }
780 }
781
782 #[test]
783 fn parse_solar_multiple_contracts_from_same_file() {
784 let src = r#"
785// SPDX-License-Identifier: MIT OR Apache-2.0
786pragma solidity >=0.8.0;
787
788import "ds-test/test.sol";
789
790contract FuzzInlineConf is DSTest {
791 /// forge-config: default.fuzz.runs = 1
792 function testInlineConfFuzz1() {}
793}
794
795contract FuzzInlineConf2 is DSTest {
796 /// forge-config: default.fuzz.runs = 2
797 function testInlineConfFuzz2() {}
798}
799 "#;
800 let mut natspecs = vec![];
801 parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
802 assert_data_eq!(
803 format!("{natspecs:#?}"),
804 str![[r#"
805[
806 NatSpec {
807 contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
808 function: Some(
809 "testInlineConfFuzz1",
810 ),
811 line: "8:6",
812 docs: "forge-config: default.fuzz.runs = 1",
813 },
814]
815"#]]
816 );
817
818 let mut natspecs = vec![];
819 parse(
820 &mut natspecs,
821 src,
822 "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2",
823 "FuzzInlineConf2",
824 );
825 assert_data_eq!(
826 format!("{natspecs:#?}"),
827 str![[r#"
828[
829 NatSpec {
830 contract: "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2",
831 function: Some(
832 "testInlineConfFuzz2",
833 ),
834 line: "13:5",
835 docs: "forge-config: default.fuzz.runs = 2",
836 },
837]
838"#]]
839 );
840 }
841
842 #[test]
843 fn parse_contract_level_config() {
844 let src = r#"
845// SPDX-License-Identifier: MIT OR Apache-2.0
846pragma solidity >=0.8.0;
847
848import "ds-test/test.sol";
849
850/// forge-config: default.fuzz.runs = 1
851contract FuzzInlineConf is DSTest {
852 /// forge-config: default.fuzz.runs = 3
853 function testInlineConfFuzz1() {}
854
855 function testInlineConfFuzz2() {}
856}"#;
857 let mut natspecs = vec![];
858 parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
859 assert_data_eq!(
860 format!("{natspecs:#?}"),
861 str![[r#"
862[
863 NatSpec {
864 contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
865 function: None,
866 line: "7:1",
867 docs: "forge-config: default.fuzz.runs = 1",
868 },
869 NatSpec {
870 contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
871 function: Some(
872 "testInlineConfFuzz1",
873 ),
874 line: "9:5",
875 docs: "forge-config: default.fuzz.runs = 3",
876 },
877]
878"#]]
879 );
880 }
881
882 #[test]
883 fn translates_legacy_halmos_array_lengths() {
884 let natspec = NatSpec {
885 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
886 function: Some("checkBytes".to_string()),
887 line: "10:1".to_string(),
888 docs: "@custom:halmos --loop 256 --array-lengths 2,4,8 --depth 100".to_string(),
889 };
890
891 assert_eq!(
892 natspec.halmos_config_values().unwrap(),
893 vec![
894 "default.symbolic.loop = 256",
895 "default.symbolic.array_lengths = [2, 4, 8]",
896 "default.symbolic.depth = 100",
897 ]
898 );
899 }
900
901 #[test]
902 fn translates_named_halmos_array_lengths() {
903 let natspec = NatSpec {
904 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
905 function: Some("checkBytes".to_string()),
906 line: "10:1".to_string(),
907 docs: "@custom:halmos --array-lengths values={2,4},data=8".to_string(),
908 };
909
910 assert_eq!(
911 natspec.halmos_config_values().unwrap(),
912 vec!["default.symbolic.dynamic_lengths = { data = [8], values = [2, 4] }"]
913 );
914 }
915
916 #[test]
917 fn translates_halmos_default_dynamic_length_sets() {
918 let natspec = NatSpec {
919 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
920 function: Some("checkBytes".to_string()),
921 line: "10:1".to_string(),
922 docs:
923 "@custom:halmos --default-array-lengths 0,1,2 --default-bytes-lengths={0,65,1024}"
924 .to_string(),
925 };
926
927 assert_eq!(
928 natspec.halmos_config_values().unwrap(),
929 vec![
930 "default.symbolic.default_array_lengths = [0, 1, 2]",
931 "default.symbolic.default_bytes_lengths = [0, 65, 1024]",
932 ]
933 );
934 }
935
936 #[test]
937 fn translates_legacy_halmos_width_depth_and_solver_timeout() {
938 let natspec = NatSpec {
939 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
940 function: Some("invariant_state".to_string()),
941 line: "10:1".to_string(),
942 docs: "@custom:halmos --width=32 --depth 128 --solver-timeout-branching 5".to_string(),
943 };
944
945 assert_eq!(
946 natspec.halmos_config_values().unwrap(),
947 vec![
948 "default.symbolic.width = 32",
949 "default.symbolic.depth = 128",
950 "default.symbolic.timeout = 5",
951 ]
952 );
953 }
954
955 #[test]
956 fn translates_legacy_halmos_solver_selection() {
957 let natspec = NatSpec {
958 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
959 function: Some("check_solver".to_string()),
960 line: "10:1".to_string(),
961 docs: "@custom:halmos --solver cvc5 --solver-command \"bitwuzla --produce-models\""
962 .to_string(),
963 };
964
965 assert_eq!(
966 natspec.halmos_config_values().unwrap(),
967 vec![
968 "default.symbolic.solver = \"cvc5\"",
969 "default.symbolic.solver_command = \"bitwuzla --produce-models\"",
970 ]
971 );
972 }
973
974 #[test]
975 fn rejects_malformed_legacy_halmos_array_lengths() {
976 let natspec = NatSpec {
977 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
978 function: Some("checkBytes".to_string()),
979 line: "10:1".to_string(),
980 docs: "@custom:halmos --array-lengths nope".to_string(),
981 };
982
983 let err = natspec.halmos_config_values().unwrap_err();
984
985 assert!(err.to_string().contains("invalid @custom:halmos annotation"));
986 assert!(err.to_string().contains("invalid length `nope`"));
987 }
988
989 #[test]
990 fn ignores_unsupported_halmos_flags_while_translating_supported_ones() {
991 let natspec = NatSpec {
992 contract: "dir/TestContract.t.sol:SymbolicContract".to_string(),
993 function: Some("checkBytes".to_string()),
994 line: "10:1".to_string(),
995 docs: "@custom:halmos --unsupported value --loop 10 --another --array-lengths 2"
996 .to_string(),
997 };
998
999 assert_eq!(
1000 natspec.halmos_config_values().unwrap(),
1001 vec!["default.symbolic.loop = 10", "default.symbolic.array_lengths = [2]",]
1002 );
1003 }
1004
1005 #[test]
1006 fn parse_solar_legacy_halmos_only_config() {
1007 let src = r#"
1008contract SymbolicHalmosLengths {
1009 /// @custom:halmos --array-lengths 3
1010 function checkArray(uint256[] memory values) public pure {
1011 values;
1012 }
1013}
1014 "#;
1015 let mut natspecs = vec![];
1016 parse(
1017 &mut natspecs,
1018 src,
1019 "inline/SymbolicHalmosLengths.t.sol:SymbolicHalmosLengths",
1020 "SymbolicHalmosLengths",
1021 );
1022
1023 assert_eq!(natspecs.len(), 1);
1024 assert_eq!(
1025 natspecs[0].halmos_config_values().unwrap(),
1026 vec!["default.symbolic.array_lengths = [3]"]
1027 );
1028 }
1029
1030 #[test]
1031 fn split_halmos_args_rejects_unterminated_quote() {
1032 let err = split_halmos_args(r#"--width "unterm"#).unwrap_err();
1033 assert_eq!(err, "invalid shell quoting in @custom:halmos config");
1034 }
1035}