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