use super::{InlineConfigError, InlineConfigErrorKind, INLINE_CONFIG_PREFIX};
use figment::Profile;
use foundry_compilers::{
artifacts::{ast::NodeType, Node},
ProjectCompileOutput,
};
use itertools::Itertools;
use serde_json::Value;
use solang_parser::{helpers::CodeLocation, pt};
use std::{collections::BTreeMap, path::Path};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NatSpec {
pub contract: String,
pub function: Option<String>,
pub line: String,
pub docs: String,
}
impl NatSpec {
pub fn parse(output: &ProjectCompileOutput, root: &Path) -> Vec<Self> {
let mut natspecs: Vec<Self> = vec![];
let solc = SolcParser::new();
let solang = SolangParser::new();
for (id, artifact) in output.artifact_ids() {
let abs_path = id.source.as_path();
let path = abs_path.strip_prefix(root).unwrap_or(abs_path);
let contract_name = id.name.split('.').next().unwrap();
let contract = format!("{}:{}", path.display(), id.name);
let mut used_solc_ast = false;
if let Some(ast) = &artifact.ast {
if let Some(node) = solc.contract_root_node(&ast.nodes, &contract) {
solc.parse(&mut natspecs, &contract, node, true);
used_solc_ast = true;
}
}
if !used_solc_ast {
if let Ok(src) = std::fs::read_to_string(abs_path) {
solang.parse(&mut natspecs, &src, &contract, contract_name);
}
}
}
natspecs
}
pub fn validate_profiles(&self, profiles: &[Profile]) -> eyre::Result<()> {
for config in self.config_values() {
if !profiles.iter().any(|p| {
config
.strip_prefix(p.as_str().as_str())
.is_some_and(|rest| rest.trim_start().starts_with('.'))
}) {
Err(InlineConfigError {
location: self.location_string(),
kind: InlineConfigErrorKind::InvalidProfile(
config.to_string(),
profiles.iter().format(", ").to_string(),
),
})?
}
}
Ok(())
}
pub fn path(&self) -> &str {
match self.contract.split_once(':') {
Some((path, _)) => path,
None => self.contract.as_str(),
}
}
pub fn location_string(&self) -> String {
format!("{}:{}", self.path(), self.line)
}
pub fn config_values(&self) -> impl Iterator<Item = &str> {
self.docs.lines().filter_map(|line| {
line.find(INLINE_CONFIG_PREFIX)
.map(|idx| line[idx + INLINE_CONFIG_PREFIX.len()..].trim())
})
}
}
struct SolcParser {
_private: (),
}
impl SolcParser {
fn new() -> Self {
Self { _private: () }
}
fn contract_root_node<'a>(&self, nodes: &'a [Node], contract_id: &str) -> Option<&'a Node> {
for n in nodes.iter() {
if n.node_type == NodeType::ContractDefinition {
let contract_data = &n.other;
if let Value::String(contract_name) = contract_data.get("name")? {
if contract_id.ends_with(contract_name) {
return Some(n)
}
}
}
}
None
}
fn parse(&self, natspecs: &mut Vec<NatSpec>, contract: &str, node: &Node, root: bool) {
if root {
if let Some((docs, line)) = self.get_node_docs(&node.other) {
natspecs.push(NatSpec { contract: contract.into(), function: None, docs, line })
}
}
for n in node.nodes.iter() {
if let Some((function, docs, line)) = self.get_fn_data(n) {
natspecs.push(NatSpec {
contract: contract.into(),
function: Some(function),
line,
docs,
})
}
self.parse(natspecs, contract, n, false);
}
}
fn get_fn_data(&self, node: &Node) -> Option<(String, String, String)> {
if node.node_type == NodeType::FunctionDefinition {
let fn_data = &node.other;
let fn_name: String = self.get_fn_name(fn_data)?;
let (fn_docs, docs_src_line) = self.get_node_docs(fn_data)?;
return Some((fn_name, fn_docs, docs_src_line))
}
None
}
fn get_fn_name(&self, fn_data: &BTreeMap<String, Value>) -> Option<String> {
match fn_data.get("name")? {
Value::String(fn_name) => Some(fn_name.into()),
_ => None,
}
}
fn get_node_docs(&self, data: &BTreeMap<String, Value>) -> Option<(String, String)> {
if let Value::Object(fn_docs) = data.get("documentation")? {
if let Value::String(comment) = fn_docs.get("text")? {
if comment.contains(INLINE_CONFIG_PREFIX) {
let mut src_line = fn_docs
.get("src")
.map(|src| src.to_string())
.unwrap_or_else(|| String::from("<no-src-line-available>"));
src_line.retain(|c| c != '"');
return Some((comment.into(), src_line))
}
}
}
None
}
}
struct SolangParser {
_private: (),
}
impl SolangParser {
fn new() -> Self {
Self { _private: () }
}
fn parse(
&self,
natspecs: &mut Vec<NatSpec>,
src: &str,
contract_id: &str,
contract_name: &str,
) {
if !src.contains(INLINE_CONFIG_PREFIX) {
return;
}
let Ok((pt, comments)) = solang_parser::parse(src, 0) else { return };
let mut handle_docs = |contract: &str, func: Option<&str>, start, end| {
let docs = solang_parser::doccomment::parse_doccomments(&comments, start, end);
natspecs.extend(
docs.into_iter()
.flat_map(|doc| doc.into_comments())
.filter(|doc| doc.value.contains(INLINE_CONFIG_PREFIX))
.map(|doc| NatSpec {
line: "0:0:0".to_string(),
contract: contract.to_string(),
function: func.map(|f| f.to_string()),
docs: doc.value,
}),
);
};
let mut prev_item_end = 0;
for item in &pt.0 {
let pt::SourceUnitPart::ContractDefinition(c) = item else {
prev_item_end = item.loc().end();
continue
};
let Some(id) = c.name.as_ref() else {
prev_item_end = item.loc().end();
continue
};
if id.name != contract_name {
prev_item_end = item.loc().end();
continue
};
handle_docs(contract_id, None, prev_item_end, item.loc().start());
let mut prev_end = c.loc.start();
for part in &c.parts {
let pt::ContractPart::FunctionDefinition(f) = part else { continue };
let start = f.loc.start();
if let Some(name) = &f.name {
handle_docs(contract_id, Some(name.name.as_str()), prev_end, start);
}
prev_end = f.loc.end();
}
prev_item_end = item.loc().end();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn can_reject_invalid_profiles() {
let profiles = ["ci".into(), "default".into()];
let natspec = NatSpec {
contract: Default::default(),
function: Default::default(),
line: Default::default(),
docs: r"
forge-config: ciii.invariant.depth = 1
forge-config: default.invariant.depth = 1
"
.into(),
};
let result = natspec.validate_profiles(&profiles);
assert!(result.is_err());
}
#[test]
fn can_accept_valid_profiles() {
let profiles = ["ci".into(), "default".into()];
let natspec = NatSpec {
contract: Default::default(),
function: Default::default(),
line: Default::default(),
docs: r"
forge-config: ci.invariant.depth = 1
forge-config: default.invariant.depth = 1
"
.into(),
};
let result = natspec.validate_profiles(&profiles);
assert!(result.is_ok());
}
#[test]
fn parse_solang() {
let src = "
contract C { /// forge-config: default.fuzz.runs = 600
\t\t\t\t /// forge-config: default.fuzz.runs = 601
function f1() {}
/** forge-config: default.fuzz.runs = 700 */
function f2() {} /** forge-config: default.fuzz.runs = 800 */ function f3() {}
/**
* forge-config: default.fuzz.runs = 1024
* forge-config: default.fuzz.max-test-rejects = 500
*/
function f4() {}
}
";
let mut natspecs = vec![];
let solang = SolangParser::new();
let id = || "path.sol:C".to_string();
let default_line = || "0:0:0".to_string();
solang.parse(&mut natspecs, src, &id(), "C");
assert_eq!(
natspecs,
[
NatSpec {
contract: id(),
function: Some("f1".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 600\nforge-config: default.fuzz.runs = 601".to_string(),
},
NatSpec {
contract: id(),
function: Some("f2".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 700".to_string(),
},
NatSpec {
contract: id(),
function: Some("f3".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 800".to_string(),
},
NatSpec {
contract: id(),
function: Some("f4".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 1024\nforge-config: default.fuzz.max-test-rejects = 500".to_string(),
},
]
);
}
#[test]
fn parse_solang_2() {
let src = r#"
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import "ds-test/test.sol";
contract FuzzInlineConf is DSTest {
/**
* forge-config: default.fuzz.runs = 1024
* forge-config: default.fuzz.max-test-rejects = 500
*/
function testInlineConfFuzz(uint8 x) public {
require(true, "this is not going to revert");
}
}
"#;
let mut natspecs = vec![];
let solang = SolangParser::new();
let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string();
let default_line = || "0:0:0".to_string();
solang.parse(&mut natspecs, src, &id(), "FuzzInlineConf");
assert_eq!(
natspecs,
[
NatSpec {
contract: id(),
function: Some("testInlineConfFuzz".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 1024\nforge-config: default.fuzz.max-test-rejects = 500".to_string(),
},
]
);
}
#[test]
fn config_lines() {
let natspec = natspec();
let config_lines = natspec.config_values();
assert_eq!(
config_lines.collect::<Vec<_>>(),
[
"default.fuzz.runs = 600".to_string(),
"ci.fuzz.runs = 500".to_string(),
"default.invariant.runs = 1".to_string()
]
)
}
#[test]
fn can_handle_unavailable_src_line_with_fallback() {
let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
let doc_without_src_field = json!({ "text": "forge-config:default.fuzz.runs=600" });
fn_data.insert("documentation".into(), doc_without_src_field);
let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
assert_eq!(src_line, "<no-src-line-available>".to_string());
}
#[test]
fn can_handle_available_src_line() {
let mut fn_data: BTreeMap<String, Value> = BTreeMap::new();
let doc_without_src_field =
json!({ "text": "forge-config:default.fuzz.runs=600", "src": "73:21:12" });
fn_data.insert("documentation".into(), doc_without_src_field);
let (_, src_line) = SolcParser::new().get_node_docs(&fn_data).expect("Some docs");
assert_eq!(src_line, "73:21:12".to_string());
}
fn natspec() -> NatSpec {
let conf = r"
forge-config: default.fuzz.runs = 600
forge-config: ci.fuzz.runs = 500
========= SOME NOISY TEXT =============
䩹𧀫Jx닧Ʀ̳盅K擷Ɂw첊}ꏻk86ᖪk-檻ܴ렝[Dz𐤬oᘓƤ
꣖ۻ%Ƅ㪕ς:(饁av/烲ڻ̛߉橞㗡𥺃̹M봓䀖ؿ̄)d
ϊ&»ϿЏ2鞷砕eߥHJ粊머?槿ᴴጅϖ뀓Ӽ츙4
醤㭊r ܖ̹灱녗V*竅⒪苏贗=숽ؓбݧʹ園Ьi
=======================================
forge-config: default.invariant.runs = 1
";
NatSpec {
contract: "dir/TestContract.t.sol:FuzzContract".to_string(),
function: Some("test_myFunction".to_string()),
line: "10:12:111".to_string(),
docs: conf.to_string(),
}
}
#[test]
fn parse_solang_multiple_contracts_from_same_file() {
let src = r#"
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import "ds-test/test.sol";
contract FuzzInlineConf is DSTest {
/// forge-config: default.fuzz.runs = 1
function testInlineConfFuzz1() {}
}
contract FuzzInlineConf2 is DSTest {
/// forge-config: default.fuzz.runs = 2
function testInlineConfFuzz2() {}
}
"#;
let mut natspecs = vec![];
let solang = SolangParser::new();
let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string();
let default_line = || "0:0:0".to_string();
solang.parse(&mut natspecs, src, &id(), "FuzzInlineConf");
assert_eq!(
natspecs,
[NatSpec {
contract: id(),
function: Some("testInlineConfFuzz1".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 1".to_string(),
},]
);
let mut natspecs = vec![];
let id = || "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2".to_string();
solang.parse(&mut natspecs, src, &id(), "FuzzInlineConf2");
assert_eq!(
natspecs,
[NatSpec {
contract: id(),
function: Some("testInlineConfFuzz2".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 2".to_string(),
},]
);
}
#[test]
fn parse_contract_level_config() {
let src = r#"
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import "ds-test/test.sol";
/// forge-config: default.fuzz.runs = 1
contract FuzzInlineConf is DSTest {
/// forge-config: default.fuzz.runs = 3
function testInlineConfFuzz1() {}
function testInlineConfFuzz2() {}
}"#;
let mut natspecs = vec![];
let solang = SolangParser::new();
let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string();
let default_line = || "0:0:0".to_string();
solang.parse(&mut natspecs, src, &id(), "FuzzInlineConf");
assert_eq!(
natspecs,
[
NatSpec {
contract: id(),
function: None,
line: default_line(),
docs: "forge-config: default.fuzz.runs = 1".to_string(),
},
NatSpec {
contract: id(),
function: Some("testInlineConfFuzz1".to_string()),
line: default_line(),
docs: "forge-config: default.fuzz.runs = 3".to_string(),
}
]
);
}
}