dotnet_lens/
parser.rs

1use spex::{
2    parsing::XmlReader,
3    xml::{Element, XmlDocument},
4};
5use std::{
6    io::{self, Read},
7    path::{Path, PathBuf},
8};
9use thiserror::Error;
10
11use crate::{PackageReference, Project, ProjectLanguage, ProjectReference};
12
13/// Parses a .NET project file and extracts project information.
14///
15/// This function reads the provided .NET project file and extracts its name,
16/// language, target framework, project references, and package references.
17///
18/// # Arguments
19///
20/// * `reader` - A reader that provides the content of the project file.
21/// * `path` - The path to the project file. It can be any type that implements `AsRef<Path>`.
22///
23/// # Returns
24///
25/// This function returns a `Result`:
26/// * `Ok(Project)` - A `Project` struct containing the parsed project information.
27/// * `Err(ParseError)` - An error if the file cannot be parsed or if it is not a valid project file.
28///
29/// # Errors
30///
31/// This function returns a `ParseError` in the following cases:
32/// * If the path is a directory.
33/// * If the file is not a recognized project file type (.csproj, .fsproj, .vbproj).
34/// * If the file does not have a name.
35/// * If there is an error reading or deserializing the file.
36///
37/// # Examples
38///
39/// ```no_run
40/// use dotnet_lens::parser::parse;
41/// use std::fs::File;
42/// use std::path::Path;
43///
44/// let path = Path::new("path/to/project.csproj");
45/// let file = File::open(path).unwrap();
46/// let project = parse(file, path).unwrap();
47/// println!("Parsed project: {:?}", project);
48/// ```
49pub fn parse<R, P>(reader: R, path: P) -> Result<Project, ParseError>
50where
51    R: Read,
52    P: AsRef<Path>,
53{
54    let path = path.as_ref();
55    if path.is_dir() {
56        return Err(ParseError::PathIsNotAFile);
57    }
58
59    let language = path.extension().and_then(ProjectLanguage::from_extension);
60    if language.is_none() {
61        return Err(ParseError::FileIsNotAProject);
62    }
63
64    let name = Project::get_project_name(path).ok_or(ParseError::FileDoesNotHaveAName)?;
65
66    let mut project = Project {
67        name,
68        language: language.unwrap(),
69        path: path.to_owned(),
70        target_framework: None,
71        project_references: vec![],
72        package_references: vec![],
73    };
74
75    fill_project_based_on_xml(&mut project, XmlReader::parse_auto(reader)?)?;
76
77    Ok(project)
78}
79
80fn fill_project_based_on_xml(
81    project: &mut Project,
82    document: XmlDocument,
83) -> Result<(), ParseError> {
84    for element in document.root().elements() {
85        match element.name().local_part() {
86            "PropertyGroup" => handle_property_group(project, element)?,
87            "ItemGroup" => handle_item_group(project, element)?,
88            _ => (),
89        }
90    }
91
92    Ok(())
93}
94
95fn handle_property_group(project: &mut Project, element: &Element) -> Result<(), ParseError> {
96    // currently, the target framework is the only information that we look in the
97    // PropertyGroup tag
98    if project.target_framework.is_some() {
99        return Ok(());
100    }
101
102    project.target_framework = element
103        .opt("TargetFramework")
104        .text()?
105        .map(|target| target.to_string());
106
107    Ok(())
108}
109
110fn handle_item_group(project: &mut Project, element: &Element) -> Result<(), ParseError> {
111    for item in element.elements() {
112        match item.name().local_part() {
113            "ProjectReference" => {
114                let attr_content = item
115                    .att_req("Include")
116                    .map_err(|_| ParseError::DeserializationError)?
117                    .replace("\\", "/");
118
119                let path = PathBuf::from(attr_content);
120
121                let name =
122                    Project::get_project_name(&path).ok_or(ParseError::FileDoesNotHaveAName)?;
123
124                project
125                    .project_references
126                    .push(ProjectReference::new(name, path));
127            }
128            "PackageReference" => {
129                let name = item
130                    .att_req("Include")
131                    .map_err(|_| ParseError::DeserializationError)?
132                    .to_string();
133
134                let version = item
135                    .att_req("Version")
136                    .map_err(|_| ParseError::DeserializationError)?
137                    .to_string();
138
139                project
140                    .package_references
141                    .push(PackageReference::new(name, version));
142            }
143            _ => (),
144        }
145    }
146
147    Ok(())
148}
149
150/// Represents errors that can occur during project file parsing.
151#[derive(Debug, Error)]
152pub enum ParseError {
153    /// An error occurred during deserialization.
154    #[error("there was an error while deserializing the file")]
155    DeserializationError,
156    /// An I/O error occurred while reading the file.
157    #[error("there was an error while reading the file")]
158    IoError(#[from] io::Error),
159    /// The provided path is a directory, not a file.
160    #[error("the path is a directory")]
161    PathIsNotAFile,
162    /// The file is not a recognized project file type (.csproj, .fsproj, .vbproj).
163    #[error("the file is not a project (.csproj, .fsproj, .vbproj)")]
164    FileIsNotAProject,
165    /// The file does not have a name.
166    #[error("the file does not have a name")]
167    FileDoesNotHaveAName,
168}
169
170impl From<spex::common::XmlError> for ParseError {
171    fn from(_: spex::common::XmlError) -> Self {
172        Self::DeserializationError
173    }
174}
175
176#[cfg(test)]
177mod test {
178    use std::path::PathBuf;
179
180    use io::Cursor;
181
182    use crate::PackageReference;
183
184    use super::*;
185
186    #[test]
187    pub fn parse_valid_csproj() {
188        // given
189        let content = r#"
190<Project Sdk="Microsoft.NET.Sdk">
191
192  <PropertyGroup>
193    <OutputType>Exe</OutputType>
194    <TargetFramework>net8.0</TargetFramework>
195    <ImplicitUsings>enable</ImplicitUsings>
196  </PropertyGroup>
197
198  <PropertyGroup>
199    <Nullable>enable</Nullable>
200  </PropertyGroup>
201
202  <ItemGroup>
203    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
204  </ItemGroup>
205
206  <ItemGroup>
207    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
208  </ItemGroup>
209
210  <ItemGroup>
211    <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
212  </ItemGroup>
213
214</Project>
215"#;
216
217        let project_path: &Path = "./TestProject.csproj".as_ref();
218
219        // when
220        let parsed_project = parse(Cursor::new(content), project_path).unwrap();
221
222        // then
223        let expected_project = Project {
224            name: "TestProject".to_string(),
225            path: PathBuf::from(project_path),
226            language: ProjectLanguage::CSharp,
227            target_framework: Some("net8.0".to_string()),
228            project_references: vec![ProjectReference {
229                name: "FsharpConsole".to_string(),
230                path: PathBuf::from("../FsharpConsole/FsharpConsole.fsproj"),
231            }],
232            package_references: vec![
233                PackageReference {
234                    name: "Microsoft.Extensions.Configuration".to_string(),
235                    version: "8.0.0".to_string(),
236                },
237                PackageReference {
238                    name: "Microsoft.Extensions.Hosting".to_string(),
239                    version: "8.0.0".to_string(),
240                },
241            ],
242        };
243
244        assert_eq!(parsed_project, expected_project);
245    }
246
247    #[test]
248    pub fn parse_valid_fsproj() {
249        // given
250        let content = r#"
251<Project Sdk="Microsoft.NET.Sdk">
252
253  <PropertyGroup>
254    <OutputType>Exe</OutputType>
255    <TargetFramework>net8.0</TargetFramework>
256  </PropertyGroup>
257
258  <ItemGroup>
259    <Compile Include="Program.fs" />
260  </ItemGroup>
261
262  <ItemGroup>
263    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
264  </ItemGroup>
265
266  <ItemGroup>
267    <ProjectReference Include="..\VbConsole\VbConsole.vbproj" />
268  </ItemGroup>
269
270</Project>
271"#;
272
273        let project_path: &Path = "./TestProject.fsproj".as_ref();
274
275        // when
276        let parsed_project = parse(Cursor::new(content), project_path).unwrap();
277
278        // then
279        let expected_project = Project {
280            name: "TestProject".to_string(),
281            path: PathBuf::from(project_path),
282            language: ProjectLanguage::FSharp,
283            target_framework: Some("net8.0".to_string()),
284            project_references: vec![ProjectReference {
285                name: "VbConsole".to_string(),
286                path: PathBuf::from("../VbConsole/VbConsole.vbproj"),
287            }],
288            package_references: vec![PackageReference {
289                name: "Microsoft.Extensions.Configuration".to_string(),
290                version: "8.0.0".to_string(),
291            }],
292        };
293
294        assert_eq!(parsed_project, expected_project);
295    }
296
297    #[test]
298    pub fn parse_valid_vbproj() {
299        // given
300        let content = r#"
301<Project Sdk="Microsoft.NET.Sdk">
302
303  <PropertyGroup>
304    <OutputType>Exe</OutputType>
305    <RootNamespace>VbConsole</RootNamespace>
306    <TargetFramework>net8.0</TargetFramework>
307  </PropertyGroup>
308
309  <ItemGroup>
310    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
311  </ItemGroup>
312
313  <ItemGroup>
314    <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
315  </ItemGroup>
316
317</Project>
318"#;
319
320        let project_path: &Path = "./TestProject.vbproj".as_ref();
321
322        // when
323        let parsed_project = parse(Cursor::new(content), project_path).unwrap();
324
325        // then
326        let expected_project = Project {
327            name: "TestProject".to_string(),
328            path: PathBuf::from(project_path),
329            language: ProjectLanguage::VB,
330            target_framework: Some("net8.0".to_string()),
331            project_references: vec![ProjectReference {
332                name: "FsharpConsole".to_string(),
333                path: PathBuf::from("../FsharpConsole/FsharpConsole.fsproj"),
334            }],
335            package_references: vec![PackageReference {
336                name: "Microsoft.Extensions.Configuration".to_string(),
337                version: "8.0.0".to_string(),
338            }],
339        };
340
341        assert_eq!(parsed_project, expected_project);
342    }
343
344    #[test]
345    pub fn invalid_xml() {
346        // given
347        let content = r#"
348<Project Sdk="Microsoft.NET.Sdk">
349
350  <PropertyGroup>
351    <OutputType>Exe</OutputType>
352
353  <ItemGroup>
354    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
355  </ItemGroup>
356
357  <ItemGroup>
358    <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
359  </ItemGroup>
360
361</Project>
362"#;
363
364        let project_path: &Path = "./TestProject.vbproj".as_ref();
365
366        // when
367        let parsed_project = parse(Cursor::new(content), project_path);
368
369        // then
370        if let Err(error) = parsed_project {
371            assert!(matches!(error, ParseError::IoError(_)));
372
373            return;
374        }
375
376        unreachable!()
377    }
378
379    #[test]
380    pub fn missing_field() {
381        // given
382        let content = r#"
383<Project Sdk="Microsoft.NET.Sdk">
384
385  <ItemGroup>
386    <PackageReference Include="Microsoft.Extensions.Configuration" />
387  </ItemGroup>
388
389  <ItemGroup>
390    <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
391  </ItemGroup>
392
393</Project>
394"#;
395
396        let project_path: &Path = "./TestProject.vbproj".as_ref();
397
398        // when
399        let parsed_project = parse(Cursor::new(content), project_path);
400
401        // then
402        if let Err(error) = parsed_project {
403            assert!(matches!(error, ParseError::DeserializationError));
404
405            return;
406        }
407
408        unreachable!()
409    }
410
411    #[test]
412    pub fn path_is_not_a_file() {
413        // given
414        let content = "";
415
416        let project_path: &Path = "./".as_ref();
417
418        // when
419        let parsed_project = parse(Cursor::new(content), project_path);
420
421        // then
422        if let Err(error) = parsed_project {
423            assert!(matches!(error, ParseError::PathIsNotAFile));
424
425            return;
426        }
427
428        unreachable!()
429    }
430
431    #[test]
432    pub fn path_is_not_a_project() {
433        // given
434        let content = "";
435
436        let project_path: &Path = "./TestProject.txt".as_ref();
437
438        // when
439        let parsed_project = parse(Cursor::new(content), project_path);
440
441        // then
442        if let Err(error) = parsed_project {
443            assert!(matches!(error, ParseError::FileIsNotAProject));
444
445            return;
446        }
447
448        unreachable!()
449    }
450
451    #[test]
452    pub fn path_does_not_have_a_name() {
453        // given
454        let content = "";
455
456        let project_path: &Path = "./.csproj".as_ref();
457
458        // when
459        let parsed_project = parse(Cursor::new(content), project_path);
460
461        // then
462        if let Err(error) = parsed_project {
463            assert!(matches!(error, ParseError::FileIsNotAProject));
464
465            return;
466        }
467
468        unreachable!()
469    }
470}