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#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct NatSpec {
15 pub contract: String,
17 pub function: Option<String>,
19 pub line: String,
21 pub docs: String,
23}
24
25impl NatSpec {
26 #[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 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 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 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 pub fn location_string(&self) -> String {
112 format!("{}:{}", self.path(), self.line)
113 }
114
115 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 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 fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
152 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 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 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 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_docs(item);
287
288 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 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 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 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 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 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ߥHJ粊머?槿ᴴጅϖ뀓Ӽ츙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 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}