foundry_compilers/resolver/
parse.rs

1use foundry_compilers_core::utils;
2use semver::VersionReq;
3use solar_parse::{ast, interface::sym};
4use solar_sema::interface;
5use std::{
6    ops::Range,
7    path::{Path, PathBuf},
8};
9
10/// Solidity parser.
11///
12/// Holds a [`solar_sema::Compiler`] that is used to parse sources incrementally.
13/// After project compilation ([`Graph::resolve`]), this will contain all sources parsed by
14/// [`Graph`].
15///
16/// This state is currently lost on `Clone`.
17///
18/// [`Graph`]: crate::Graph
19/// [`Graph::resolve`]: crate::Graph::resolve
20#[derive(derive_more::Debug)]
21pub struct SolParser {
22    #[debug(ignore)]
23    pub(crate) compiler: solar_sema::Compiler,
24}
25
26impl Clone for SolParser {
27    fn clone(&self) -> Self {
28        Self {
29            compiler: solar_sema::Compiler::new(Self::session_with_opts(
30                self.compiler.sess().opts.clone(),
31            )),
32        }
33    }
34}
35
36impl SolParser {
37    /// Returns a reference to the compiler.
38    pub fn compiler(&self) -> &solar_sema::Compiler {
39        &self.compiler
40    }
41
42    /// Returns a mutable reference to the compiler.
43    pub fn compiler_mut(&mut self) -> &mut solar_sema::Compiler {
44        &mut self.compiler
45    }
46
47    /// Consumes the parser and returns the compiler.
48    pub fn into_compiler(self) -> solar_sema::Compiler {
49        self.compiler
50    }
51
52    pub(crate) fn session_with_opts(
53        opts: solar_sema::interface::config::Opts,
54    ) -> solar_sema::interface::Session {
55        let sess = solar_sema::interface::Session::builder()
56            .with_buffer_emitter(Default::default())
57            .opts(opts)
58            .build();
59        sess.source_map().set_file_loader(FileLoader);
60        sess
61    }
62}
63
64struct FileLoader;
65impl interface::source_map::FileLoader for FileLoader {
66    fn canonicalize_path(&self, path: &Path) -> std::io::Result<PathBuf> {
67        interface::source_map::RealFileLoader.canonicalize_path(path)
68    }
69
70    fn load_stdin(&self) -> std::io::Result<String> {
71        interface::source_map::RealFileLoader.load_stdin()
72    }
73
74    fn load_file(&self, path: &Path) -> std::io::Result<String> {
75        interface::source_map::RealFileLoader.load_file(path).map(|s| {
76            if s.contains('\r') {
77                s.replace('\r', "")
78            } else {
79                s
80            }
81        })
82    }
83
84    fn load_binary_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
85        interface::source_map::RealFileLoader.load_binary_file(path)
86    }
87}
88
89/// Represents various information about a Solidity file.
90#[derive(Clone, Debug)]
91#[non_exhaustive]
92pub struct SolData {
93    pub license: Option<Spanned<String>>,
94    pub version: Option<Spanned<String>>,
95    pub experimental: Option<Spanned<String>>,
96    pub imports: Vec<Spanned<SolImport>>,
97    pub version_req: Option<VersionReq>,
98    pub libraries: Vec<SolLibrary>,
99    pub contract_names: Vec<String>,
100    pub is_yul: bool,
101    pub parse_result: Result<(), String>,
102}
103
104impl SolData {
105    /// Returns the result of parsing the file.
106    pub fn parse_result(&self) -> crate::Result<()> {
107        self.parse_result.clone().map_err(crate::SolcError::ParseError)
108    }
109
110    #[allow(dead_code)]
111    pub fn fmt_version<W: std::fmt::Write>(
112        &self,
113        f: &mut W,
114    ) -> std::result::Result<(), std::fmt::Error> {
115        if let Some(version) = &self.version {
116            write!(f, "({})", version.data)?;
117        }
118        Ok(())
119    }
120
121    /// Extracts the useful data from a solidity source
122    ///
123    /// This will attempt to parse the solidity AST and extract the imports and version pragma. If
124    /// parsing fails, we'll fall back to extract that info via regex
125    #[instrument(name = "SolData::parse", skip_all)]
126    pub fn parse(content: &str, file: &Path) -> Self {
127        match crate::parse_one_source(content, file, |sess, ast| {
128            SolDataBuilder::parse(content, file, Ok((sess, ast)))
129        }) {
130            Ok(data) => data,
131            Err(e) => {
132                let e = e.to_string();
133                trace!("failed parsing {file:?}: {e}");
134                SolDataBuilder::parse(content, file, Err(Some(e)))
135            }
136        }
137    }
138
139    pub(crate) fn parse_from(
140        sess: &solar_sema::interface::Session,
141        s: &solar_sema::Source<'_>,
142    ) -> Self {
143        let content = s.file.src.as_str();
144        let file = s.file.name.as_real().unwrap();
145        let ast = s.ast.as_ref().map(|ast| (sess, ast)).ok_or(None);
146        SolDataBuilder::parse(content, file, ast)
147    }
148
149    /// Parses the version pragma and returns the corresponding SemVer version requirement.
150    ///
151    /// See [`parse_version_req`](Self::parse_version_req).
152    pub fn parse_version_pragma(pragma: &str) -> Option<Result<VersionReq, semver::Error>> {
153        let version = utils::find_version_pragma(pragma)?.as_str();
154        Some(Self::parse_version_req(version))
155    }
156
157    /// Returns the corresponding SemVer version requirement for the solidity version.
158    ///
159    /// Note: This is a workaround for the fact that `VersionReq::parse` does not support whitespace
160    /// separators and requires comma separated operators. See [VersionReq].
161    pub fn parse_version_req(version: &str) -> Result<VersionReq, semver::Error> {
162        let version = version.replace(' ', ",");
163
164        // Somehow, Solidity semver without an operator is considered to be "exact",
165        // but lack of operator automatically marks the operator as Caret, so we need
166        // to manually patch it? :shrug:
167        let exact = !matches!(version.get(..1), Some("*" | "^" | "=" | ">" | "<" | "~"));
168        let mut version = VersionReq::parse(&version)?;
169        if exact {
170            version.comparators[0].op = semver::Op::Exact;
171        }
172
173        Ok(version)
174    }
175}
176
177#[derive(Default)]
178struct SolDataBuilder {
179    version: Option<Spanned<String>>,
180    experimental: Option<Spanned<String>>,
181    imports: Vec<Spanned<SolImport>>,
182    libraries: Vec<SolLibrary>,
183    contract_names: Vec<String>,
184    parse_err: Option<String>,
185}
186
187impl SolDataBuilder {
188    fn parse(
189        content: &str,
190        file: &Path,
191        ast: Result<
192            (&solar_sema::interface::Session, &solar_parse::ast::SourceUnit<'_>),
193            Option<String>,
194        >,
195    ) -> SolData {
196        let mut builder = Self::default();
197        match ast {
198            Ok((sess, ast)) => builder.parse_from_ast(sess, ast),
199            Err(err) => {
200                builder.parse_from_regex(content);
201                if let Some(err) = err {
202                    builder.parse_err = Some(err);
203                }
204            }
205        }
206        builder.build(content, file)
207    }
208
209    fn parse_from_ast(
210        &mut self,
211        sess: &solar_sema::interface::Session,
212        ast: &solar_parse::ast::SourceUnit<'_>,
213    ) {
214        for item in ast.items.iter() {
215            let loc = sess.source_map().span_to_source(item.span).unwrap().1;
216            match &item.kind {
217                ast::ItemKind::Pragma(pragma) => match &pragma.tokens {
218                    ast::PragmaTokens::Version(name, req) if name.name == sym::solidity => {
219                        self.version = Some(Spanned::new(req.to_string(), loc));
220                    }
221                    ast::PragmaTokens::Custom(name, value) if name.as_str() == "experimental" => {
222                        let value =
223                            value.as_ref().map(|v| v.as_str().to_string()).unwrap_or_default();
224                        self.experimental = Some(Spanned::new(value, loc));
225                    }
226                    _ => {}
227                },
228
229                ast::ItemKind::Import(import) => {
230                    let path = import.path.value.to_string();
231                    let aliases = match &import.items {
232                        ast::ImportItems::Plain(None) => &[][..],
233                        ast::ImportItems::Plain(Some(alias)) | ast::ImportItems::Glob(alias) => {
234                            &[(*alias, None)][..]
235                        }
236                        ast::ImportItems::Aliases(aliases) => aliases,
237                    };
238                    let sol_import = SolImport::new(PathBuf::from(path)).set_aliases(
239                        aliases
240                            .iter()
241                            .map(|(id, alias)| match alias {
242                                Some(al) => SolImportAlias::Contract(
243                                    al.name.to_string(),
244                                    id.name.to_string(),
245                                ),
246                                None => SolImportAlias::File(id.name.to_string()),
247                            })
248                            .collect(),
249                    );
250                    self.imports.push(Spanned::new(sol_import, loc));
251                }
252
253                ast::ItemKind::Contract(contract) => {
254                    if contract.kind.is_library() {
255                        self.libraries
256                            .push(SolLibrary { is_inlined: library_is_inlined(contract) });
257                    }
258                    self.contract_names.push(contract.name.to_string());
259                }
260
261                _ => {}
262            }
263        }
264    }
265
266    fn parse_from_regex(&mut self, content: &str) {
267        if self.version.is_none() {
268            self.version = utils::capture_outer_and_inner(
269                content,
270                &utils::RE_SOL_PRAGMA_VERSION,
271                &["version"],
272            )
273            .first()
274            .map(|(cap, name)| Spanned::new(name.as_str().to_owned(), cap.range()));
275        }
276        if self.imports.is_empty() {
277            self.imports = capture_imports(content);
278        }
279        if self.contract_names.is_empty() {
280            utils::RE_CONTRACT_NAMES.captures_iter(content).for_each(|cap| {
281                self.contract_names.push(cap[1].to_owned());
282            });
283        }
284    }
285
286    fn build(self, content: &str, file: &Path) -> SolData {
287        let Self { version, experimental, imports, libraries, contract_names, parse_err } = self;
288        let license = content.lines().next().and_then(|line| {
289            utils::capture_outer_and_inner(
290                line,
291                &utils::RE_SOL_SDPX_LICENSE_IDENTIFIER,
292                &["license"],
293            )
294            .first()
295            .map(|(cap, l)| Spanned::new(l.as_str().to_owned(), cap.range()))
296        });
297        let version_req = version.as_ref().and_then(|v| SolData::parse_version_req(v.data()).ok());
298        SolData {
299            license,
300            version,
301            experimental,
302            imports,
303            version_req,
304            libraries,
305            contract_names,
306            is_yul: file.extension().is_some_and(|ext| ext == "yul"),
307            parse_result: parse_err.map(Err).unwrap_or(Ok(())),
308        }
309    }
310}
311
312#[derive(Clone, Debug)]
313pub struct SolImport {
314    path: PathBuf,
315    aliases: Vec<SolImportAlias>,
316}
317
318#[derive(Clone, Debug, PartialEq, Eq)]
319pub enum SolImportAlias {
320    File(String),
321    Contract(String, String),
322}
323
324impl SolImport {
325    pub fn new(path: PathBuf) -> Self {
326        Self { path, aliases: vec![] }
327    }
328
329    pub fn path(&self) -> &Path {
330        &self.path
331    }
332
333    pub fn aliases(&self) -> &[SolImportAlias] {
334        &self.aliases
335    }
336
337    fn set_aliases(mut self, aliases: Vec<SolImportAlias>) -> Self {
338        self.aliases = aliases;
339        self
340    }
341}
342
343/// Minimal representation of a contract inside a solidity file
344#[derive(Clone, Debug)]
345pub struct SolLibrary {
346    pub is_inlined: bool,
347}
348
349impl SolLibrary {
350    /// Returns `true` if all functions of this library will be inlined.
351    ///
352    /// This checks if all functions are either internal or private, because internal functions can
353    /// only be accessed from within the current contract or contracts deriving from it. They cannot
354    /// be accessed externally. Since they are not exposed to the outside through the contract’s
355    /// ABI, they can take parameters of internal types like mappings or storage references.
356    ///
357    /// See also <https://docs.soliditylang.org/en/latest/contracts.html#libraries>
358    pub fn is_inlined(&self) -> bool {
359        self.is_inlined
360    }
361}
362
363/// A spanned item.
364#[derive(Clone, Debug)]
365pub struct Spanned<T> {
366    /// The byte range of `data` in the file.
367    pub span: Range<usize>,
368    /// The data of the item.
369    pub data: T,
370}
371
372impl<T> Spanned<T> {
373    /// Creates a new data unit with the given data and location.
374    pub fn new(data: T, span: Range<usize>) -> Self {
375        Self { data, span }
376    }
377
378    /// Returns the underlying data.
379    pub fn data(&self) -> &T {
380        &self.data
381    }
382
383    /// Returns the location.
384    pub fn span(&self) -> Range<usize> {
385        self.span.clone()
386    }
387
388    /// Returns the location adjusted by an offset.
389    ///
390    /// Used to determine new position of the unit within the file after content manipulation.
391    pub fn loc_by_offset(&self, offset: isize) -> Range<usize> {
392        utils::range_by_offset(&self.span, offset)
393    }
394}
395
396fn library_is_inlined(contract: &ast::ItemContract<'_>) -> bool {
397    contract
398        .body
399        .iter()
400        .filter_map(|item| match &item.kind {
401            ast::ItemKind::Function(f) => Some(f),
402            _ => None,
403        })
404        .all(|f| {
405            !matches!(
406                f.header.visibility.map(|v| *v),
407                Some(ast::Visibility::Public | ast::Visibility::External)
408            )
409        })
410}
411
412/// Capture the import statement information together with aliases
413pub fn capture_imports(content: &str) -> Vec<Spanned<SolImport>> {
414    let mut imports = vec![];
415    for cap in utils::RE_SOL_IMPORT.captures_iter(content) {
416        if let Some(name_match) = ["p1", "p2", "p3", "p4"].iter().find_map(|name| cap.name(name)) {
417            let statement_match = cap.get(0).unwrap();
418            let mut aliases = vec![];
419            for alias_cap in utils::RE_SOL_IMPORT_ALIAS.captures_iter(statement_match.as_str()) {
420                if let Some(alias) = alias_cap.name("alias") {
421                    let alias = alias.as_str().to_owned();
422                    let import_alias = match alias_cap.name("target") {
423                        Some(target) => SolImportAlias::Contract(alias, target.as_str().to_owned()),
424                        None => SolImportAlias::File(alias),
425                    };
426                    aliases.push(import_alias);
427                }
428            }
429            let sol_import =
430                SolImport::new(PathBuf::from(name_match.as_str())).set_aliases(aliases);
431            imports.push(Spanned::new(sol_import, statement_match.range()));
432        }
433    }
434    imports
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[track_caller]
442    fn assert_version(version_req: Option<&str>, src: &str) {
443        let data = SolData::parse(src, "test.sol".as_ref());
444        assert_eq!(data.version_req, version_req.map(|v| v.parse().unwrap()), "src:\n{src}");
445    }
446
447    #[track_caller]
448    fn assert_contract_names(names: &[&str], src: &str) {
449        let data = SolData::parse(src, "test.sol".as_ref());
450        assert_eq!(data.contract_names, names, "src:\n{src}");
451    }
452
453    #[test]
454    fn soldata_parsing() {
455        assert_version(None, "");
456        assert_version(None, "contract C { }");
457
458        // https://github.com/foundry-rs/foundry/issues/9349
459        assert_version(
460            Some(">=0.4.22, <0.6"),
461            r#"
462pragma solidity >=0.4.22 <0.6;
463
464contract BugReport {
465    function() external payable {
466        deposit();
467    }
468    function deposit() public payable {}
469}
470        "#,
471        );
472
473        assert_contract_names(
474            &["A", "B69$_", "C_", "$D"],
475            r#"
476    contract A {}
477library B69$_ {}
478abstract contract C_ {} interface $D {}
479
480uint constant x = .1e10;
481uint constant y = .1 ether;
482        "#,
483        );
484    }
485
486    #[test]
487    fn can_capture_curly_imports() {
488        let content = r#"
489import { T } from "../Test.sol";
490import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
491import {DsTest} from "ds-test/test.sol";
492"#;
493
494        let captured_imports =
495            capture_imports(content).into_iter().map(|s| s.data.path).collect::<Vec<_>>();
496
497        let expected =
498            utils::find_import_paths(content).map(|m| m.as_str().into()).collect::<Vec<PathBuf>>();
499
500        assert_eq!(captured_imports, expected);
501
502        assert_eq!(
503            captured_imports,
504            vec![
505                PathBuf::from("../Test.sol"),
506                "@openzeppelin/contracts/utils/ReentrancyGuard.sol".into(),
507                "ds-test/test.sol".into(),
508            ],
509        );
510    }
511
512    #[test]
513    fn cap_capture_aliases() {
514        let content = r#"
515import * as T from "./Test.sol";
516import { DsTest as Test } from "ds-test/test.sol";
517import "ds-test/test.sol" as Test;
518import { FloatMath as Math, Math as FloatMath } from "./Math.sol";
519"#;
520
521        let caputred_imports =
522            capture_imports(content).into_iter().map(|s| s.data.aliases).collect::<Vec<_>>();
523        assert_eq!(
524            caputred_imports,
525            vec![
526                vec![SolImportAlias::File("T".into())],
527                vec![SolImportAlias::Contract("Test".into(), "DsTest".into())],
528                vec![SolImportAlias::File("Test".into())],
529                vec![
530                    SolImportAlias::Contract("Math".into(), "FloatMath".into()),
531                    SolImportAlias::Contract("FloatMath".into(), "Math".into()),
532                ],
533            ]
534        );
535    }
536}