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#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct NatSpec {
18 pub contract: String,
20 pub function: Option<String>,
22 pub line: String,
24 pub docs: String,
26}
27
28impl NatSpec {
29 #[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 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 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 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 pub fn location_string(&self) -> String {
115 format!("{}:{}", self.path(), self.line)
116 }
117
118 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 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 fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
155 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 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 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 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_docs(item);
293
294 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 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ߥHJ粊머?槿ᴴጅϖ뀓Ӽ츙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}