Skip to main content

cargo_sonar/
cargo.rs

1use crate::TextRange;
2use eyre::Context as _;
3use std::{
4    collections::BTreeMap,
5    fs::File,
6    io::{BufRead as _, BufReader},
7    path::{Path, PathBuf},
8};
9use tracing::error;
10
11#[derive(Debug)]
12pub struct PackageRange {
13    pub range: TextRange,
14    pub name_range: TextRange,
15    pub version_range: TextRange,
16}
17
18#[derive(Debug)]
19pub struct Lockfile {
20    pub lockfile_path: PathBuf,
21    pub dependencies: BTreeMap<String, PackageRange>,
22}
23
24impl Lockfile {
25    #[must_use]
26    #[inline]
27    pub fn dependency_range(&self, name: &str) -> TextRange {
28        self.dependencies.get(name).map_or_else(
29            || {
30                error!("failed to find a corresponding text range for dependency '{}'. This is probably a bug in `cargo-sonar`, if you can please report an issue at https://gitlab.com/woshilapin/cargo-sonar/-/issues, we'd be happy to try and fix it.", name);
31                TextRange::new((1, 0), (1, 0))
32            },
33            |ranges| ranges.range,
34        )
35    }
36}
37
38#[derive(Debug)]
39enum StateParser {
40    // on '[[package]]'
41    Package {
42        start_line: usize,
43    },
44    // on 'name = "package-name"'
45    Name {
46        start_line: usize,
47        package_name: String,
48        name_range: TextRange,
49    },
50    // on 'version = "1.2.3"'
51    Version {
52        start_line: usize,
53        package_name: String,
54        name_range: TextRange,
55        version_range: TextRange,
56    },
57    // on empty line
58    End,
59}
60
61impl TryFrom<&Path> for Lockfile {
62    type Error = eyre::Error;
63
64    #[inline]
65    fn try_from(lockfile_path: &Path) -> Result<Self, Self::Error> {
66        let lockfile_path = lockfile_path
67            .canonicalize()
68            .with_context(|| format!("failed to canonicalize '{}", lockfile_path.display()))?;
69        let mut dependencies = BTreeMap::new();
70        let file = File::open(&lockfile_path)
71            .with_context(|| format!("failed to open '{}", lockfile_path.display()))?;
72        let reader = BufReader::new(file);
73        let mut state = StateParser::End;
74        for (line_num, line) in reader
75            .lines()
76            // Add an empty line at the end of the file so the last package can be parsed with a tailing empty line too
77            .chain(std::iter::once(Ok(String::new())))
78            .enumerate()
79        {
80            let line_num = line_num.saturating_add(1);
81            let line = line?;
82            let create_range = |line: &str| {
83                let start_column = line.find('"').unwrap_or(0);
84                let end_column = line
85                    .rfind('"')
86                    .unwrap_or_else(|| line.len().saturating_sub(1));
87                TextRange::new((line_num, start_column), (line_num, end_column))
88            };
89            match state {
90                StateParser::End if line == "[[package]]" => {
91                    state = StateParser::Package {
92                        start_line: line_num,
93                    };
94                }
95                StateParser::Package { start_line } if line.starts_with("name = ") => {
96                    let name_range = create_range(&line);
97                    state = StateParser::Name {
98                        start_line,
99                        package_name: line.replace("name = ", "").replace('"', ""),
100                        name_range,
101                    };
102                }
103                StateParser::Name {
104                    start_line,
105                    package_name,
106                    name_range,
107                } if line.starts_with("version = ") => {
108                    let version_range = create_range(&line);
109                    state = StateParser::Version {
110                        start_line,
111                        package_name,
112                        name_range,
113                        version_range,
114                    };
115                }
116                StateParser::Version {
117                    start_line,
118                    package_name,
119                    name_range,
120                    version_range,
121                } if line.is_empty() => {
122                    let range = TextRange::new((start_line, 0), (line_num, 0));
123                    let package_range = PackageRange {
124                        range,
125                        name_range,
126                        version_range,
127                    };
128                    dependencies.insert(package_name, package_range);
129                    state = StateParser::End;
130                }
131                s => state = s,
132            }
133        }
134        let lockfile = Self {
135            lockfile_path,
136            dependencies,
137        };
138        Ok(lockfile)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use crate::Lockfile;
145    use std::io::Write as _;
146    use test_log::test;
147
148    #[test]
149    fn cargo_lock_parsing() {
150        let lock = r#"
151version = 3
152
153[[package]]
154name = "addr2line"
155version = "0.20.0"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3"
158dependencies = [
159"gimli",
160]
161        "#;
162        let mut cargo_lock = tempfile::NamedTempFile::new().unwrap();
163        write!(cargo_lock, "{}", lock).unwrap();
164
165        let lockfile = Lockfile::try_from(cargo_lock.path()).unwrap();
166        let text_range = lockfile.dependency_range("addr2line");
167        assert_eq!(text_range.start.line, 4);
168        assert_eq!(text_range.start.column, 0);
169        assert_eq!(text_range.end.line, 13);
170        assert_eq!(text_range.end.column, 0);
171    }
172}