lintspec_core/utils.rs
1//! Utils for parsing Solidity source code.
2use std::{fmt::Write as _, path::Path, sync::LazyLock};
3
4use regex::Regex;
5pub use semver;
6use semver::{Version, VersionReq};
7use slang_solidity::{
8 cst::{NonterminalKind, Query, TextIndex},
9 parser::Parser,
10 utils::LanguageFacts,
11};
12
13use crate::{
14 error::{ErrorKind, Result},
15 prelude::OrPanic as _,
16};
17
18/// A regex to identify version pragma statements so that the whole file does not need to be parsed.
19static REGEX: LazyLock<Regex> = LazyLock::new(|| {
20 Regex::new(r"pragma\s+solidity[^;]+;").or_panic("the version pragma regex should compile")
21});
22
23/// Search for `pragma solidity` statements in the source and return the highest matching Solidity version.
24///
25/// If no pragma directive is found, the version defaults to `0.8.0`. Only the first pragma directive is considered,
26/// other ones in the file are ignored. Multiple version specifiers separated by a space are taken as meaning "and",
27/// specifiers separated by `||` are taken as meaning "or". Spaces take precedence over double-pipes.
28///
29/// Example: `0.6.0 || >=0.7.0 <0.8.0` means "either 0.6.0 or 0.7.x".
30///
31/// Within the specifiers' constraints, the highest version that is supported by [`slang_solidity`] is returned. In
32/// the above example, version `0.7.6` would be used.
33///
34/// # Errors
35/// This function errors if the found version string cannot be parsed to a [`VersionReq`] or if the version is not
36/// supported by [`slang_solidity`].
37///
38/// # Panics
39/// This function panics if the [`LanguageFacts::ALL_VERSIONS`] list is empty.
40///
41/// # Examples
42///
43/// ```
44/// # use std::path::PathBuf;
45/// # use lintspec_core::utils::{detect_solidity_version, semver::Version};
46/// assert_eq!(
47/// detect_solidity_version("pragma solidity >=0.8.4 <0.8.26;", PathBuf::from("./file.sol")).unwrap(),
48/// Version::new(0, 8, 25)
49/// );
50/// assert_eq!(
51/// detect_solidity_version("pragma solidity ^0.4.0 || 0.6.x;", PathBuf::from("./file.sol")).unwrap(),
52/// Version::new(0, 6, 12)
53/// );
54/// assert_eq!(
55/// detect_solidity_version("contract Foo {}", PathBuf::from("./file.sol")).unwrap(),
56/// Version::new(0, 8, 0)
57/// );
58/// // this version of Solidity does not exist
59/// assert!(detect_solidity_version("pragma solidity 0.7.7;", PathBuf::from("./file.sol")).is_err());
60/// ```
61pub fn detect_solidity_version(src: impl AsRef<str>, path: impl AsRef<Path>) -> Result<Version> {
62 fn inner(src: &str, path: &Path) -> Result<Version> {
63 let Some(pragma) = REGEX.find(src) else {
64 return Ok(Version::new(0, 8, 0));
65 };
66
67 let parser = Parser::create(get_latest_supported_version()).or_panic(
68 "the Parser should be initialized correctly with a supported solidity version",
69 );
70
71 let parse_result =
72 parser.parse_nonterminal(NonterminalKind::PragmaDirective, pragma.as_str());
73 if !parse_result.is_valid() {
74 let Some(error) = parse_result.errors().first() else {
75 return Err(ErrorKind::UnknownError.into());
76 };
77 return Err(ErrorKind::ParsingError {
78 path: path.to_path_buf(),
79 loc: error.text_range().start.into(),
80 message: error.message(),
81 }
82 .into());
83 }
84
85 let cursor = parse_result.create_tree_cursor();
86 let query_set = Query::create("@version_set [VersionExpressionSet]")
87 .or_panic("version set query should compile");
88 let query_expr = Query::create("@version_expr [VersionExpression]")
89 .or_panic("version expr query should compile");
90
91 let mut version_reqs = Vec::new();
92 for m in cursor.query(vec![query_set]) {
93 let Some(Some(set)) = m
94 .capture("version_set")
95 .map(|capture| capture.cursors().first().cloned())
96 else {
97 continue;
98 };
99 version_reqs.push(String::new());
100 let cursor = set.node().create_cursor(TextIndex::default());
101 for m in cursor.query(vec![query_expr.clone()]) {
102 let Some(Some(expr)) = m
103 .capture("version_expr")
104 .map(|capture| capture.cursors().first().cloned())
105 else {
106 continue;
107 };
108 let text = expr.node().unparse();
109 let text = text.trim();
110 // check if we are dealing with a version range with hyphen format
111 let v = version_reqs.last_mut().ok_or(ErrorKind::ParsingError {
112 path: path.to_path_buf(),
113 loc: expr.text_range().start.into(),
114 message: "version expression is not in an expression set".to_string(),
115 })?;
116 if let Some((start, end)) = text.split_once('-') {
117 let _ = write!(v, ",>={},<={}", start.trim(), end.trim());
118 } else {
119 // for `semver`, the different specifiers should be combined with a comma if they must all match
120 if let Some(true) = text.chars().next().map(|c| c.is_ascii_digit()) {
121 // for `semver`, no comparator is the same as the caret comparator, but for solidity it means `=`
122 let _ = write!(v, ",={text}");
123 } else {
124 let _ = write!(v, ",{text}");
125 }
126 }
127 }
128 }
129 let reqs = version_reqs
130 .into_iter()
131 .map(|r| VersionReq::parse(r.trim_start_matches(',')).map_err(Into::into))
132 .collect::<Result<Vec<_>>>()?;
133 reqs.iter()
134 .filter_map(|r| {
135 LanguageFacts::ALL_VERSIONS
136 .iter()
137 .rev()
138 .find(|v| r.matches(v))
139 })
140 .max()
141 .cloned()
142 .ok_or_else(|| {
143 ErrorKind::SolidityUnsupportedVersion(pragma.as_str().to_string()).into()
144 })
145 }
146 inner(src.as_ref(), path.as_ref())
147}
148
149/// Get the latest Solidity version supported by the [`slang_solidity`] parser
150#[must_use]
151pub fn get_latest_supported_version() -> Version {
152 LanguageFacts::LATEST_VERSION
153}