foundry_config/inline/
natspec.rs

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