solp/
lib.rs

1/*!
2A library for parsing Microsoft Visual Studio solution file
3
4
5## Example: parsing solution from [&str]
6
7```
8use solp::parse_str;
9
10const SOLUTION: &str = r#"
11Microsoft Visual Studio Solution File, Format Version 12.00
12Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bench", "bench\bench.csproj", "{A61CD222-0F3B-47B6-9F7F-25D658368EEC}"
13EndProject
14Global
15    GlobalSection(SolutionConfigurationPlatforms) = preSolution
16        Debug|Any CPU = Debug|Any CPU
17        Release|Any CPU = Release|Any CPU
18    EndGlobalSection
19    GlobalSection(ProjectConfigurationPlatforms) = postSolution
20        {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21        {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
22        {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
23        {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Release|Any CPU.Build.0 = Release|Any CPU
24    EndGlobalSection
25EndGlobal
26"#;
27
28let result = parse_str(SOLUTION);
29assert!(result.is_ok());
30let solution = result.unwrap();
31assert_eq!(solution.projects.len(), 1);
32assert_eq!(solution.configurations.len(), 2);
33assert_eq!(solution.format, "12.00");
34
35```
36*/
37
38#![warn(unused_extern_crates)]
39#![allow(clippy::missing_errors_doc)]
40use std::fs;
41
42use api::Solution;
43use jwalk::{Parallelism, WalkDir};
44use miette::{Context, IntoDiagnostic};
45
46pub mod api;
47mod ast;
48mod lex;
49pub mod msbuild;
50mod parser;
51
52#[macro_use]
53extern crate lalrpop_util;
54
55#[cfg(test)] // <-- not needed in integration tests
56extern crate rstest;
57
58lalrpop_mod!(
59    #[allow(clippy::all)]
60    #[allow(unused)]
61    #[allow(clippy::no_effect_underscore_binding)]
62    #[allow(clippy::trivially_copy_pass_by_ref)]
63    #[allow(clippy::cloned_instead_of_copied)]
64    #[allow(clippy::cast_sign_loss)]
65    #[allow(clippy::too_many_lines)]
66    #[allow(clippy::match_same_arms)]
67    #[allow(clippy::uninlined_format_args)]
68    #[allow(clippy::unused_self)]
69    #[allow(clippy::needless_raw_string_hashes)]
70    solp
71);
72
73/// Default Visual Studio solution file extension
74pub const DEFAULT_SOLUTION_EXT: &str = "sln";
75
76/// Consume provides parsed [`Solution`] consumer
77pub trait Consume {
78    /// Called in case of success parsing
79    fn ok(&mut self, solution: &Solution);
80    /// Called on error
81    fn err(&self, path: &str);
82}
83
84/// Builder for walking a directory structure.
85pub struct SolpWalker<'a, C: Consume> {
86    /// [`Consume`] trait instance that will be applied to each file scanned
87    pub consumer: C,
88    extension: &'a str,
89    show_errors: bool,
90    recursively: bool,
91}
92
93/// Parses a solution file at the specified path and notifies the consumer of the result.
94///
95/// This function reads the content of the file at the given path and attempts to parse it
96/// as a Microsoft Visual Studio solution file. If the file is successfully read and parsed,
97/// the consumer's `ok` method is called with the parsed `Solution`. If any errors occur during
98/// reading or parsing, the consumer's `err` method is called with the path of the file, and an
99/// error is returned.
100///
101/// # Parameters
102///
103/// - `path`: A string slice that holds the path to the solution file.
104/// - `consumer`: A mutable reference to an object that implements the `Consume` trait. This consumer
105///   will be notified of the result of the parse operation.
106///
107/// # Returns
108///
109/// A `Result` which is `Ok(())` if the file was successfully read and parsed, or an error if any
110/// issues occurred during reading or parsing.
111///
112/// # Errors
113///
114/// This function will return an error if the file cannot be read or if the content cannot be parsed
115/// as a valid solution file. In both cases, the consumer's `err` method will be called with the path
116/// of the file.
117///
118/// # Example
119///
120/// ```rust
121/// use solp::parse_file;
122/// use solp::api::Solution;
123/// use solp::Consume;
124///
125/// struct Consumer;
126///
127/// impl Consume for Consumer {
128///   fn ok(&mut self, solution: &Solution) {
129///      // ...
130///   }
131///
132///   fn err(&self, path: &str) {
133///      // ...
134///   }
135/// }
136///
137/// let path = "path/to/solution.sln";
138/// let mut consumer = Consumer{};
139/// match parse_file(path, &mut consumer) {
140///     Ok(()) => println!("Successfully parsed the solution file."),
141///     Err(e) => eprintln!("Failed to parse the solution file: {:?}", e),
142/// }
143/// ```
144pub fn parse_file(path: &str, consumer: &mut dyn Consume) -> miette::Result<()> {
145    let contents = fs::read_to_string(path)
146        .into_diagnostic()
147        .wrap_err_with(|| {
148            consumer.err(path);
149            format!("Failed to read content from path: {path}")
150        })?;
151    let mut solution = parse_str(&contents).wrap_err_with(|| {
152        consumer.err(path);
153        format!("Failed to parse solution from path: {path}")
154    })?;
155
156    solution.path = path;
157    consumer.ok(&solution);
158    Ok(())
159}
160
161/// Parses a solution file content from a string slice and returns a [`Solution`] object.
162///
163/// This function takes the content of a solution file as a string slice, attempts to parse it,
164/// and returns a `Solution` object representing the parsed content. If parsing fails, an error
165/// is returned.
166///
167/// # Parameters
168///
169/// - `contents`: A string slice that holds the content of the solution file to be parsed.
170///
171/// # Returns
172///
173/// A `Result` containing a [`Solution`] object if parsing is successful, or an error if parsing fails.
174///
175/// # Errors
176///
177/// This function will return an error if the content cannot be parsed as a valid solution file.
178///
179/// # Example
180///
181/// ```rust
182/// use solp::parse_str;
183///
184/// let solution_content = r#"
185/// Microsoft Visual Studio Solution File, Format Version 12.00
186/// # Visual Studio 16
187/// VisualStudioVersion = 16.0.28701.123
188/// MinimumVisualStudioVersion = 10.0.40219.1
189/// Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyProject", "MyProject.csproj", "{A61CD222-0F3B-47B6-9F7F-25D658368EEC}"
190/// EndProject
191/// Global
192///     GlobalSection(SolutionConfigurationPlatforms) = preSolution
193///         Debug|Any CPU = Debug|Any CPU
194///         Release|Any CPU = Release|Any CPU
195///     EndGlobalSection
196///     GlobalSection(ProjectConfigurationPlatforms) = postSolution
197///         {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
198///         {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
199///         {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
200///         {A61CD222-0F3B-47B6-9F7F-25D658368EEC}.Release|Any CPU.Build.0 = Release|Any CPU
201///     EndGlobalSection
202/// EndGlobal
203/// "#;
204///
205/// parse_str(solution_content);
206/// // This will return a Result containing a Solution object if parsing is successful.
207/// ```
208///
209/// # Remarks
210///
211/// This function uses the `parser::parse_str` function to perform the actual parsing and then
212/// constructs a [`Solution`] object from the parsed data.
213pub fn parse_str(contents: &str) -> miette::Result<Solution> {
214    let parsed = parser::parse_str(contents)?;
215    Ok(Solution::from(&parsed))
216}
217
218impl<'a, C: Consume> SolpWalker<'a, C> {
219    /// Create a builder for a directory structure parsing.
220    pub fn new(consumer: C) -> Self {
221        Self {
222            consumer,
223            extension: DEFAULT_SOLUTION_EXT,
224            show_errors: false,
225            recursively: false,
226        }
227    }
228
229    /// Setting Visual Studio solution file extension. sln by default.
230    #[must_use]
231    pub fn with_extension(mut self, extension: &'a str) -> Self {
232        self.extension = extension;
233        self
234    }
235
236    /// Scan recursively. Disabled by default.
237    #[must_use]
238    pub fn recursively(mut self, recursively: bool) -> Self {
239        self.recursively = recursively;
240        self
241    }
242
243    /// Whether to show parsing errors during directory scanning. Disabled by default.
244    #[must_use]
245    pub fn show_errors(mut self, show_errors: bool) -> Self {
246        self.show_errors = show_errors;
247        self
248    }
249
250    /// `walk_and_parse` parses directory structure specified by path.
251    /// it finds all files with extension specified and parses them.
252    /// returns the number of scanned solutions
253    ///
254    /// ## Remarks
255    /// Any errors occurred during parsing of found files will be ignored (so parsing won't stopped)
256    /// but error paths will be added into error files list (using err function of [`Consume`] trait)
257    pub fn walk_and_parse(&mut self, path: &str) -> usize {
258        let iter = if self.recursively {
259            let parallelism = Parallelism::RayonNewPool(num_cpus::get_physical());
260            create_dir_iterator(path).parallelism(parallelism)
261        } else {
262            create_dir_iterator(path).max_depth(1)
263        };
264        let ext = self.extension.trim_start_matches('.');
265
266        iter.into_iter()
267            .filter_map(std::result::Result::ok)
268            .filter(|f| f.file_type().is_file())
269            .map(|f| f.path())
270            .filter(|p| p.extension().is_some_and(|s| s == ext))
271            .filter_map(|fp| {
272                let p = fp.to_str()?;
273                if let Err(e) = parse_file(p, &mut self.consumer) {
274                    if self.show_errors {
275                        println!("{e:?}");
276                    }
277                    None
278                } else {
279                    Some(())
280                }
281            })
282            .count()
283    }
284}
285
286fn create_dir_iterator(path: &str) -> WalkDir {
287    let root = decorate_path(path);
288    WalkDir::new(root).skip_hidden(false).follow_links(false)
289}
290
291/// On Windows trailing backslash (\) to be added if volume and colon passed (like c:).
292/// It needed paths look to be more pleasant
293#[cfg(target_os = "windows")]
294fn decorate_path(path: &str) -> String {
295    if path.len() == 2 && path.ends_with(':') {
296        format!("{path}\\")
297    } else {
298        path.to_owned()
299    }
300}
301
302/// On Unix just pass-through as is
303#[cfg(not(target_os = "windows"))]
304fn decorate_path(path: &str) -> String {
305    path.to_owned()
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use rstest::rstest;
312
313    #[cfg(not(target_os = "windows"))]
314    #[rstest]
315    #[case("", "")]
316    #[case("/", "/")]
317    #[case("/home", "/home")]
318    #[case("d:", "d:")]
319    #[trace]
320    fn decorate_path_tests(#[case] raw_path: &str, #[case] expected: &str) {
321        // Arrange
322
323        // Act
324        let actual = decorate_path(raw_path);
325
326        // Assert
327        assert_eq!(actual, expected);
328    }
329
330    #[cfg(target_os = "windows")]
331    #[rstest]
332    #[case("", "")]
333    #[case("/", "/")]
334    #[case("d:", "d:\\")]
335    #[case("dd:", "dd:")]
336    #[trace]
337    fn decorate_path_tests(#[case] raw_path: &str, #[case] expected: &str) {
338        // Arrange
339
340        // Act
341        let actual = decorate_path(raw_path);
342
343        // Assert
344        assert_eq!(actual, expected);
345    }
346}