Skip to main content

clang_installer/
version.rs

1use std::{path::PathBuf, str::FromStr};
2
3use crate::{
4    Cacher, ClangTool, PyPiDownloadError, PyPiDownloader,
5    downloader::{native_packages::try_install_package, static_dist::StaticDistDownloader},
6    tool::{GetClangPathError, GetClangVersionError},
7    utils::normalize_path,
8};
9use semver::{Version, VersionReq};
10
11#[derive(Debug, Clone)]
12pub struct ClangVersion {
13    pub version: Version,
14    pub path: PathBuf,
15}
16
17/// An enumeration of the possible requested versions of the clang tool binary.
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub enum RequestedVersion {
20    /// A specific path to the clang tool binary.
21    Path(PathBuf),
22
23    /// Whatever the system default uses (if any).
24    #[default]
25    SystemDefault,
26
27    /// A specific version requirement for the clang tool binary.
28    ///
29    /// For example, `=12.0.1`, `>=10.0.0, <13.0.0`.
30    Requirement(VersionReq),
31
32    /// A sentinel when no value is given.
33    ///
34    /// This is used internally to differentiate when the user intended
35    /// to invoke the `version` CLI subcommand instead.
36    NoValue,
37}
38
39/// Errors that occur when getting the clang tool binary.
40#[derive(Debug, thiserror::Error)]
41pub enum GetToolError {
42    /// No executable found for the specified version requirement.
43    #[error("No executable found for the specified version requirement")]
44    NotFound,
45
46    /// Failed to parse the version string.
47    #[error("Failed to parse version: {0}")]
48    VersionParseError(String),
49
50    /// The version requirement does not satisfy any known/supported clang version
51    #[error("The version requirement does not satisfy any known/supported clang version")]
52    UnsupportedVersion,
53
54    /// Binary executable in cache has no parent directory.
55    #[error("Binary executable in cache has no parent directory")]
56    ExecutablePathNoParent,
57
58    /// Failed to capture the clang version from `--version` output.
59    #[error(transparent)]
60    GetClangVersion(#[from] GetClangVersionError),
61
62    /// Failed to get the clang executable path.
63    #[error("Failed to get the clang executable path: {0}")]
64    GetClangPathError(#[from] GetClangPathError),
65
66    /// Failed to create symlink for the downloaded binary.
67    #[error("Failed to create symlink for the downloaded binary: {0}")]
68    SymlinkError(std::io::Error),
69
70    /// Failed to download tool from PyPi.
71    #[error("Failed to download tool from PyPi: {0}")]
72    PyPiDownloadError(#[from] PyPiDownloadError),
73}
74
75impl RequestedVersion {
76    pub async fn eval_tool(
77        &self,
78        tool: &ClangTool,
79        overwrite_symlink: bool,
80        directory: Option<&PathBuf>,
81    ) -> Result<Option<ClangVersion>, GetToolError> {
82        match self {
83            RequestedVersion::Path(_) => {
84                let exec_path = tool.get_exe_path(self)?;
85                let version = tool.capture_version(&exec_path)?;
86                log::info!(
87                    "Found {tool} version {version} at path: {:?}",
88                    exec_path.to_string_lossy()
89                );
90                Ok(Some(ClangVersion {
91                    version,
92                    path: exec_path,
93                }))
94            }
95            RequestedVersion::SystemDefault => {
96                let path = tool.get_exe_path(&Self::SystemDefault)?;
97                let version = tool.capture_version(&path)?;
98                log::info!(
99                    "Found {tool} version {version} at path: {:?}",
100                    path.to_string_lossy()
101                );
102                Ok(Some(ClangVersion { version, path }))
103            }
104            RequestedVersion::Requirement(version_req) => {
105                // check default available version first (if any)
106                if let Ok(path) = tool.get_exe_path(&Self::Requirement(version_req.clone())) {
107                    let version = tool.capture_version(&path)?;
108                    if version_req.matches(&version) {
109                        log::info!(
110                            "Found {tool} version {version} at path: {:?}",
111                            path.to_string_lossy()
112                        );
113                        return Ok(Some(ClangVersion { version, path }));
114                    }
115                }
116
117                // check if cache has a suitable version
118                let bin_ext = if cfg!(windows) { ".exe" } else { "" };
119                let min_ver = get_min_ver(version_req).ok_or(GetToolError::UnsupportedVersion)?;
120                let cached_bin = StaticDistDownloader::get_cache_dir()
121                    .join("bin")
122                    .join(format!("{tool}-{min_ver}{bin_ext}"));
123                if cached_bin.exists() {
124                    let version = tool.capture_version(&cached_bin)?;
125                    if version_req.matches(&version) {
126                        log::info!(
127                            "Found {tool} version {version} in cache at path: {:?}",
128                            cached_bin.to_string_lossy()
129                        );
130                        return Ok(Some(ClangVersion {
131                            version,
132                            path: cached_bin,
133                        }));
134                    }
135                }
136
137                // try to download a suitable version
138                let bin = match PyPiDownloader::download_tool(tool, version_req, directory).await {
139                    Ok(bin) => bin,
140                    Err(e) => {
141                        log::error!("Failed to download {tool} {version_req} from PyPi: {e}");
142                        if let Some(result) =
143                            try_install_package(tool, version_req, &min_ver).await?
144                        {
145                            return Ok(Some(result));
146                        }
147                        log::info!("Falling back to downloading {tool} static binaries.");
148                        match StaticDistDownloader::download_tool(tool, version_req, directory)
149                            .await
150                        {
151                            Ok(bin) => bin,
152                            Err(e) => {
153                                log::error!(
154                                    "Failed to download {tool} from static distribution: {e}"
155                                );
156                                return Err(GetToolError::NotFound);
157                            }
158                        }
159                    }
160                };
161
162                // create a symlink
163                let bin_dir = bin.parent().ok_or(GetToolError::ExecutablePathNoParent)?;
164                let symlink_path = bin_dir.join(format!("{tool}{bin_ext}"));
165                tool.symlink_bin(&bin, &symlink_path, overwrite_symlink)
166                    .map_err(GetToolError::SymlinkError)?;
167                let version = tool.capture_version(&bin)?;
168                Ok(Some(ClangVersion { version, path: bin }))
169            }
170            RequestedVersion::NoValue => {
171                log::info!(
172                    "{} version: {}",
173                    option_env!("CARGO_BIN_NAME").unwrap_or(env!("CARGO_PKG_NAME")),
174                    env!("CARGO_PKG_VERSION")
175                );
176                Ok(None)
177            }
178        }
179    }
180}
181
182pub fn get_min_ver(version_req: &VersionReq) -> Option<Version> {
183    let mut result = None;
184    let supported_version_range = StaticDistDownloader::get_major_version_range();
185    for major in supported_version_range.rev() {
186        let ver = Version::new(major as u64, 0, 0);
187        if version_req.matches(&ver) {
188            result = Some(ver);
189        }
190    }
191    result
192}
193
194/// Represents an error that occurred while parsing a requested version.
195#[derive(Debug, thiserror::Error)]
196pub enum RequestedVersionParsingError {
197    /// The specified version is not a proper version requirement or a valid path.
198    #[error("The specified version is not a proper version requirement or a valid path: {0}")]
199    InvalidInput(String),
200
201    /// Unknown parent directory of the given file path for `--version`.
202    #[error("Unknown parent directory of the given file path for `--version`: {0}")]
203    InvalidPath(String),
204
205    /// Failed to canonicalize path '{0}'.
206    #[error("Failed to canonicalize path '{0}': {1:?}")]
207    NonCanonicalPath(String, std::io::Error),
208}
209
210impl FromStr for RequestedVersion {
211    type Err = RequestedVersionParsingError;
212
213    fn from_str(input: &str) -> Result<Self, Self::Err> {
214        if input.is_empty() {
215            Ok(Self::SystemDefault)
216        } else if input == "CPP-LINTER-VERSION" {
217            Ok(Self::NoValue)
218        } else if let Ok(req) = VersionReq::parse(input) {
219            Ok(Self::Requirement(req))
220        } else {
221            let path = PathBuf::from(input);
222            if !path.exists() {
223                return Err(RequestedVersionParsingError::InvalidInput(
224                    input.to_string(),
225                ));
226            }
227            let path = if !path.is_dir() {
228                path.parent()
229                    .ok_or(RequestedVersionParsingError::InvalidPath(input.to_string()))?
230                    .to_path_buf()
231            } else {
232                path
233            };
234            let path = match path.canonicalize() {
235                Ok(p) => Ok(normalize_path(&p)),
236                Err(e) => Err(RequestedVersionParsingError::NonCanonicalPath(
237                    input.to_string(),
238                    e,
239                )),
240            }?;
241            Ok(Self::Path(path))
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use std::{path::PathBuf, str::FromStr};
249
250    use semver::VersionReq;
251    use tempfile::TempDir;
252
253    use super::RequestedVersion;
254    use crate::{ClangTool, utils::normalize_path};
255
256    // See also crate::tool::tests module for other `RequestedVersion::from_str()` tests.
257
258    #[test]
259    fn validate_version_path() {
260        let this_path_str = "src/version.rs";
261        let this_path = PathBuf::from(this_path_str);
262        let this_canonical = this_path.canonicalize().unwrap();
263        let parent = this_canonical.parent().unwrap();
264        let expected = normalize_path(parent);
265        let req_ver = RequestedVersion::from_str(this_path_str).unwrap();
266        if let RequestedVersion::Path(parsed) = req_ver {
267            assert_eq!(&parsed, &expected);
268        }
269
270        assert!(RequestedVersion::from_str("file.rs").is_err());
271    }
272
273    #[test]
274    fn validate_version_exact() {
275        let req_ver = RequestedVersion::from_str("12").unwrap();
276        if let RequestedVersion::Requirement(req) = req_ver {
277            assert_eq!(req.to_string(), "^12");
278        }
279    }
280
281    #[tokio::test]
282    async fn eval_no_value() {
283        let result = RequestedVersion::NoValue
284            .eval_tool(&ClangTool::ClangFormat, false, None)
285            .await
286            .unwrap();
287        assert!(result.is_none());
288    }
289
290    /// The idea for this test is to make sure the desired clang-tool is downloaded and
291    /// the download path can be used to identify the location of the clang tool.
292    #[tokio::test]
293    async fn eval_download_path() {
294        let tmp_cache_dir = TempDir::new().unwrap();
295        unsafe {
296            std::env::set_var("CPP_LINTER_CACHE", tmp_cache_dir.path());
297        }
298        let tool = ClangTool::ClangFormat;
299        // for this test we should use the oldest supported clang version
300        // because that would be most likely to require downloading.
301        let version_req =
302            VersionReq::parse(option_env!("MIN_CLANG_TOOLS_VERSION").unwrap_or("16")).unwrap();
303        let downloaded_clang = RequestedVersion::Requirement(version_req.clone())
304            .eval_tool(&tool, false, Some(&PathBuf::from(tmp_cache_dir.path())))
305            .await
306            .unwrap()
307            .unwrap();
308        println!("Downloaded clang-format: {downloaded_clang:?}");
309        let req_ver = RequestedVersion::Path(downloaded_clang.path.parent().unwrap().to_owned());
310        let result = req_ver
311            .eval_tool(&tool, false, None)
312            .await
313            .unwrap()
314            .unwrap();
315        println!("Evaluated clang-format from path: {result:?}");
316        assert!(
317            version_req.matches(&result.version),
318            "Expected {downloaded_clang:?} does not match {result:?}",
319        );
320        assert_eq!(result.version, downloaded_clang.version);
321        assert_eq!(result.path.parent(), downloaded_clang.path.parent());
322    }
323
324    /// WARNING: This test should only run in CI.
325    /// It is designed to use the system's package manager to install clang-tidy.
326    /// If successful, clang-tidy will be installed globally, which may be undesirable.
327    #[tokio::test]
328    async fn eval_version() {
329        let clang_version = option_env!("CLANG_VERSION").unwrap_or("12.0.1");
330        for tool in [ClangTool::ClangFormat, ClangTool::ClangTidy] {
331            let version_req = VersionReq::parse(clang_version).unwrap();
332            println!("Installing {tool} with version requirement: {version_req}");
333            let clang_path = RequestedVersion::Requirement(version_req.clone())
334                .eval_tool(&tool, false, None)
335                .await
336                .unwrap()
337                .unwrap();
338            eprintln!("Using {clang_path:?}");
339            // assert!(version_req.matches(&clang_path.version));
340        }
341    }
342}