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