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
15/// Convenient struct to hold in-line per-test configurations
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct NatSpec {
18    /// The parent contract of the natspec.
19    pub contract: String,
20    /// The function annotated with the natspec. None if the natspec is contract-level.
21    pub function: Option<String>,
22    /// The line the natspec begins, in the form `line:column`, i.e. `10:21`.
23    pub line: String,
24    /// The actual natspec comment, without slashes or block punctuation.
25    pub docs: String,
26}
27
28impl NatSpec {
29    /// Factory function that extracts a vector of [`NatSpec`] instances from
30    /// a solc compiler output. The root path is to express contract base dirs.
31    /// That is essential to match per-test configs at runtime.
32    #[instrument(name = "NatSpec::parse", skip_all)]
33    pub fn parse(output: &ProjectCompileOutput, root: &Path) -> Vec<Self> {
34        let mut natspecs: Vec<Self> = vec![];
35
36        let compiler = output.parser().solc().compiler();
37        let solar = SolarParser::new(compiler.sess());
38        let solc = SolcParser::new();
39        for (id, artifact) in output.artifact_ids() {
40            let path = id.source.as_path();
41            let path = path.strip_prefix(root).unwrap_or(path);
42            let abs_path = &*root.join(path);
43            let contract_name = id.name.split('.').next().unwrap();
44            // `id.identifier` but with the stripped path.
45            let contract = format!("{}:{}", path.display(), id.name);
46
47            let mut used_solar = false;
48            compiler.enter_sequential(|compiler| {
49                if let Some((_, source)) = compiler.gcx().get_ast_source(abs_path)
50                    && let Some(ast) = &source.ast
51                {
52                    solar.parse_ast(&mut natspecs, ast, &contract, contract_name);
53                    used_solar = true;
54                }
55            });
56
57            if !used_solar {
58                warn!(?abs_path, %contract, "could not parse natspec with solar");
59            }
60
61            let mut used_solc = false;
62            if !used_solar
63                && let Some(ast) = &artifact.ast
64                && let Some(node) = solc.contract_root_node(&ast.nodes, &contract)
65            {
66                solc.parse(&mut natspecs, &contract, node, true);
67                used_solc = true;
68            }
69
70            if !used_solar && !used_solc {
71                warn!(?abs_path, %contract, "could not parse natspec");
72            }
73        }
74
75        natspecs
76    }
77
78    /// Checks if all configuration lines use a valid profile.
79    ///
80    /// i.e. Given available profiles
81    /// ```rust
82    /// let _profiles = vec!["ci", "default"];
83    /// ```
84    /// A configuration like `forge-config: ciii.invariant.depth = 1` would result
85    /// in an error.
86    pub fn validate_profiles(&self, profiles: &[Profile]) -> eyre::Result<()> {
87        for config in self.config_values() {
88            if !profiles.iter().any(|p| {
89                config
90                    .strip_prefix(p.as_str().as_str())
91                    .is_some_and(|rest| rest.trim_start().starts_with('.'))
92            }) {
93                Err(InlineConfigError {
94                    location: self.location_string(),
95                    kind: InlineConfigErrorKind::InvalidProfile(
96                        config.to_string(),
97                        profiles.iter().format(", ").to_string(),
98                    ),
99                })?
100            }
101        }
102        Ok(())
103    }
104
105    /// Returns the path of the contract.
106    pub fn path(&self) -> &str {
107        match self.contract.split_once(':') {
108            Some((path, _)) => path,
109            None => self.contract.as_str(),
110        }
111    }
112
113    /// Returns the location of the natspec as a string.
114    pub fn location_string(&self) -> String {
115        format!("{}:{}", self.path(), self.line)
116    }
117
118    /// Returns a list of all the configuration values available in the natspec.
119    pub fn config_values(&self) -> impl Iterator<Item = &str> {
120        self.docs.lines().filter_map(|line| {
121            line.find(INLINE_CONFIG_PREFIX)
122                .map(|idx| line[idx + INLINE_CONFIG_PREFIX.len()..].trim())
123        })
124    }
125}
126
127struct SolcParser {
128    _private: (),
129}
130
131impl SolcParser {
132    fn new() -> Self {
133        Self { _private: () }
134    }
135
136    /// Given a list of nodes, find a "ContractDefinition" node that matches
137    /// the provided contract_id.
138    fn contract_root_node<'a>(&self, nodes: &'a [Node], contract_id: &str) -> Option<&'a Node> {
139        for n in nodes {
140            if n.node_type == NodeType::ContractDefinition {
141                let contract_data = &n.other;
142                if let Value::String(contract_name) = contract_data.get("name")?
143                    && contract_id.ends_with(contract_name)
144                {
145                    return Some(n);
146                }
147            }
148        }
149        None
150    }
151
152    /// Implements a DFS over a compiler output node and its children.
153    /// If a natspec is found it is added to `natspecs`
154    fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
155        // If we're at the root contract definition node, try parsing contract-level natspec
156        if root && let Some((docs, line)) = self.get_node_docs(&node.other) {
157            natspecs.push(NatSpec { contract: contract.into(), function: None, docs, line })
158        }
159        for n in &node.nodes {
160            if let Some((function, docs, line)) = self.get_fn_data(n) {
161                natspecs.push(NatSpec {
162                    contract: contract.into(),
163                    function: Some(function),
164                    line,
165                    docs,
166                })
167            }
168            self.parse(natspecs, contract, n, false);
169        }
170    }
171
172    /// Given a compilation output node, if it is a function definition
173    /// that also contains a natspec then return a tuple of:
174    /// - Function name
175    /// - Natspec text
176    /// - Natspec position with format "row:col:length"
177    ///
178    /// Return None otherwise.
179    fn get_fn_data(&self, node: &Node) -> Option<(String, String, String)> {
180        if node.node_type == NodeType::FunctionDefinition {
181            let fn_data = &node.other;
182            let fn_name: String = self.get_fn_name(fn_data)?;
183            let (fn_docs, docs_src_line) = self.get_node_docs(fn_data)?;
184            return Some((fn_name, fn_docs, docs_src_line));
185        }
186
187        None
188    }
189
190    /// Given a dictionary of function data returns the name of the function.
191    fn get_fn_name(&self, fn_data: &BTreeMap<String, Value>) -> Option<String> {
192        match fn_data.get("name")? {
193            Value::String(fn_name) => Some(fn_name.into()),
194            _ => None,
195        }
196    }
197
198    /// Inspects Solc compiler output for documentation comments. Returns:
199    /// - `Some((String, String))` in case the function has natspec comments. First item is a
200    ///   textual natspec representation, the second item is the natspec src line, in the form
201    ///   "raw:col:length".
202    /// - `None` in case the function has not natspec comments.
203    fn get_node_docs(&self, data: &BTreeMap<String, Value>) -> Option<(String, String)> {
204        if let Value::Object(fn_docs) = data.get("documentation")?
205            && let Value::String(comment) = fn_docs.get("text")?
206            && comment.contains(INLINE_CONFIG_PREFIX)
207        {
208            let mut src_line = fn_docs
209                .get("src")
210                .map(|src| src.to_string())
211                .unwrap_or_else(|| String::from("<no-src-line-available>"));
212
213            src_line.retain(|c| c != '"');
214            return Some((comment.into(), src_line));
215        }
216        None
217    }
218}
219
220struct SolarParser<'a> {
221    sess: &'a Session,
222}
223
224impl<'a> SolarParser<'a> {
225    fn new(sess: &'a Session) -> Self {
226        Self { sess }
227    }
228
229    fn parse_ast(
230        &self,
231        natspecs: &mut Vec<NatSpec>,
232        source_unit: &ast::SourceUnit<'_>,
233        contract_id: &str,
234        contract_name: &str,
235    ) {
236        let mut handle_docs = |item: &ast::Item<'_>| {
237            if item.docs.is_empty() {
238                return;
239            }
240            let mut span = Span::DUMMY;
241            let lines = item
242                .docs
243                .iter()
244                .filter_map(|d| {
245                    let s = d.symbol.as_str();
246                    if !s.contains(INLINE_CONFIG_PREFIX) {
247                        return None;
248                    }
249                    span = if span.is_dummy() { d.span } else { span.to(d.span) };
250                    match d.kind {
251                        ast::CommentKind::Line => Some(s.trim().to_string()),
252                        ast::CommentKind::Block => Some(
253                            s.lines()
254                                .filter(|line| line.contains(INLINE_CONFIG_PREFIX))
255                                .map(|line| line.trim_start().trim_start_matches('*').trim())
256                                .collect::<Vec<_>>()
257                                .join("\n"),
258                        ),
259                    }
260                })
261                .join("\n");
262            if lines.is_empty() {
263                return;
264            }
265            natspecs.push(NatSpec {
266                contract: contract_id.to_string(),
267                function: if let ast::ItemKind::Function(f) = &item.kind {
268                    Some(
269                        f.header
270                            .name
271                            .map(|sym| sym.to_string())
272                            .unwrap_or_else(|| f.kind.to_string()),
273                    )
274                } else {
275                    None
276                },
277                line: {
278                    let (_, loc) = self.sess.source_map().span_to_location_info(span);
279                    format!("{}:{}", loc.lo.line, loc.lo.col.0 + 1)
280                },
281                docs: lines,
282            });
283        };
284
285        for item in source_unit.items.iter() {
286            let ast::ItemKind::Contract(c) = &item.kind else { continue };
287            if c.name.as_str() != contract_name {
288                continue;
289            }
290
291            // Handle contract level doc comments.
292            handle_docs(item);
293
294            // Handle function level doc comments.
295            for item in c.body.iter() {
296                let ast::ItemKind::Function(_) = &item.kind else { continue };
297                handle_docs(item);
298            }
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use serde_json::json;
307    use snapbox::{assert_data_eq, str};
308    use solar::parse::{
309        Parser,
310        ast::{Arena, interface},
311    };
312
313    fn parse(natspecs: &mut Vec<NatSpec>, src: &str, contract_id: &str, contract_name: &str) {
314        // Fast path to avoid parsing the file.
315        if !src.contains(INLINE_CONFIG_PREFIX) {
316            return;
317        }
318
319        let sess = Session::builder()
320            .with_silent_emitter(Some("Inline config parsing failed".to_string()))
321            .build();
322        let solar = SolarParser::new(&sess);
323        let _ = sess.enter(|| -> interface::Result<()> {
324            let arena = Arena::new();
325
326            let mut parser = Parser::from_source_code(
327                &sess,
328                &arena,
329                interface::source_map::FileName::Custom(contract_id.to_string()),
330                src.to_string(),
331            )?;
332
333            let source_unit = parser.parse_file().map_err(|e| e.emit())?;
334
335            solar.parse_ast(natspecs, &source_unit, contract_id, contract_name);
336
337            Ok(())
338        });
339    }
340
341    #[test]
342    fn can_reject_invalid_profiles() {
343        let profiles = ["ci".into(), "default".into()];
344        let natspec = NatSpec {
345            contract: Default::default(),
346            function: Default::default(),
347            line: Default::default(),
348            docs: r"
349            forge-config: ciii.invariant.depth = 1
350            forge-config: default.invariant.depth = 1
351            "
352            .into(),
353        };
354
355        let result = natspec.validate_profiles(&profiles);
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn can_accept_valid_profiles() {
361        let profiles = ["ci".into(), "default".into()];
362        let natspec = NatSpec {
363            contract: Default::default(),
364            function: Default::default(),
365            line: Default::default(),
366            docs: r"
367            forge-config: ci.invariant.depth = 1
368            forge-config: default.invariant.depth = 1
369            "
370            .into(),
371        };
372
373        let result = natspec.validate_profiles(&profiles);
374        assert!(result.is_ok());
375    }
376
377    #[test]
378    fn parse_solar() {
379        let src = "
380contract C { /// forge-config: default.fuzz.runs = 600
381
382\t\t\t\t                                /// forge-config: default.fuzz.runs = 601
383
384    function f1() {}
385       /** forge-config: default.fuzz.runs = 700 */
386function f2() {} /** forge-config: default.fuzz.runs = 800 */ function f3() {}
387
388/**
389 * forge-config: default.fuzz.runs = 1024
390 * forge-config: default.fuzz.max-test-rejects = 500
391 */
392    function f4() {}
393}
394";
395        let mut natspecs = vec![];
396        parse(&mut natspecs, src, "path.sol:C", "C");
397        assert_data_eq!(
398            format!("{natspecs:#?}"),
399            str![[r#"
400[
401    NatSpec {
402        contract: "path.sol:C",
403        function: Some(
404            "f1",
405        ),
406        line: "2:14",
407        docs: "forge-config: default.fuzz.runs = 600/nforge-config: default.fuzz.runs = 601",
408    },
409    NatSpec {
410        contract: "path.sol:C",
411        function: Some(
412            "f2",
413        ),
414        line: "7:8",
415        docs: "forge-config: default.fuzz.runs = 700",
416    },
417    NatSpec {
418        contract: "path.sol:C",
419        function: Some(
420            "f3",
421        ),
422        line: "8:18",
423        docs: "forge-config: default.fuzz.runs = 800",
424    },
425    NatSpec {
426        contract: "path.sol:C",
427        function: Some(
428            "f4",
429        ),
430        line: "10:1",
431        docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500",
432    },
433]
434"#]]
435        );
436    }
437
438    #[test]
439    fn parse_solar_2() {
440        let src = r#"
441// SPDX-License-Identifier: MIT OR Apache-2.0
442pragma solidity >=0.8.0;
443
444import "ds-test/test.sol";
445
446contract FuzzInlineConf is DSTest {
447    /**
448     * forge-config: default.fuzz.runs = 1024
449     * forge-config: default.fuzz.max-test-rejects = 500
450     */
451    function testInlineConfFuzz(uint8 x) public {
452        require(true, "this is not going to revert");
453    }
454}
455        "#;
456        let mut natspecs = vec![];
457        parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
458        assert_data_eq!(
459            format!("{natspecs:#?}"),
460            str![[r#"
461[
462    NatSpec {
463        contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
464        function: Some(
465            "testInlineConfFuzz",
466        ),
467        line: "8:5",
468        docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500",
469    },
470]
471"#]]
472        );
473    }
474
475    #[test]
476    fn config_lines() {
477        let natspec = natspec();
478        let config_lines = natspec.config_values();
479        assert_eq!(
480            config_lines.collect::<Vec<_>>(),
481            [
482                "default.fuzz.runs = 600".to_string(),
483                "ci.fuzz.runs = 500".to_string(),
484                "default.invariant.runs = 1".to_string()
485            ]
486        )
487    }
488
489    #[test]
490    fn can_handle_unavailable_src_line_with_fallback() {
491        let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
492        let doc_without_src_field = json!({ "text":  "forge-config:default.fuzz.runs=600" });
493        fn_data.insert("documentation".into(), doc_without_src_field);
494        let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
495        assert_eq!(src_line, "<no-src-line-available>".to_string());
496    }
497
498    #[test]
499    fn can_handle_available_src_line() {
500        let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
501        let doc_without_src_field =
502            json!({ "text":  "forge-config:default.fuzz.runs=600", "src": "73:21:12" });
503        fn_data.insert("documentation".into(), doc_without_src_field);
504        let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
505        assert_eq!(src_line, "73:21:12".to_string());
506    }
507
508    fn natspec() -> NatSpec {
509        let conf = r"
510        forge-config: default.fuzz.runs = 600
511        forge-config: ci.fuzz.runs = 500
512        ========= SOME NOISY TEXT =============
513         䩹𧀫Jx닧Ʀ̳盅K擷􅟽Ɂw첊}ꏻk86ᖪk-檻ܴ렝[Dz𐤬oᘓƤ
514        ꣖ۻ%Ƅ㪕ς:(饁΍av/烲ڻ̛߉橞㗡𥺃̹M봓䀖ؿ̄󵼁)𯖛d􂽰񮍃
515        ϊ&»ϿЏ񊈞2򕄬񠪁鞷砕eߥH󶑶J粊񁼯머?槿ᴴጅ𙏑ϖ뀓򨙺򷃅Ӽ츙4󍔹
516        醤㭊r􎜕󷾸𶚏 ܖ̹灱녗V*竅􋹲⒪苏贗񾦼=숽ؓ򗋲бݧ󫥛𛲍ʹ園Ьi
517        =======================================
518        forge-config: default.invariant.runs = 1
519        ";
520
521        NatSpec {
522            contract: "dir/TestContract.t.sol:FuzzContract".to_string(),
523            function: Some("test_myFunction".to_string()),
524            line: "10:12:111".to_string(),
525            docs: conf.to_string(),
526        }
527    }
528
529    #[test]
530    fn parse_solar_multiple_contracts_from_same_file() {
531        let src = r#"
532// SPDX-License-Identifier: MIT OR Apache-2.0
533pragma solidity >=0.8.0;
534
535import "ds-test/test.sol";
536
537contract FuzzInlineConf is DSTest {
538     /// forge-config: default.fuzz.runs = 1
539    function testInlineConfFuzz1() {}
540}
541
542contract FuzzInlineConf2 is DSTest {
543    /// forge-config: default.fuzz.runs = 2
544    function testInlineConfFuzz2() {}
545}
546        "#;
547        let mut natspecs = vec![];
548        parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
549        assert_data_eq!(
550            format!("{natspecs:#?}"),
551            str![[r#"
552[
553    NatSpec {
554        contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
555        function: Some(
556            "testInlineConfFuzz1",
557        ),
558        line: "8:6",
559        docs: "forge-config: default.fuzz.runs = 1",
560    },
561]
562"#]]
563        );
564
565        let mut natspecs = vec![];
566        parse(
567            &mut natspecs,
568            src,
569            "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2",
570            "FuzzInlineConf2",
571        );
572        assert_data_eq!(
573            format!("{natspecs:#?}"),
574            str![[r#"
575[
576    NatSpec {
577        contract: "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2",
578        function: Some(
579            "testInlineConfFuzz2",
580        ),
581        line: "13:5",
582        docs: "forge-config: default.fuzz.runs = 2",
583    },
584]
585"#]]
586        );
587    }
588
589    #[test]
590    fn parse_contract_level_config() {
591        let src = r#"
592// SPDX-License-Identifier: MIT OR Apache-2.0
593pragma solidity >=0.8.0;
594
595import "ds-test/test.sol";
596
597/// forge-config: default.fuzz.runs = 1
598contract FuzzInlineConf is DSTest {
599    /// forge-config: default.fuzz.runs = 3
600    function testInlineConfFuzz1() {}
601
602    function testInlineConfFuzz2() {}
603}"#;
604        let mut natspecs = vec![];
605        parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf");
606        assert_data_eq!(
607            format!("{natspecs:#?}"),
608            str![[r#"
609[
610    NatSpec {
611        contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
612        function: None,
613        line: "7:1",
614        docs: "forge-config: default.fuzz.runs = 1",
615    },
616    NatSpec {
617        contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf",
618        function: Some(
619            "testInlineConfFuzz1",
620        ),
621        line: "9:5",
622        docs: "forge-config: default.fuzz.runs = 3",
623    },
624]
625"#]]
626        );
627    }
628}