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