Skip to main content

foundry_compilers/compilers/vyper/
parser.rs

1use super::VyperLanguage;
2use crate::{
3    ProjectPathsConfig, SourceParser,
4    compilers::{ParsedSource, vyper::VYPER_EXTENSIONS},
5};
6use foundry_compilers_core::{
7    error::{Result, SolcError},
8    utils::{RE_VYPER_VERSION, capture_outer_and_inner},
9};
10use semver::VersionReq;
11use std::{
12    collections::BTreeSet,
13    path::{Path, PathBuf},
14};
15use winnow::{
16    ModalResult, Parser,
17    ascii::space1,
18    combinator::{alt, opt, preceded},
19    token::{take_till, take_while},
20};
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct VyperImport {
24    pub level: usize,
25    pub path: Option<String>,
26    pub final_part: Option<String>,
27}
28
29#[derive(Clone, Debug, Default)]
30pub struct VyperParser {
31    _inner: (),
32}
33
34impl SourceParser for VyperParser {
35    type ParsedSource = VyperParsedSource;
36
37    fn new(_config: &ProjectPathsConfig) -> Self {
38        Self { _inner: () }
39    }
40}
41
42#[derive(Clone, Debug)]
43pub struct VyperParsedSource {
44    path: PathBuf,
45    version_req: Option<VersionReq>,
46    imports: Vec<VyperImport>,
47}
48
49impl ParsedSource for VyperParsedSource {
50    type Language = VyperLanguage;
51
52    #[instrument(name = "VyperParsedSource::parse", skip_all)]
53    fn parse(content: &str, file: &Path) -> Result<Self> {
54        let version_req = capture_outer_and_inner(content, &RE_VYPER_VERSION, &["version"])
55            .first()
56            .and_then(|(_, cap)| match parse_vyper_version_req(cap.as_str()) {
57                Ok(req) => Some(req),
58                Err(err) => {
59                    warn!(
60                        file = %file.display(),
61                        pragma = cap.as_str(),
62                        error = %err,
63                        "failed to parse Vyper `pragma version` requirement; \
64                         continuing without a version constraint",
65                    );
66                    None
67                }
68            });
69
70        let imports = parse_imports(content);
71
72        let path = file.to_path_buf();
73
74        Ok(Self { path, version_req, imports })
75    }
76
77    fn version_req(&self) -> Option<&VersionReq> {
78        self.version_req.as_ref()
79    }
80
81    fn contract_names(&self) -> &[String] {
82        &[]
83    }
84
85    fn language(&self) -> Self::Language {
86        VyperLanguage
87    }
88
89    fn resolve_imports<C>(
90        &self,
91        paths: &ProjectPathsConfig<C>,
92        include_paths: &mut BTreeSet<PathBuf>,
93    ) -> Result<Vec<PathBuf>> {
94        let mut imports = Vec::new();
95        'outer: for import in &self.imports {
96            // skip built-in imports
97            if import.level == 0
98                && import
99                    .path
100                    .as_ref()
101                    .map(|path| path.starts_with("vyper.") || path.starts_with("ethereum.ercs"))
102                    .unwrap_or_default()
103            {
104                continue;
105            }
106
107            // Potential locations of imported source.
108            let mut candidate_dirs = Vec::new();
109
110            // For relative imports, vyper always checks only directory containing contract which
111            // includes given import.
112            if import.level > 0 {
113                let mut candidate_dir = Some(self.path.as_path());
114
115                for _ in 0..import.level {
116                    candidate_dir = candidate_dir.and_then(|dir| dir.parent());
117                }
118
119                let candidate_dir = candidate_dir.ok_or_else(|| {
120                    SolcError::msg(format!(
121                        "Could not go {} levels up for import at {}",
122                        import.level,
123                        self.path.display()
124                    ))
125                })?;
126
127                candidate_dirs.push(candidate_dir);
128            } else {
129                // For absolute imports, Vyper firstly checks current directory, and then root.
130                if let Some(parent) = self.path.parent() {
131                    candidate_dirs.push(parent);
132                }
133                candidate_dirs.push(paths.root.as_path());
134            }
135
136            candidate_dirs.extend(paths.libraries.iter().map(PathBuf::as_path));
137
138            let import_path = {
139                let mut path = PathBuf::new();
140
141                if let Some(import_path) = &import.path {
142                    path = path.join(import_path.replace('.', "/"));
143                }
144
145                if let Some(part) = &import.final_part {
146                    path = path.join(part);
147                }
148
149                path
150            };
151
152            for candidate_dir in candidate_dirs {
153                let candidate = candidate_dir.join(&import_path);
154                for extension in VYPER_EXTENSIONS {
155                    let candidate = candidate.clone().with_extension(extension);
156                    trace!("trying {}", candidate.display());
157                    if candidate.exists() {
158                        imports.push(candidate);
159                        include_paths.insert(candidate_dir.to_path_buf());
160                        continue 'outer;
161                    }
162                }
163            }
164
165            return Err(SolcError::msg(format!(
166                "failed to resolve import {}{} at {}",
167                ".".repeat(import.level),
168                import_path.display(),
169                self.path.display()
170            )));
171        }
172        Ok(imports)
173    }
174}
175
176/// Parses a Vyper `pragma version` requirement into a [`VersionReq`].
177///
178/// Vyper's pragma follows PEP 440, not Cargo's semver. Two PEP 440 spellings frequently appear
179/// in real-world contracts that `semver::VersionReq::parse` can't handle:
180///
181/// * The "compatible release" operator `~=`, e.g. `~=0.5.0` (≡ `>=0.5.0, <0.6.0`).
182/// * Implicit pre-release tags without a hyphen, e.g. `0.5.0a1`, `0.5.0b1`, `0.5.0rc1` (semver
183///   requires `0.5.0-a1`).
184///
185/// This helper first tries to parse the input as plain semver to preserve existing behavior, and
186/// only falls back to the PEP 440 → semver translation if that fails.
187fn parse_vyper_version_req(input: &str) -> Result<VersionReq, semver::Error> {
188    let trimmed = strip_inline_comment(input).trim();
189    if let Ok(req) = VersionReq::parse(trimmed) {
190        return Ok(req);
191    }
192    VersionReq::parse(&pep440_to_semver_req(trimmed))
193}
194
195/// Strip a trailing `#` comment from a captured pragma line.
196fn strip_inline_comment(s: &str) -> &str {
197    s.split_once('#').map_or(s, |(head, _)| head)
198}
199
200/// Translate the subset of PEP 440 grammar that shows up in Vyper pragmas into semver syntax.
201fn pep440_to_semver_req(input: &str) -> String {
202    let hyphenated = hyphenate_prerelease(input.trim());
203
204    if let Some(rest) = hyphenated.strip_prefix("~=") {
205        return compatible_release(rest.trim());
206    }
207    if let Some(rest) = hyphenated.strip_prefix("==") {
208        return format!("={}", rest.trim());
209    }
210    hyphenated
211}
212
213/// Insert a hyphen before bare PEP 440 pre-release labels (`a`, `b`, `rc`) so the result is valid
214/// semver: `0.5.0a1` -> `0.5.0-a1`. Anything after `+` (build metadata) is copied verbatim.
215fn hyphenate_prerelease(input: &str) -> String {
216    let bytes = input.as_bytes();
217    let mut out = String::with_capacity(input.len() + 1);
218    let mut i = 0;
219    while i < bytes.len() {
220        let c = bytes[i];
221        if c == b'+' {
222            out.push_str(&input[i..]);
223            return out;
224        }
225
226        let prev_is_digit = out.as_bytes().last().is_some_and(u8::is_ascii_digit);
227        if prev_is_digit {
228            // `<digit>rc<digit>` -> `<digit>-rc<digit>`
229            if c == b'r'
230                && bytes.get(i + 1) == Some(&b'c')
231                && bytes.get(i + 2).is_some_and(u8::is_ascii_digit)
232            {
233                out.push('-');
234                out.push_str("rc");
235                i += 2;
236                continue;
237            }
238            // `<digit>a<digit>` / `<digit>b<digit>` -> `<digit>-a<digit>` / `<digit>-b<digit>`
239            if (c == b'a' || c == b'b') && bytes.get(i + 1).is_some_and(u8::is_ascii_digit) {
240                out.push('-');
241                out.push(c as char);
242                i += 1;
243                continue;
244            }
245        }
246
247        out.push(c as char);
248        i += 1;
249    }
250    out
251}
252
253/// Expand the PEP 440 `~=` "compatible release" operator into the equivalent semver range.
254///
255/// Per PEP 440, `~=X.Y.Z` ≡ `>=X.Y.Z, ==X.Y.*` ≡ `>=X.Y.Z, <X.(Y+1).0`, and `~=X.Y` ≡
256/// `>=X.Y, <(X+1).0.0`.
257fn compatible_release(version: &str) -> String {
258    // Strip pre-release / build metadata before splitting on `.`; PEP 440 allows e.g. `~=0.5.0a1`
259    // and the upper bound is computed from the release component only.
260    let core = version.split(['+', '-']).next().unwrap_or(version);
261    let parts: Vec<&str> = core.split('.').collect();
262
263    if parts.len() < 2 {
264        // Not enough components to bump; encode at least the lower bound.
265        return format!(">={version}");
266    }
267
268    let bump_idx = parts.len() - 2;
269    let mut upper: Vec<String> = parts.iter().take(bump_idx + 1).map(|s| s.to_string()).collect();
270    let Ok(n) = upper[bump_idx].parse::<u64>() else {
271        return format!(">={version}");
272    };
273    upper[bump_idx] = (n + 1).to_string();
274    while upper.len() < 3 {
275        upper.push("0".to_string());
276    }
277
278    format!(">={version}, <{}", upper.join("."))
279}
280
281/// Parses given source trying to find all import directives.
282fn parse_imports(content: &str) -> Vec<VyperImport> {
283    let mut imports = Vec::new();
284
285    for mut line in content.split('\n') {
286        if let Ok(parts) = parse_import(&mut line) {
287            imports.push(parts);
288        }
289    }
290
291    imports
292}
293
294/// Parses given input, trying to find (import|from) part1.part2.part3 (import part4)?
295fn parse_import(input: &mut &str) -> ModalResult<VyperImport> {
296    (
297        preceded(
298            (alt(["from", "import"]), space1),
299            (take_while(0.., |c| c == '.'), take_till(0.., [' '])),
300        ),
301        opt(preceded((space1, "import", space1), take_till(0.., [' ']))),
302    )
303        .parse_next(input)
304        .map(|((dots, path), last)| VyperImport {
305            level: dots.len(),
306            path: (!path.is_empty()).then(|| path.to_string()),
307            final_part: last.map(|p| p.to_string()),
308        })
309}
310
311#[cfg(test)]
312mod tests {
313    use super::{
314        VyperImport, VyperParsedSource, parse_import, parse_vyper_version_req, pep440_to_semver_req,
315    };
316    use crate::compilers::ParsedSource;
317    use semver::{Version, VersionReq};
318    use std::path::Path;
319    use winnow::Parser;
320
321    #[test]
322    fn parses_semver_pragmas_unchanged() {
323        let req = parse_vyper_version_req("^0.3.7").unwrap();
324        assert_eq!(req, VersionReq::parse("^0.3.7").unwrap());
325    }
326
327    #[test]
328    fn parses_pep440_compatible_release_three_part() {
329        let req = parse_vyper_version_req("~=0.5.0").unwrap();
330        let expected = VersionReq::parse(">=0.5.0, <0.6.0").unwrap();
331        assert_eq!(req, expected);
332        assert!(req.matches(&Version::parse("0.5.3").unwrap()));
333        assert!(!req.matches(&Version::parse("0.6.0").unwrap()));
334    }
335
336    #[test]
337    fn parses_pep440_compatible_release_two_part() {
338        let req = parse_vyper_version_req("~=2.2").unwrap();
339        let expected = VersionReq::parse(">=2.2, <3.0.0").unwrap();
340        assert_eq!(req, expected);
341    }
342
343    #[test]
344    fn parses_pep440_compatible_release_with_prerelease() {
345        // `~=0.5.0a1` is the pragma snekmate uses for Vyper 0.5.0a1.
346        let req = parse_vyper_version_req("~=0.5.0a1").unwrap();
347        let expected = VersionReq::parse(">=0.5.0-a1, <0.6.0").unwrap();
348        assert_eq!(req, expected);
349        // Should match both the alpha and the eventual stable release within the same minor.
350        assert!(req.matches(&Version::parse("0.5.0-a1").unwrap()));
351        assert!(req.matches(&Version::parse("0.5.0").unwrap()));
352    }
353
354    #[test]
355    fn parses_pep440_bare_prerelease_versions() {
356        // `==0.5.0a1` -> `=0.5.0-a1`
357        let req = parse_vyper_version_req("==0.5.0a1").unwrap();
358        let expected = VersionReq::parse("=0.5.0-a1").unwrap();
359        assert_eq!(req, expected);
360    }
361
362    #[test]
363    fn pep440_translation_handles_rc_and_beta_tags() {
364        assert_eq!(pep440_to_semver_req(">=0.5.0rc2"), ">=0.5.0-rc2");
365        assert_eq!(pep440_to_semver_req(">=0.5.0b3"), ">=0.5.0-b3");
366    }
367
368    #[test]
369    fn rejects_garbage_pragmas() {
370        assert!(parse_vyper_version_req("not a version").is_err());
371    }
372
373    #[test]
374    fn vyper_pragma_with_space_after_hash_is_recognized() {
375        // Vyper accepts `# pragma version <req>` (with a space after `#`); make sure the
376        // regex picks it up and the constraint is recorded.
377        let parsed =
378            VyperParsedSource::parse("# pragma version ~=0.5.0a1\n", Path::new("test.vy")).unwrap();
379        let req = parsed.version_req().expect("expected a version requirement");
380        assert!(req.matches(&Version::parse("0.5.0-a1").unwrap()));
381        assert!(!req.matches(&Version::parse("0.6.0").unwrap()));
382    }
383
384    #[test]
385    fn legacy_at_version_pragma_still_parses() {
386        let parsed = VyperParsedSource::parse("#@version ^0.3.7\n", Path::new("test.vy")).unwrap();
387        assert_eq!(parsed.version_req(), Some(&VersionReq::parse("^0.3.7").unwrap()));
388    }
389
390    #[test]
391    fn can_parse_import() {
392        assert_eq!(
393            parse_import.parse("import one.two.three").unwrap(),
394            VyperImport { level: 0, path: Some("one.two.three".to_string()), final_part: None }
395        );
396        assert_eq!(
397            parse_import.parse("from one.two.three import four").unwrap(),
398            VyperImport {
399                level: 0,
400                path: Some("one.two.three".to_string()),
401                final_part: Some("four".to_string()),
402            }
403        );
404        assert_eq!(
405            parse_import.parse("from one import two").unwrap(),
406            VyperImport {
407                level: 0,
408                path: Some("one".to_string()),
409                final_part: Some("two".to_string()),
410            }
411        );
412        assert_eq!(
413            parse_import.parse("import one").unwrap(),
414            VyperImport { level: 0, path: Some("one".to_string()), final_part: None }
415        );
416        assert_eq!(
417            parse_import.parse("from . import one").unwrap(),
418            VyperImport { level: 1, path: None, final_part: Some("one".to_string()) }
419        );
420        assert_eq!(
421            parse_import.parse("from ... import two").unwrap(),
422            VyperImport { level: 3, path: None, final_part: Some("two".to_string()) }
423        );
424        assert_eq!(
425            parse_import.parse("from ...one.two import three").unwrap(),
426            VyperImport {
427                level: 3,
428                path: Some("one.two".to_string()),
429                final_part: Some("three".to_string())
430            }
431        );
432    }
433}