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