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