Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
name: CI

permissions:
contents: read
contents: read

on:
push:
branches: [ develop ]
branches:
- develop
pull_request:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
fail-fast: false

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
cache: true
cache-dependency-path: Directory.Packages.props
- name: Display OS Information
run: |
echo "Running on: ${{ matrix.os }}"
echo "Runner OS: ${{ runner.os }}"

- name: Restore dependencies
run: dotnet restore ProjGraph.slnx
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
cache: true
cache-dependency-path: Directory.Packages.props

- name: Build
run: dotnet build ProjGraph.slnx --no-restore --configuration Release
- name: Restore dependencies
run: dotnet restore ProjGraph.slnx

- name: Test
run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal
- name: Build
run: dotnet build ProjGraph.slnx --no-restore --configuration Release

- name: Test
run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal
1 change: 0 additions & 1 deletion .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ on:
branches:
- develop


jobs:
build:
name: Build and analyze
Expand Down
55 changes: 0 additions & 55 deletions ProjGraph.sln

This file was deleted.

9 changes: 7 additions & 2 deletions src/ProjGraph.Lib/Parsers/SlnxParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public static IEnumerable<string> GetProjectPaths(string slnxPath)
return doc.Descendants("Project")
.Select(x => x.Attribute("Path")?.Value)
.Where(path => path != null)
.Select(path => Path.GetFullPath(Path.Combine(solutionDir, path!)));
.Select(path =>
{
// Normalize path separators to be platform-appropriate before combining
var normalizedPath = path!.Replace('\\', Path.DirectorySeparatorChar);
return Path.GetFullPath(Path.Combine(solutionDir, normalizedPath));
});
}
}
}
135 changes: 115 additions & 20 deletions src/ProjGraph.Lib/Services/GraphService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ public class GraphService : IGraphService
/// <param name="path">The path to the solution or project file.</param>
/// <returns>A <see cref="SolutionGraph"/> object representing the projects and their dependencies.</returns>
/// <exception cref="ArgumentException">Thrown when the file type is not supported.</exception>
/// <exception cref="FileNotFoundException">Thrown when the specified file does not exist.</exception>
public SolutionGraph BuildGraph(string path)
{
if (!File.Exists(path))
{
throw new FileNotFoundException($"The specified file does not exist: {path}", path);
}

IEnumerable<string> projectFilePaths;

if (path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase))
Expand All @@ -40,27 +46,31 @@ public SolutionGraph BuildGraph(string path)

var projects = new List<Project>();
var dependencies = new List<Dependency>();
var pathToProject = new Dictionary<string, Project>(StringComparer.OrdinalIgnoreCase);
var pathToProject = new Dictionary<string, Project>(new PathEqualityComparer());
var rawDependencies = new List<(string sourcePath, string targetPath)>();

foreach (var projectPath in projectFilePaths)
{
var normalizedPath = Path.GetFullPath(projectPath);
var fullPath = Path.GetFullPath(projectPath);
var normalizedPath = NormalizePath(fullPath);

if (!File.Exists(normalizedPath))
if (!File.Exists(fullPath))
{
continue;
}

try
{
var (project, refs) = ProjectParser.Parse(normalizedPath);
var (project, refs) = ProjectParser.Parse(fullPath);
projects.Add(project);
pathToProject[project.FullPath] = project;

rawDependencies.AddRange(refs
.Select(r => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(normalizedPath)!, r)))
.Select(absoluteRef => (project.FullPath, absoluteRef)));
pathToProject[normalizedPath] = project; // Store by normalized path for lookup

rawDependencies.AddRange(from refPath in refs
select ResolveProjectReferencePath(fullPath, refPath)
into absoluteRefPath
select NormalizePath(absoluteRefPath)
into normalizedRefPath
select (normalizedPath, normalizedRefPath));
}
catch
{
Expand Down Expand Up @@ -97,44 +107,129 @@ public SolutionGraph BuildGraph(string path)
/// </remarks>
private static HashSet<string> DiscoverProjectsRecursively(string rootProjectPath)
{
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var discoveredNormalized = new HashSet<string>(new PathEqualityComparer());
var discoveredFullPaths = new HashSet<string>();
var toProcess = new Queue<string>();

var rootFullPath = Path.GetFullPath(rootProjectPath);
var rootNormalizedPath = NormalizePath(rootFullPath);
toProcess.Enqueue(rootFullPath);
discovered.Add(rootFullPath);
discoveredNormalized.Add(rootNormalizedPath);
discoveredFullPaths.Add(rootFullPath);

while (toProcess.Count > 0)
{
var currentPath = toProcess.Dequeue();
var currentFullPath = toProcess.Dequeue();

if (!File.Exists(currentPath))
if (!File.Exists(currentFullPath))
{
continue;
}

try
{
var (_, refs) = ProjectParser.Parse(currentPath);
var projectDir = Path.GetDirectoryName(currentPath)!;
var (_, refs) = ProjectParser.Parse(currentFullPath);

foreach (var refPath in refs)
{
var absoluteRefPath = Path.GetFullPath(Path.Combine(projectDir, refPath));
var absoluteRefPath = ResolveProjectReferencePath(currentFullPath, refPath);
var normalizedRefPath = NormalizePath(absoluteRefPath);

if (discovered.Add(absoluteRefPath))
if (!discoveredNormalized.Add(normalizedRefPath))
{
toProcess.Enqueue(absoluteRefPath);
continue;
}

discoveredFullPaths.Add(absoluteRefPath);
toProcess.Enqueue(absoluteRefPath);
}
}
catch (Exception ex)
{
// Log but continue - don't let one bad project stop the whole analysis
Debug.WriteLine($"Failed to parse project {currentPath}: {ex.Message}");
Debug.WriteLine($"Failed to parse project {currentFullPath}: {ex.Message}");
}
}

return discoveredFullPaths;
}

/// <summary>
/// Normalizes a path to ensure consistent comparison across platforms.
/// Replaces backslashes with forward slashes for consistency.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
private static string NormalizePath(string path)
{
// Replace backslashes with forward slashes for consistency across platforms
return path.Replace('\\', '/');
}

/// <summary>
/// Resolves a project reference path relative to a project file path.
/// </summary>
/// <param name="projectPath">The full path to the project file.</param>
/// <param name="referencePath">The relative path to the referenced project.</param>
/// <returns>The absolute path to the referenced project.</returns>
private static string ResolveProjectReferencePath(string projectPath, string referencePath)
{
var projectDir = Path.GetDirectoryName(projectPath) ?? string.Empty;
// Normalize path separators to be platform-appropriate before combining
var normalizedReferencePath = referencePath.Replace('\\', Path.DirectorySeparatorChar);
var combinedPath = Path.Combine(projectDir, normalizedReferencePath);
return Path.GetFullPath(combinedPath);
}

/// <summary>
/// Equality comparer for file paths that handles cross-platform path comparison.
/// Normalizes paths to use forward slashes and applies case-insensitive comparison on Windows.
/// </summary>
private sealed class PathEqualityComparer : IEqualityComparer<string>
{
/// <summary>
/// Determines whether two file paths are equal, taking into account platform-specific
/// case sensitivity and ensuring paths are normalized for comparison.
/// </summary>
/// <param name="x">The first file path to compare.</param>
/// <param name="y">The second file path to compare.</param>
/// <returns>
/// True if the specified file paths are considered equal; otherwise, false.
/// </returns>
public bool Equals(string? x, string? y)
{
if (x == null && y == null)
{
return true;
}

if (x == null || y == null)
{
return false;
}

var normalizedX = NormalizePath(x);
var normalizedY = NormalizePath(y);

return string.Equals(normalizedX, normalizedY,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}

return discovered;
/// <summary>
/// Computes the hash code for a given file path, ensuring that the path is normalized
/// and taking into account platform-specific case sensitivity.
/// </summary>
/// <param name="obj">The file path for which to compute the hash code.</param>
/// <returns>
/// An integer hash code for the specified file path. On Windows, the hash code is
/// computed in a case-insensitive manner, while on other platforms it is case-sensitive.
/// </returns>
public int GetHashCode(string obj)
{
var normalized = NormalizePath(obj);
return OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase.GetHashCode(normalized)
: StringComparer.Ordinal.GetHashCode(normalized);
}
}
}
Loading