foundry_compilers/compilers/vyper/
parser.rs1use 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 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 let mut candidate_dirs = Vec::new();
109
110 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 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
176fn 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
195fn strip_inline_comment(s: &str) -> &str {
197 s.split_once('#').map_or(s, |(head, _)| head)
198}
199
200fn 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
213fn 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 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 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
253fn compatible_release(version: &str) -> String {
258 let core = version.split(['+', '-']).next().unwrap_or(version);
261 let parts: Vec<&str> = core.split('.').collect();
262
263 if parts.len() < 2 {
264 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
281fn 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
294fn 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 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 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 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 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}