Skip to main content

foundry_config/inline/
natspec.rs

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/// Convenient struct to hold in-line per-test configurations
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct NatSpec {
20    /// The parent contract of the natspec.
21    pub contract: String,
22    /// The function annotated with the natspec. None if the natspec is contract-level.
23    pub function: Option<String>,
24    /// The line the natspec begins, in the form `line:column`, i.e. `10:21`.
25    pub line: String,
26    /// The actual natspec comment, without slashes or block punctuation.
27    pub docs: String,
28}
29
30impl NatSpec {
31    /// Factory function that extracts a vector of [`NatSpec`] instances from
32    /// a solc compiler output. The root path is to express contract base dirs.
33    /// That is essential to match per-test configs at runtime.
34    #[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            // `id.identifier` but with the stripped path.
47            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    /// Checks if all configuration lines use a valid profile.
82    ///
83    /// i.e. Given available profiles
84    /// ```rust
85    /// let _profiles = vec!["ci", "default"];
86    /// ```
87    /// A configuration like `forge-config: ciii.invariant.depth = 1` would result
88    /// in an error.
89    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    /// Returns the path of the contract.
109    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    /// Returns the location of the natspec as a string.
117    pub fn location_string(&self) -> String {
118        format!("{}:{}", self.path(), self.line)
119    }
120
121    /// Returns a list of all the configuration values available in the natspec.
122    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    /// Returns Foundry inline config values translated from legacy Halmos annotations.
130    ///
131    /// Native `forge-config:` annotations remain preferred. This compatibility path only
132    /// translates the Halmos flags that map cleanly onto Foundry symbolic config.
133    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    /// Given a list of nodes, find a "ContractDefinition" node that matches
386    /// the provided contract_id.
387    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    /// Implements a DFS over a compiler output node and its children.
402    /// If a natspec is found it is added to `natspecs`
403    fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
404        // If we're at the root contract definition node, try parsing contract-level natspec
405        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    /// Given a compilation output node, if it is a function definition
422    /// that also contains a natspec then return a tuple of:
423    /// - Function name
424    /// - Natspec text
425    /// - Natspec position with format "row:col:length"
426    ///
427    /// Return None otherwise.
428    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    /// Given a dictionary of function data returns the name of the function.
440    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    /// Inspects Solc compiler output for documentation comments. Returns:
448    /// - `Some((String, String))` in case the function has natspec comments. First item is a
449    ///   textual natspec representation, the second item is the natspec src line, in the form
450    ///   "raw:col:length".
451    /// - `None` in case the function has not natspec comments.
452    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 contract level doc comments.
541            handle_docs(item);
542
543            // Handle function level doc comments.
544            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        // Fast path to avoid parsing the file.
568        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ߥH󶑶J粊񁼯머?槿ᴴጅ𙏑ϖ뀓򨙺򷃅Ӽ츙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}