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 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 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 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 pub fn location_string(&self) -> String {
116 format!("{}:{}", self.path(), self.line)
117 }
118
119 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 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 fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
156 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 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 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 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_docs(item);
294
295 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 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ߥHJ粊머?槿ᴴጅϖ뀓Ӽ츙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}