foundry_compilers/resolver/
parse.rs

1use foundry_compilers_core::utils;
2use semver::VersionReq;
3use solar_parse::{ast, interface::sym};
4use std::{
5    ops::Range,
6    path::{Path, PathBuf},
7};
8
9/// Represents various information about a Solidity file.
10#[derive(Clone, Debug)]
11pub struct SolData {
12    pub license: Option<Spanned<String>>,
13    pub version: Option<Spanned<String>>,
14    pub experimental: Option<Spanned<String>>,
15    pub imports: Vec<Spanned<SolImport>>,
16    pub version_req: Option<VersionReq>,
17    pub libraries: Vec<SolLibrary>,
18    pub contract_names: Vec<String>,
19    pub is_yul: bool,
20    pub parse_result: Result<(), String>,
21}
22
23impl SolData {
24    /// Returns the result of parsing the file.
25    pub fn parse_result(&self) -> crate::Result<()> {
26        self.parse_result.clone().map_err(crate::SolcError::ParseError)
27    }
28
29    #[allow(dead_code)]
30    pub fn fmt_version<W: std::fmt::Write>(
31        &self,
32        f: &mut W,
33    ) -> std::result::Result<(), std::fmt::Error> {
34        if let Some(version) = &self.version {
35            write!(f, "({})", version.data)?;
36        }
37        Ok(())
38    }
39
40    /// Extracts the useful data from a solidity source
41    ///
42    /// This will attempt to parse the solidity AST and extract the imports and version pragma. If
43    /// parsing fails, we'll fall back to extract that info via regex
44    pub fn parse(content: &str, file: &Path) -> Self {
45        let is_yul = file.extension().is_some_and(|ext| ext == "yul");
46        let mut version = None;
47        let mut experimental = None;
48        let mut imports = Vec::<Spanned<SolImport>>::new();
49        let mut libraries = Vec::new();
50        let mut contract_names = Vec::new();
51        let mut parse_result = Ok(());
52
53        let sess = solar_parse::interface::Session::builder()
54            .with_buffer_emitter(Default::default())
55            .build();
56        sess.enter(|| {
57            let arena = ast::Arena::new();
58            let filename = solar_parse::interface::source_map::FileName::Real(file.to_path_buf());
59            let Ok(mut parser) =
60                solar_parse::Parser::from_source_code(&sess, &arena, filename, content.to_string())
61            else {
62                return;
63            };
64            let Ok(ast) = parser.parse_file().map_err(|e| e.emit()) else { return };
65            for item in ast.items {
66                let loc = item.span.lo().to_usize()..item.span.hi().to_usize();
67                match &item.kind {
68                    ast::ItemKind::Pragma(pragma) => match &pragma.tokens {
69                        ast::PragmaTokens::Version(name, req) if name.name == sym::solidity => {
70                            version = Some(Spanned::new(req.to_string(), loc));
71                        }
72                        ast::PragmaTokens::Custom(name, value)
73                            if name.as_str() == "experimental" =>
74                        {
75                            let value =
76                                value.as_ref().map(|v| v.as_str().to_string()).unwrap_or_default();
77                            experimental = Some(Spanned::new(value, loc));
78                        }
79                        _ => {}
80                    },
81
82                    ast::ItemKind::Import(import) => {
83                        let path = import.path.value.to_string();
84                        let aliases = match &import.items {
85                            ast::ImportItems::Plain(None) | ast::ImportItems::Glob(None) => &[][..],
86                            ast::ImportItems::Plain(Some(alias))
87                            | ast::ImportItems::Glob(Some(alias)) => &[(*alias, None)][..],
88                            ast::ImportItems::Aliases(aliases) => aliases,
89                        };
90                        let sol_import = SolImport::new(PathBuf::from(path)).set_aliases(
91                            aliases
92                                .iter()
93                                .map(|(id, alias)| match alias {
94                                    Some(al) => SolImportAlias::Contract(
95                                        al.name.to_string(),
96                                        id.name.to_string(),
97                                    ),
98                                    None => SolImportAlias::File(id.name.to_string()),
99                                })
100                                .collect(),
101                        );
102                        imports.push(Spanned::new(sol_import, loc));
103                    }
104
105                    ast::ItemKind::Contract(contract) => {
106                        if contract.kind.is_library() {
107                            libraries.push(SolLibrary { is_inlined: library_is_inlined(contract) });
108                        }
109                        contract_names.push(contract.name.to_string());
110                    }
111
112                    _ => {}
113                }
114            }
115        });
116        if let Err(e) = sess.emitted_errors().unwrap() {
117            let e = e.to_string();
118            trace!("failed parsing {file:?}: {e}");
119            parse_result = Err(e);
120
121            if version.is_none() {
122                version = utils::capture_outer_and_inner(
123                    content,
124                    &utils::RE_SOL_PRAGMA_VERSION,
125                    &["version"],
126                )
127                .first()
128                .map(|(cap, name)| Spanned::new(name.as_str().to_owned(), cap.range()));
129            }
130            if imports.is_empty() {
131                imports = capture_imports(content);
132            }
133            if contract_names.is_empty() {
134                utils::RE_CONTRACT_NAMES.captures_iter(content).for_each(|cap| {
135                    contract_names.push(cap[1].to_owned());
136                });
137            }
138        }
139        let license = content.lines().next().and_then(|line| {
140            utils::capture_outer_and_inner(
141                line,
142                &utils::RE_SOL_SDPX_LICENSE_IDENTIFIER,
143                &["license"],
144            )
145            .first()
146            .map(|(cap, l)| Spanned::new(l.as_str().to_owned(), cap.range()))
147        });
148        let version_req = version.as_ref().and_then(|v| Self::parse_version_req(v.data()).ok());
149
150        Self {
151            version_req,
152            version,
153            experimental,
154            imports,
155            license,
156            libraries,
157            contract_names,
158            is_yul,
159            parse_result,
160        }
161    }
162
163    /// Returns the corresponding SemVer version requirement for the solidity version.
164    ///
165    /// Note: This is a workaround for the fact that `VersionReq::parse` does not support whitespace
166    /// separators and requires comma separated operators. See [VersionReq].
167    pub fn parse_version_req(version: &str) -> Result<VersionReq, semver::Error> {
168        let version = version.replace(' ', ",");
169
170        // Somehow, Solidity semver without an operator is considered to be "exact",
171        // but lack of operator automatically marks the operator as Caret, so we need
172        // to manually patch it? :shrug:
173        let exact = !matches!(&version[0..1], "*" | "^" | "=" | ">" | "<" | "~");
174        let mut version = VersionReq::parse(&version)?;
175        if exact {
176            version.comparators[0].op = semver::Op::Exact;
177        }
178
179        Ok(version)
180    }
181}
182
183#[derive(Clone, Debug)]
184pub struct SolImport {
185    path: PathBuf,
186    aliases: Vec<SolImportAlias>,
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub enum SolImportAlias {
191    File(String),
192    Contract(String, String),
193}
194
195impl SolImport {
196    pub fn new(path: PathBuf) -> Self {
197        Self { path, aliases: vec![] }
198    }
199
200    pub fn path(&self) -> &PathBuf {
201        &self.path
202    }
203
204    pub fn aliases(&self) -> &Vec<SolImportAlias> {
205        &self.aliases
206    }
207
208    fn set_aliases(mut self, aliases: Vec<SolImportAlias>) -> Self {
209        self.aliases = aliases;
210        self
211    }
212}
213
214/// Minimal representation of a contract inside a solidity file
215#[derive(Clone, Debug)]
216pub struct SolLibrary {
217    pub is_inlined: bool,
218}
219
220impl SolLibrary {
221    /// Returns `true` if all functions of this library will be inlined.
222    ///
223    /// This checks if all functions are either internal or private, because internal functions can
224    /// only be accessed from within the current contract or contracts deriving from it. They cannot
225    /// be accessed externally. Since they are not exposed to the outside through the contract’s
226    /// ABI, they can take parameters of internal types like mappings or storage references.
227    ///
228    /// See also <https://docs.soliditylang.org/en/latest/contracts.html#libraries>
229    pub fn is_inlined(&self) -> bool {
230        self.is_inlined
231    }
232}
233
234/// A spanned item.
235#[derive(Clone, Debug)]
236pub struct Spanned<T> {
237    /// The byte range of `data` in the file.
238    pub span: Range<usize>,
239    /// The data of the item.
240    pub data: T,
241}
242
243impl<T> Spanned<T> {
244    /// Creates a new data unit with the given data and location.
245    pub fn new(data: T, span: Range<usize>) -> Self {
246        Self { data, span }
247    }
248
249    /// Returns the underlying data.
250    pub fn data(&self) -> &T {
251        &self.data
252    }
253
254    /// Returns the location.
255    pub fn span(&self) -> Range<usize> {
256        self.span.clone()
257    }
258
259    /// Returns the location adjusted by an offset.
260    ///
261    /// Used to determine new position of the unit within the file after content manipulation.
262    pub fn loc_by_offset(&self, offset: isize) -> Range<usize> {
263        utils::range_by_offset(&self.span, offset)
264    }
265}
266
267fn library_is_inlined(contract: &ast::ItemContract<'_>) -> bool {
268    contract
269        .body
270        .iter()
271        .filter_map(|item| match &item.kind {
272            ast::ItemKind::Function(f) => Some(f),
273            _ => None,
274        })
275        .all(|f| {
276            !matches!(
277                f.header.visibility,
278                Some(ast::Visibility::Public | ast::Visibility::External)
279            )
280        })
281}
282
283/// Capture the import statement information together with aliases
284pub fn capture_imports(content: &str) -> Vec<Spanned<SolImport>> {
285    let mut imports = vec![];
286    for cap in utils::RE_SOL_IMPORT.captures_iter(content) {
287        if let Some(name_match) = ["p1", "p2", "p3", "p4"].iter().find_map(|name| cap.name(name)) {
288            let statement_match = cap.get(0).unwrap();
289            let mut aliases = vec![];
290            for alias_cap in utils::RE_SOL_IMPORT_ALIAS.captures_iter(statement_match.as_str()) {
291                if let Some(alias) = alias_cap.name("alias") {
292                    let alias = alias.as_str().to_owned();
293                    let import_alias = match alias_cap.name("target") {
294                        Some(target) => SolImportAlias::Contract(alias, target.as_str().to_owned()),
295                        None => SolImportAlias::File(alias),
296                    };
297                    aliases.push(import_alias);
298                }
299            }
300            let sol_import =
301                SolImport::new(PathBuf::from(name_match.as_str())).set_aliases(aliases);
302            imports.push(Spanned::new(sol_import, statement_match.range()));
303        }
304    }
305    imports
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[track_caller]
313    fn assert_version(version_req: Option<&str>, src: &str) {
314        let data = SolData::parse(src, "test.sol".as_ref());
315        assert_eq!(data.version_req, version_req.map(|v| v.parse().unwrap()), "src:\n{src}");
316    }
317
318    #[track_caller]
319    fn assert_contract_names(names: &[&str], src: &str) {
320        let data = SolData::parse(src, "test.sol".as_ref());
321        assert_eq!(data.contract_names, names, "src:\n{src}");
322    }
323
324    #[test]
325    fn soldata_parsing() {
326        assert_version(None, "");
327        assert_version(None, "contract C { }");
328
329        // https://github.com/foundry-rs/foundry/issues/9349
330        assert_version(
331            Some(">=0.4.22, <0.6"),
332            r#"
333pragma solidity >=0.4.22 <0.6;
334
335contract BugReport {
336    function() external payable {
337        deposit();
338    }
339    function deposit() public payable {}
340}
341        "#,
342        );
343
344        assert_contract_names(
345            &["A", "B69$_", "C_", "$D"],
346            r#"
347    contract A {}
348library B69$_ {}
349abstract contract C_ {} interface $D {}
350
351uint constant x = .1e10;
352uint constant y = .1 ether;
353        "#,
354        );
355    }
356
357    #[test]
358    fn can_capture_curly_imports() {
359        let content = r#"
360import { T } from "../Test.sol";
361import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
362import {DsTest} from "ds-test/test.sol";
363"#;
364
365        let captured_imports =
366            capture_imports(content).into_iter().map(|s| s.data.path).collect::<Vec<_>>();
367
368        let expected =
369            utils::find_import_paths(content).map(|m| m.as_str().into()).collect::<Vec<PathBuf>>();
370
371        assert_eq!(captured_imports, expected);
372
373        assert_eq!(
374            captured_imports,
375            vec![
376                PathBuf::from("../Test.sol"),
377                "@openzeppelin/contracts/utils/ReentrancyGuard.sol".into(),
378                "ds-test/test.sol".into(),
379            ],
380        );
381    }
382
383    #[test]
384    fn cap_capture_aliases() {
385        let content = r#"
386import * as T from "./Test.sol";
387import { DsTest as Test } from "ds-test/test.sol";
388import "ds-test/test.sol" as Test;
389import { FloatMath as Math, Math as FloatMath } from "./Math.sol";
390"#;
391
392        let caputred_imports =
393            capture_imports(content).into_iter().map(|s| s.data.aliases).collect::<Vec<_>>();
394        assert_eq!(
395            caputred_imports,
396            vec![
397                vec![SolImportAlias::File("T".into())],
398                vec![SolImportAlias::Contract("Test".into(), "DsTest".into())],
399                vec![SolImportAlias::File("Test".into())],
400                vec![
401                    SolImportAlias::Contract("Math".into(), "FloatMath".into()),
402                    SolImportAlias::Contract("FloatMath".into(), "Math".into()),
403                ],
404            ]
405        );
406    }
407}