diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 648fe137..eb0e86bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"BabLoRP_ArchLens" /o:"bablorp" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"BabLoRP_ArchLens" /o:"bablorp" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" dotnet build src/c-sharp/Archlens.sln + dotnet test src/ArchlensTests/ArchlensTests.csproj --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file diff --git a/src/ArchlensTests/Application/DependencyGraphBuilderTests.cs b/src/ArchlensTests/Application/DependencyGraphBuilderTests.cs index a9b25683..c6c9b6ec 100644 --- a/src/ArchlensTests/Application/DependencyGraphBuilderTests.cs +++ b/src/ArchlensTests/Application/DependencyGraphBuilderTests.cs @@ -30,7 +30,7 @@ private void SetupMockProject() Directory.CreateDirectory(Path.Combine(_fs.Root, "Domain", "Utils")); } - private ProjectChanges CreateProjectChanges(IReadOnlyDictionary> changedFilesByDirectory, + private static ProjectChanges CreateProjectChanges(IReadOnlyDictionary> changedFilesByDirectory, IReadOnlyList deletedFiles, IReadOnlyList deletedDirectories) => new(changedFilesByDirectory, deletedFiles, deletedDirectories); @@ -732,7 +732,7 @@ public async Task BuildGraph_DoesNotCallParser_ForDirectoriesOnlyForFiles() Assert.Equal(uFile, parser.Calls[0]); var domainChildren = graph.ChildrenOf(domainDir); - Assert.Single(domainChildren.Where(x => x.Equals(utilsDir))); + Assert.Single(domainChildren, x => x.Equals(utilsDir)); } [Fact] diff --git a/src/ArchlensTests/Domain/RendererBaseTests.cs b/src/ArchlensTests/Domain/RendererBaseTests.cs index 2bb675aa..acd88f3c 100644 --- a/src/ArchlensTests/Domain/RendererBaseTests.cs +++ b/src/ArchlensTests/Domain/RendererBaseTests.cs @@ -6,7 +6,7 @@ namespace ArchlensTests.Domain; -public sealed class RendererBaseTests : IDisposable +public sealed partial class RendererBaseTests : IDisposable { private readonly TestFileSystem _fs = new(); public void Dispose() => _fs.Dispose(); @@ -19,7 +19,7 @@ public sealed class RendererBaseTests : IDisposable ), Format: default, Views: [new View("completeView", [], []), new View("ignoringView", [], ["./Infra/"])], - SaveLocation: null + SaveLocation: $"{_fs.Root}/diagrams" ); private RenderOptions MakeOptions( @@ -33,9 +33,9 @@ private RenderOptions MakeOptions( FullRootPath: _fs.Root), Format: default, Views: [new View(viewName, packages ?? [], ignore ?? [])], - SaveLocation: saveLocation); + SaveLocation: saveLocation ?? $"{_fs.Root}/diagrams"); - private static string Minify(string s) => Regex.Replace(s, @"\s+", ""); + private static string Minify(string s) => StringOneOrMoreRegex().Replace(s, ""); [Fact] @@ -130,9 +130,9 @@ public void JsonRendererRendersDiffCorrectly() Assert.Contains("\"edges\": [", result); Assert.EndsWith("}", result); - result = Regex.Replace(result, @"\s*", ""); - newEdge = Regex.Replace(newEdge, @"\s*", ""); - deletedEdge = Regex.Replace(deletedEdge, @"\s*", ""); + result = StringZeroMoreRegex().Replace(result, ""); + newEdge = StringZeroMoreRegex().Replace(newEdge, ""); + deletedEdge = StringZeroMoreRegex().Replace(deletedEdge, ""); Assert.Contains(newEdge, result); Assert.Contains(deletedEdge, result); @@ -178,7 +178,7 @@ public void EdgesAreOrderedByFromThenTo() var graph = TestDependencyGraph.MakeDependencyGraph(_fs.Root); var result = new JsonRenderer().RenderView(graph, opts.Views[0], opts); - var froms = Regex.Matches(result, @"""fromPackage""\s*:\s*""([^""]+)""") + var froms = FromRegex().Matches(result) .Select(m => m.Groups[1].Value) .ToList(); @@ -283,7 +283,7 @@ public void UnchangedEdgesAreNeutral() var graph = TestDependencyGraph.MakeDependencyGraph(_fs.Root); var result = Minify(new JsonRenderer().RenderDiffView(graph, graph, opts.Views[0], opts)); - Assert.Empty(Regex.Matches(result, @"""state"":""(CREATED|DELETED)""")); + Assert.Empty(StateRegex().Matches(result)); } [Fact] @@ -524,4 +524,13 @@ public void DiffRenderIsDeterministic() new JsonRenderer().RenderDiffView(local, remote, opts.Views[0], opts), new JsonRenderer().RenderDiffView(local, remote, opts.Views[0], opts)); } + + [GeneratedRegex(@"\s+")] + private static partial Regex StringOneOrMoreRegex(); + [GeneratedRegex(@"\s*")] + private static partial Regex StringZeroMoreRegex(); + [GeneratedRegex(@"""fromPackage""\s*:\s*""([^""]+)""")] + private static partial Regex FromRegex(); + [GeneratedRegex(@"""state"":""(CREATED|DELETED)""")] + private static partial Regex StateRegex(); } diff --git a/src/ArchlensTests/Infra/ConfigManagerTests.cs b/src/ArchlensTests/Infra/ConfigManagerTests.cs index e98cca46..2d9fe9bc 100644 --- a/src/ArchlensTests/Infra/ConfigManagerTests.cs +++ b/src/ArchlensTests/Infra/ConfigManagerTests.cs @@ -380,6 +380,7 @@ public async Task RenderOptions_SaveLocation_IsSet() { var path = WriteConfig(new(SaveLocation: "\"diagrams\"")); var (_, _, renderOptions, _) = await Manager(path).LoadAsync(); + Assert.NotNull(renderOptions.SaveLocation); Assert.NotEmpty(renderOptions.SaveLocation); Assert.Contains("diagrams", renderOptions.SaveLocation); } diff --git a/src/ArchlensTests/Infra/Renderers/JsonRendererTests.cs b/src/ArchlensTests/Infra/Renderers/JsonRendererTests.cs index 4d96145d..9e1409cd 100644 --- a/src/ArchlensTests/Infra/Renderers/JsonRendererTests.cs +++ b/src/ArchlensTests/Infra/Renderers/JsonRendererTests.cs @@ -24,15 +24,15 @@ private RenderOptions Opts( FullRootPath: _fs.Root), Format: default, Views: [new View(viewName, packages ?? [], ignore ?? [])], - SaveLocation: null); + SaveLocation: $"{_fs.Root}/diagrams"); - private JsonObject ParseJson(string json) => + private static JsonObject ParseJson(string json) => JsonNode.Parse(json)!.AsObject(); - private JsonArray Packages(JsonObject root) => + private static JsonArray Packages(JsonObject root) => root["packages"]!.AsArray(); - private JsonArray Edges(JsonObject root) => + private static JsonArray Edges(JsonObject root) => root["edges"]!.AsArray(); private ProjectDependencyGraph DefaultGraph() => diff --git a/src/ArchlensTests/Infra/Renderers/PlantUMLRendererTests.cs b/src/ArchlensTests/Infra/Renderers/PlantUMLRendererTests.cs index 11183e7d..05ae67aa 100644 --- a/src/ArchlensTests/Infra/Renderers/PlantUMLRendererTests.cs +++ b/src/ArchlensTests/Infra/Renderers/PlantUMLRendererTests.cs @@ -22,7 +22,7 @@ private RenderOptions Opts( FullRootPath: _fs.Root), Format: default, Views: [new View(viewName, packages ?? [], ignore ?? [])], - SaveLocation: null); + SaveLocation: $"{_fs.Root}/diagrams"); private ProjectDependencyGraph DefaultGraph() => TestDependencyGraph.MakeDependencyGraph(_fs.Root); @@ -36,8 +36,10 @@ private string RenderDiff( RenderOptions opts) => _renderer.RenderDiffView(local, remote, opts.Views[0], opts); +#pragma warning disable CA1859 // Use concrete types when possible for improved performance private static IReadOnlyList Lines(string output) => - output.Split('\n').Select(l => l.TrimEnd('\r')).ToList(); + [.. output.Split('\n').Select(l => l.TrimEnd('\r'))]; +#pragma warning restore CA1859 // Use concrete types when possible for improved performance [Fact] public void FileExtension_IsPuml() diff --git a/src/ArchlensTests/Infra/SnapshotManagers/LocalSnaphotManagerTests.cs b/src/ArchlensTests/Infra/SnapshotManagers/LocalSnaphotManagerTests.cs index 69cdb3d2..92241638 100644 --- a/src/ArchlensTests/Infra/SnapshotManagers/LocalSnaphotManagerTests.cs +++ b/src/ArchlensTests/Infra/SnapshotManagers/LocalSnaphotManagerTests.cs @@ -94,7 +94,11 @@ public async Task SaveThenLoad_Get_Name_And_LastWriteTime() var enums = RelativePath.Directory(rootPath, "./Domain/Models/Enums/"); var utils = RelativePath.Directory(rootPath, "./Domain/Utils/"); - var loadedItems = loaded.ProjectItems; + var loadedItems = loaded?.ProjectItems; + + if (loadedItems is null) + Assert.Fail("Loaded items is null"); + Assert.Contains(root, loadedItems); Assert.Contains(application, loadedItems); Assert.Contains(infra, loadedItems); @@ -106,7 +110,7 @@ public async Task SaveThenLoad_Get_Name_And_LastWriteTime() Assert.Contains(enums, loadedItems); Assert.Contains(utils, loadedItems); - Assert.Equal(graph.GetProjectItem(root).LastWriteTime.ToString("dd-MM-yyyy HH:mm:ss"), loaded.GetProjectItem(root).LastWriteTime.ToString("dd-MM-yyyy HH:mm:ss")); + Assert.Equal(graph?.GetProjectItem(root)?.LastWriteTime.ToString("dd-MM-yyyy HH:mm:ss"), loaded?.GetProjectItem(root)?.LastWriteTime.ToString("dd-MM-yyyy HH:mm:ss")); } [Fact] @@ -148,7 +152,7 @@ public async Task Load_ReturnsGraph_WhenFilePresent() var loaded = await snapshotManager.GetLastSavedDependencyGraphAsync(opts); - Assert.Equal(graph.ProjectItems, loaded.ProjectItems); + Assert.Equal(graph.ProjectItems, loaded?.ProjectItems); } [Fact] @@ -163,13 +167,13 @@ public async Task Load_ReturnsMultiLevelGraph_WhenPresent() var loaded = await snapshotManager.GetLastSavedDependencyGraphAsync(opts); - Assert.Equal(graph.ProjectItems, loaded.ProjectItems); + Assert.Equal(graph.ProjectItems, loaded?.ProjectItems); var rootPath = RelativePath.Directory(root, "./"); - Assert.Equal(graph.ChildrenOf(rootPath).Count, loaded.ChildrenOf(rootPath).Count); + Assert.Equal(graph.ChildrenOf(rootPath).Count, loaded?.ChildrenOf(rootPath).Count); var domainPath = RelativePath.Directory(root, "./Domain/"); - var domain = loaded.GetProjectItem(domainPath); + var domain = loaded?.GetProjectItem(domainPath); Assert.NotNull(domain); Assert.Equal(3, graph.ChildrenOf(domainPath).Count); diff --git a/src/ArchlensTests/Utils/TestFileSystem.cs b/src/ArchlensTests/Utils/TestFileSystem.cs index e1c37569..32d9946d 100644 --- a/src/ArchlensTests/Utils/TestFileSystem.cs +++ b/src/ArchlensTests/Utils/TestFileSystem.cs @@ -30,7 +30,10 @@ public string File(string relPath, string contents = "", DateTime? lastWriteUtc return abs; } +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { try { Directory.Delete(Root, recursive: true); } catch { /* ignore */ } } diff --git a/src/c-sharp/Application/ChangeDetector.cs b/src/c-sharp/Application/ChangeDetector.cs index 65ec8dff..75f852e4 100644 --- a/src/c-sharp/Application/ChangeDetector.cs +++ b/src/c-sharp/Application/ChangeDetector.cs @@ -80,6 +80,16 @@ private static Dictionary> BuildDeltaStructure( ProjectFileStructure current, ProjectDependencyGraph lastSavedGraph, CancellationToken ct) + { + var changedFiles = CollectChangedFiles(current, lastSavedGraph, ct); + var neededDirs = CollectNeededDirs(projectRoot, current, changedFiles, lastSavedGraph, ct); + return BuildDelta(current, neededDirs, changedFiles, lastSavedGraph, ct); + } + + private static HashSet CollectChangedFiles( + ProjectFileStructure current, + ProjectDependencyGraph lastSavedGraph, + CancellationToken ct) { HashSet changedFiles = []; @@ -88,23 +98,27 @@ private static Dictionary> BuildDeltaStructure( ct.ThrowIfCancellationRequested(); var lastItem = lastSavedGraph.GetProjectItem(fileRel); - if (lastItem is null) - { - changedFiles.Add(fileRel); - continue; - } + var isNew = lastItem is null; + var isModified = !isNew && TrimMilliseconds(meta.LastWriteUtc) > TrimMilliseconds(lastItem!.LastWriteTime); - if (TrimMilliseconds(meta.LastWriteUtc) > TrimMilliseconds(lastItem.LastWriteTime)) + if (isNew || isModified) changedFiles.Add(fileRel); } + return changedFiles; + } + + private static HashSet CollectNeededDirs( + string projectRoot, + ProjectFileStructure current, + HashSet changedFiles, + ProjectDependencyGraph lastSavedGraph, + CancellationToken ct) + { HashSet neededDirs = []; foreach (var file in changedFiles) - { - var parent = current.Files[file].ParentDirRel; - AddDirAndAncestors(projectRoot, parent, neededDirs, lastSavedGraph, ct); - } + AddDirAndAncestors(projectRoot, current.Files[file].ParentDirRel, neededDirs, lastSavedGraph, ct); foreach (var dir in current.DirRels) { @@ -113,46 +127,52 @@ private static Dictionary> BuildDeltaStructure( AddDirAndAncestors(projectRoot, dir, neededDirs, lastSavedGraph, ct); } + return neededDirs; + } + + private static Dictionary> BuildDelta( + ProjectFileStructure current, + HashSet neededDirs, + HashSet changedFiles, + ProjectDependencyGraph lastSavedGraph, + CancellationToken ct) + { var delta = new Dictionary>(); foreach (var dir in neededDirs) { ct.ThrowIfCancellationRequested(); - var isNewDir = !lastSavedGraph.ContainsProjectItem(dir); - if (!current.ChildrenByDir.TryGetValue(dir, out var children)) { - if (isNewDir) + if (!lastSavedGraph.ContainsProjectItem(dir)) delta[dir] = []; continue; } - var list = new List(); + var relevantChildren = children + .Where(child => IsChildRelevant(child, current.DirRels, neededDirs, changedFiles, lastSavedGraph)) + .ToList(); - foreach (var child in children) - { - var childItemIsDir = current.DirRels.Contains(child); - - if (childItemIsDir) - { - if (neededDirs.Contains(child) || !lastSavedGraph.ContainsProjectItem(child)) - list.Add(child); - } - else - { - if (changedFiles.Contains(child)) - list.Add(child); - } - } - - if (list.Count > 0) - delta[dir] = list; + if (relevantChildren.Count > 0) + delta[dir] = relevantChildren; } return delta; } + private static bool IsChildRelevant( + RelativePath child, + HashSet currentDirRels, + HashSet neededDirs, + HashSet changedFiles, + ProjectDependencyGraph lastSavedGraph) + { + return currentDirRels.Contains(child) + ? neededDirs.Contains(child) || !lastSavedGraph.ContainsProjectItem(child) + : changedFiles.Contains(child); + } + private static void AddDirAndAncestors( string projectRoot, RelativePath dir, @@ -198,40 +218,11 @@ private static ExclusionRule CompileExclusions(IReadOnlyList exclusions) foreach (var entry in exclusions) { - var exclusion = (entry ?? string.Empty).Trim(); - if (exclusion.Length == 0) continue; - - if (exclusion.StartsWith("**/", StringComparison.Ordinal)) exclusion = exclusion[3..]; - - var norm = exclusion.Replace('\\', '/'); - if (norm.EndsWith('.')) norm = norm[..^1]; - - // relative path with trailing '/' -> dir - if (norm.EndsWith('/')) - { - var p = norm; - if (p.StartsWith("./", StringComparison.Ordinal)) p = p[2..]; - if (!p.EndsWith('/')) p += "/"; - dirPrefixes.Add(p); - continue; - } - - // a relative path without trailing '/' -> dir - if (norm.Contains('/')) - { - var p = norm; - if (!p.EndsWith('/')) p += "/"; - dirPrefixes.Add(p); - continue; - } - - // Filename wildcard like "*.dev.cs" -> suffix on filename - if (norm.StartsWith("*.", StringComparison.Ordinal)) - { - suffixes.Add(norm[1..]); - continue; - } + var norm = NormaliseExclusionEntry(entry); + if (norm is null) continue; + if (ToDirPrefix(norm) is { } prefix) { dirPrefixes.Add(prefix); continue; } + if (norm.StartsWith("*.", StringComparison.Ordinal)) { suffixes.Add(norm[1..]); continue; } segments.Add(norm); } @@ -242,6 +233,35 @@ private static ExclusionRule CompileExclusions(IReadOnlyList exclusions) ); } + // Returns null for blank/empty entries; otherwise strips **/ prefix, normalises slashes, and trims trailing dot. + private static string? NormaliseExclusionEntry(string? entry) + { + var exclusion = (entry ?? string.Empty).Trim(); + if (exclusion.Length == 0) return null; + + if (exclusion.StartsWith("**/", StringComparison.Ordinal)) exclusion = exclusion[3..]; + + var norm = exclusion.Replace('\\', '/'); + return norm.EndsWith('.') ? norm[..^1] : norm; + } + + // Returns the canonical dir-prefix form when norm represents a directory pattern, otherwise null. + private static string? ToDirPrefix(string norm) + { + // relative path with trailing '/' -> dir + if (norm.EndsWith('/')) + { + var p = norm.StartsWith("./", StringComparison.Ordinal) ? norm[2..] : norm; + return p.EndsWith('/') ? p : p + "/"; + } + + // relative path containing '/' but no trailing slash -> dir + if (norm.Contains('/')) + return norm + "/"; + + return null; + } + private static ProjectFileStructure ScanCurrentProjectFileStructure( string projectRoot, IReadOnlyList extensions, @@ -271,8 +291,8 @@ void ScanDirectory(string dirAbs) string[] subdirs = []; string[] fileAbsList = []; - try { subdirs = Directory.GetDirectories(dirAbs); } catch { } - try { fileAbsList = Directory.GetFiles(dirAbs); } catch { } + try { subdirs = Directory.GetDirectories(dirAbs); } catch { /* ignore */} + try { fileAbsList = Directory.GetFiles(dirAbs); } catch { /* ignore */} foreach (var fileAbs in fileAbsList) { @@ -314,7 +334,10 @@ private static (List deletedFilesRel, List deletedDi string projectRoot, ProjectDependencyGraph? lastSavedGraph, IReadOnlyDictionary currentFiles, +#pragma warning disable CA1859 // Use concrete types when possible for improved performance IReadOnlySet currentDirs, +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + ExclusionRule rules, CancellationToken ct) { @@ -335,16 +358,10 @@ private static (List deletedFilesRel, List deletedDi if (IsExcluded(projectRoot, absPath, rules)) continue; - if (item.Type == ProjectItemType.File) - { - if (!currentFiles.ContainsKey(item.Path)) - deletedFiles.Add(item.Path); - } - else - { - if (!currentDirs.Contains(item.Path)) - deletedDirs.Add(item.Path); - } + var isFile = item.Type == ProjectItemType.File; + var isDeleted = isFile ? !currentFiles.ContainsKey(item.Path) : !currentDirs.Contains(item.Path); + if (isDeleted) + (isFile ? deletedFiles : deletedDirs).Add(item.Path); } return (deletedFiles, deletedDirs); } @@ -361,16 +378,7 @@ private static List CollapseDeletedDirectories(IEnumerable d.Value.StartsWith(parent.Value, StringComparison.OrdinalIgnoreCase))) kept.Add(d); } return kept; @@ -378,15 +386,13 @@ private static List CollapseDeletedDirectories(IEnumerable deletedDirsRel) { - foreach (var deletedDir in deletedDirsRel) - { - if (fileRel.Value.StartsWith(deletedDir.Value, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; + return deletedDirsRel.Any(deletedDir => fileRel.Value.StartsWith(deletedDir.Value, StringComparison.OrdinalIgnoreCase)); } +#pragma warning disable CA1859 // Use concrete types when possible for improved performance private static IReadOnlyDictionary> FreezeChanged( +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + Dictionary> changed) { return changed.ToDictionary( @@ -398,19 +404,20 @@ private static IReadOnlyDictionary> Fr private static bool IsExcluded(string projectRoot, string content, ExclusionRule rules) { var path = GetRelative(projectRoot, content); + var pathSeparater = '/'; + var pathWithSlash = path + pathSeparater; + var pathWithBothSlashes = pathSeparater + path + pathSeparater; - // Plain loops — this is called for every file and directory during the scan, - // so avoiding LINQ enumerator allocations per call matters. - var pathWithSlash = path + '/'; - var pathWithBothSlashes = '/' + path + '/'; + // Do not change to linq - this is called on every file in a project and linq would allocate too much space on large systems foreach (var rule in rules.DirPrefixes) { if (pathWithSlash.StartsWith(rule, StringComparison.OrdinalIgnoreCase) - || pathWithBothSlashes.Contains('/' + rule, StringComparison.OrdinalIgnoreCase)) + || pathWithBothSlashes.Contains(pathSeparater + rule, StringComparison.OrdinalIgnoreCase)) return true; } - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var segments = path.Split(pathSeparater, StringSplitOptions.RemoveEmptyEntries); + // Do not change to linq - this is called on every file in a project and linq would allocate too much space on large systems foreach (var segment in segments) { foreach (var ban in rules.Segments) @@ -421,6 +428,7 @@ private static bool IsExcluded(string projectRoot, string content, ExclusionRule } var fileName = Path.GetFileName(path); + // Do not change to linq - this is called on every file in a project and linq would allocate too much space on large systems foreach (var suf in rules.FileSuffixes) { if (fileName.EndsWith(suf, StringComparison.OrdinalIgnoreCase)) diff --git a/src/c-sharp/Application/DependencyGraphBuilder.cs b/src/c-sharp/Application/DependencyGraphBuilder.cs index 95128a8a..10395c0a 100644 --- a/src/c-sharp/Application/DependencyGraphBuilder.cs +++ b/src/c-sharp/Application/DependencyGraphBuilder.cs @@ -37,85 +37,104 @@ private async Task BuildGraphAsync( var root = RelativePath.Directory(rootFull, rootFull); var graph = new ProjectDependencyGraph(rootFull); + var fileItems = CollectFileItems(changedModules, root, graph, ct); + var parsedDeps = await ParseAllAsync(fileItems, ct); + + foreach (var (parent, item, deps) in parsedDeps) + { + graph.UpsertProjectItem(item, ProjectItemType.File); + graph.AddChild(parent, item); + graph.SetDependencies(item, deps); + } + + return graph; + } + + private List<(RelativePath Parent, RelativePath Item, string AbsPath)> CollectFileItems( + IReadOnlyDictionary> changedModules, + RelativePath root, + ProjectDependencyGraph graph, + CancellationToken ct) + { var fileItems = new List<(RelativePath Parent, RelativePath Item, string AbsPath)>(); foreach (var (parent, items) in changedModules) { ct.ThrowIfCancellationRequested(); - graph.UpsertProjectItem(parent, ProjectItemType.Directory); - foreach (var item in items) +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + TryClassifyItem(item, parent, root, graph, fileItems, ct); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + + } + + return fileItems; + } + + private async Task TryClassifyItem( + RelativePath item, + RelativePath parent, + RelativePath root, + ProjectDependencyGraph graph, + List<(RelativePath Parent, RelativePath Item, string AbsPath)> fileItems, + CancellationToken ct) + { + try + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(item.Value) || item.Value.Trim() == root.Value) + return; + + var absPath = PathNormaliser.GetAbsolutePath(_options.FullRootPath, item.Value); + + if (Path.GetExtension(absPath).Length == 0) + { + graph.UpsertProjectItem(item, ProjectItemType.Directory); + graph.AddChild(parent, item); + } + else { - try - { - ct.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(item.Value) || item.Value.Trim() == root.Value) - continue; - - var itemAbsPath = PathNormaliser.GetAbsolutePath(rootFull, item.Value); - - if (Path.GetExtension(itemAbsPath).Length == 0) - { - graph.UpsertProjectItem(item, ProjectItemType.Directory); - graph.AddChild(parent, item); - } - else - { - fileItems.Add((parent, item, itemAbsPath)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error while processing '{item.Value}': {ex}"); - } + fileItems.Add((parent, item, absPath)); } } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { await Console.Error.WriteLineAsync($"Error while processing '{item.Value}': {ex}"); } + } - var parsedDeps = new (RelativePath Parent, RelativePath Item, IReadOnlyList Deps)?[fileItems.Count]; + private async Task Deps)>> ParseAllAsync( + List<(RelativePath Parent, RelativePath Item, string AbsPath)> fileItems, + CancellationToken ct) + { + var results = new (RelativePath Parent, RelativePath Item, IReadOnlyList Deps)?[fileItems.Count]; await Parallel.ForEachAsync( Enumerable.Range(0, fileItems.Count), new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = Environment.ProcessorCount }, - async (i, innerCt) => - { - var (parent, item, absPath) = fileItems[i]; - try - { - var deps = new List(); - foreach (var parser in _dependencyParsers) - { - var d = await parser.ParseFileDependencies(absPath, innerCt).ConfigureAwait(false); - deps.AddRange(d); - } - parsedDeps[i] = (parent, item, deps); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error while processing '{item.Value}': {ex}"); - } - }); - - foreach (var entry in parsedDeps) - { - if (entry is not { } parsed) - continue; + async (i, innerCt) => results[i] = await ParseFileItemAsync(fileItems[i], innerCt).ConfigureAwait(false)); - graph.UpsertProjectItem(parsed.Item, ProjectItemType.File); - graph.AddChild(parsed.Parent, parsed.Item); - graph.SetDependencies(parsed.Item, parsed.Deps); - } + return results.Where(r => r.HasValue).Select(r => r!.Value); + } - return graph; + private async Task<(RelativePath Parent, RelativePath Item, IReadOnlyList Deps)?> ParseFileItemAsync( + (RelativePath Parent, RelativePath Item, string AbsPath) fileItem, + CancellationToken ct) + { + var (parent, item, absPath) = fileItem; + try + { + var deps = new List(); + foreach (var parser in _dependencyParsers) + deps.AddRange(await parser.ParseFileDependencies(absPath, ct).ConfigureAwait(false)); + return (parent, item, deps); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error while processing '{item.Value}': {ex}"); + return null; + } } private static void ApplyDeletions(ProjectDependencyGraph graph, ProjectChanges changes) diff --git a/src/c-sharp/Application/UpdateDiffGraphUseCase.cs b/src/c-sharp/Application/UpdateDiffGraphUseCase.cs deleted file mode 100644 index 75b7049b..00000000 --- a/src/c-sharp/Application/UpdateDiffGraphUseCase.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Archlens.Domain; -using Archlens.Domain.Interfaces; -using Archlens.Domain.Models.Records; - -namespace Archlens.Application; - -public sealed class UpdateDiffGraphUseCase( - BaseOptions baseOptions, - ParserOptions parserOptions, - RenderOptions renderOptions, - SnapshotOptions snapshotOptions, - IReadOnlyList parsers, - RendererBase renderer, - ISnapshotManager snapshotManager - ) -{ - public async Task RunAsync(CancellationToken ct = default) - { - var snapshotGraph = await snapshotManager.GetLastSavedDependencyGraphAsync(snapshotOptions, ct); - var projectChanges = await ChangeDetector.GetProjectChangesAsync(parserOptions, snapshotGraph, ct); - var graph = await new DependencyGraphBuilder(parsers, baseOptions).GetGraphAsync(projectChanges, snapshotGraph, ct); - - await renderer.RenderDiffViewsAndSaveToFiles(graph, snapshotGraph, renderOptions, ct); - await snapshotManager.SaveGraphAsync(graph, snapshotOptions, ct); - } -} \ No newline at end of file diff --git a/src/c-sharp/Application/UpdateGraphUseCase.cs b/src/c-sharp/Application/UpdateGraphUseCase.cs index 22d5ef68..912d4604 100644 --- a/src/c-sharp/Application/UpdateGraphUseCase.cs +++ b/src/c-sharp/Application/UpdateGraphUseCase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -14,7 +15,8 @@ public sealed class UpdateGraphUseCase( SnapshotOptions snapshotOptions, IReadOnlyList parsers, RendererBase renderer, - ISnapshotManager snapshotManager + ISnapshotManager snapshotManager, + bool diff = false ) { public async Task RunAsync(CancellationToken ct = default) @@ -23,7 +25,16 @@ public async Task RunAsync(CancellationToken ct = default) var projectChanges = await ChangeDetector.GetProjectChangesAsync(parserOptions, snapshotGraph, ct); var graph = await new DependencyGraphBuilder(parsers, baseOptions).GetGraphAsync(projectChanges, snapshotGraph, ct); - await renderer.RenderViewsAndSaveToFiles(graph, renderOptions, ct); + if (diff) + { + if (snapshotGraph is null) + throw new InvalidOperationException("Diff mode requires a saved snapshot, but none was found."); + + await renderer.RenderDiffViewsAndSaveToFiles(graph, snapshotGraph, renderOptions, ct); + } + else + await renderer.RenderViewsAndSaveToFiles(graph, renderOptions, ct); + await snapshotManager.SaveGraphAsync(graph, snapshotOptions, ct); } -} \ No newline at end of file +} diff --git a/src/c-sharp/Domain/DependencyGraphSerializer.cs b/src/c-sharp/Domain/DependencyGraphSerializer.cs index acc31a1d..950d438d 100644 --- a/src/c-sharp/Domain/DependencyGraphSerializer.cs +++ b/src/c-sharp/Domain/DependencyGraphSerializer.cs @@ -129,11 +129,8 @@ public static ProjectDependencyGraph Deserialize(byte[] data, string projectRoot .Select(i => { var type = (ProjectItemType)i.Type; - var path = type == ProjectItemType.Directory - ? RelativePath.Directory(projectRoot, i.Path) - : RelativePath.File(projectRoot, i.Path); return new ProjectItem( - Path: path, + Path: ToRelativePath(projectRoot, i.Path, type), Name: i.Name, LastWriteTime: i.LastWriteTime, Type: type); @@ -150,36 +147,37 @@ public static ProjectDependencyGraph Deserialize(byte[] data, string projectRoot foreach (var entry in dto.Contains) { var parent = RelativePath.Directory(projectRoot, entry.Parent); - - var children = entry.Children.Select(childPath => - { - if (!itemTypeByPath.TryGetValue(childPath, out var childType)) - throw new InvalidOperationException($"Child '{childPath}' does not exist in snapshot items."); - - return childType == ProjectItemType.Directory - ? RelativePath.Directory(projectRoot, childPath) - : RelativePath.File(projectRoot, childPath); - }); - + var children = entry.Children.Select(childPath => ResolveChildPath(projectRoot, childPath, itemTypeByPath)); graph.AddChildren(parent, children); } foreach (var entry in dto.DependsOn) { var from = RelativePath.File(projectRoot, entry.From); - var dependencies = entry.Dependencies.ToDictionary( - d => itemTypeByPath.TryGetValue(d.To, out var targetType) - ? (targetType == ProjectItemType.Directory - ? RelativePath.Directory(projectRoot, d.To) - : RelativePath.File(projectRoot, d.To)) - : RelativePath.File(projectRoot, d.To), - d => new Dependency(d.Count, (DependencyType)d.Type) - ); - + d => ResolveDependencyTarget(projectRoot, d.To, itemTypeByPath), + d => new Dependency(d.Count, (DependencyType)d.Type)); graph.AddDependencies(from, dependencies); } return graph; } + + private static RelativePath ToRelativePath(string projectRoot, string path, ProjectItemType type) + => type == ProjectItemType.Directory + ? RelativePath.Directory(projectRoot, path) + : RelativePath.File(projectRoot, path); + + private static RelativePath ResolveChildPath(string projectRoot, string childPath, Dictionary itemTypeByPath) + { + if (!itemTypeByPath.TryGetValue(childPath, out var childType)) + throw new InvalidOperationException($"Child '{childPath}' does not exist in snapshot items."); + return ToRelativePath(projectRoot, childPath, childType); + } + + private static RelativePath ResolveDependencyTarget(string projectRoot, string path, Dictionary itemTypeByPath) + { + var type = itemTypeByPath.TryGetValue(path, out var t) ? t : ProjectItemType.File; + return ToRelativePath(projectRoot, path, type); + } } diff --git a/src/c-sharp/Domain/Models/Records/Options.cs b/src/c-sharp/Domain/Models/Records/Options.cs index a4018cc3..e53f5b4b 100644 --- a/src/c-sharp/Domain/Models/Records/Options.cs +++ b/src/c-sharp/Domain/Models/Records/Options.cs @@ -20,7 +20,7 @@ public sealed record RenderOptions( BaseOptions BaseOptions, RenderFormat Format, IReadOnlyList Views, - string? SaveLocation + string SaveLocation ); public sealed record SnapshotOptions( diff --git a/src/c-sharp/Domain/Models/Records/RelativePath.cs b/src/c-sharp/Domain/Models/Records/RelativePath.cs index 19876625..591cab46 100644 --- a/src/c-sharp/Domain/Models/Records/RelativePath.cs +++ b/src/c-sharp/Domain/Models/Records/RelativePath.cs @@ -15,7 +15,7 @@ public static RelativePath File(string projectRoot, string input) public static RelativePath Directory(string projectRoot, string input) => new(PathNormaliser.NormaliseModule(projectRoot, input)); - public string GetName() => Value.Split('/').Where(s => !string.IsNullOrEmpty(s)).Last() ?? Value; + public string GetName() => Value.Split('/').Last(s => !string.IsNullOrEmpty(s)) ?? Value; public override string ToString() => Value; } \ No newline at end of file diff --git a/src/c-sharp/Domain/RendererBase.cs b/src/c-sharp/Domain/RendererBase.cs index a15336eb..9ea56f64 100644 --- a/src/c-sharp/Domain/RendererBase.cs +++ b/src/c-sharp/Domain/RendererBase.cs @@ -99,7 +99,7 @@ public async Task SaveViewToFileAsync(string content, View view, RenderOptions o var filename = $"{options.BaseOptions.ProjectName}{diffString}-{view.ViewName}.{FileExtension}"; var path = Path.Combine(dir, filename); - await File.WriteAllTextAsync(path, content); + await File.WriteAllTextAsync(path, content, ct); } private static RenderGraph BuildRenderGraph( @@ -212,10 +212,13 @@ private static RenderGraph BuildDiffRenderGraph( ); } +#pragma warning disable CA1859 // Use concrete types when possible for improved performance private static IReadOnlyDictionary> MergeChildren( RenderGraph localRender, RenderGraph remoteRender, IReadOnlySet visibleNodes) +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + { var merged = new Dictionary>(); @@ -253,50 +256,125 @@ private static List BuildDiffEdges( RenderGraph localRender, RenderGraph remoteRender) { - var localEdges = localRender.Edges.ToDictionary( - e => (e.From, e.To), - e => e); - - var remoteEdges = remoteRender.Edges.ToDictionary( - e => (e.From, e.To), - e => e); + var localEdges = localRender.Edges.ToDictionary(e => (e.From, e.To), e => e); + var remoteEdges = remoteRender.Edges.ToDictionary(e => (e.From, e.To), e => e); var allKeys = new HashSet<(RelativePath From, RelativePath To)>(localEdges.Keys); allKeys.UnionWith(remoteEdges.Keys); return [.. allKeys - .Select(key => + .Select(key => MergeToDiffEdge(key, localEdges.GetValueOrDefault(key), remoteEdges.GetValueOrDefault(key))) + .OrderBy(e => e.From.Value, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.To.Value, StringComparer.OrdinalIgnoreCase)]; + } + + private static RenderEdge MergeToDiffEdge( + (RelativePath From, RelativePath To) key, + RenderEdge? local, + RenderEdge? remote) + { + var localCount = local?.Count ?? 0; + var remoteCount = remote?.Count ?? 0; + var delta = localCount - remoteCount; + + var state = + delta > 0 ? RenderState.CREATED : + delta < 0 ? RenderState.DELETED : + RenderState.NEUTRAL; + + var relations = state == RenderState.DELETED + ? remote?.Relations ?? [] + : local?.Relations ?? []; + + return new RenderEdge( + From: key.From, + To: key.To, + Count: localCount, + Delta: delta, + Type: (local ?? remote)!.Type, + State: state, + Relations: relations + ); + } + + private static List AggregateEdgesToVisibleDirectories( + ProjectDependencyGraph graph, + IReadOnlySet selectedRoots, + IReadOnlySet visibleDirs) + { + var (edgeMap, relationsMap) = BuildEdgeMaps(graph, selectedRoots, visibleDirs); + + return [.. edgeMap + .OrderBy(kv => kv.Key.From.Value, StringComparer.OrdinalIgnoreCase) + .ThenBy(kv => kv.Key.To.Value, StringComparer.OrdinalIgnoreCase) + .Select(kv => new RenderEdge( + From: kv.Key.From, + To: kv.Key.To, + Count: kv.Value.Count, + Delta: 0, + Type: kv.Value.Type, + State: RenderState.NEUTRAL, + Relations: relationsMap.GetValueOrDefault(kv.Key) ?? []))]; + } + + private static (Dictionary<(RelativePath From, RelativePath To), Dependency> EdgeMap, Dictionary<(RelativePath From, RelativePath To), List> RelationsMap) BuildEdgeMaps( + ProjectDependencyGraph graph, + IReadOnlySet selectedRoots, + IReadOnlySet visibleDirs) + { + var ancestorCache = new Dictionary(); + RelativePath? CachedAncestor(RelativePath path) + { + if (!ancestorCache.TryGetValue(path, out var result)) + ancestorCache[path] = result = NearestVisibleAncestor(graph, path, visibleDirs); + return result; + } + + var edgeMap = new Dictionary<(RelativePath From, RelativePath To), Dependency>(); + var relationsMap = new Dictionary<(RelativePath From, RelativePath To), List>(); + var relationsKeySet = new Dictionary<(RelativePath From, RelativePath To), HashSet<(RelativePath, RelativePath)>>(); + + foreach (var item in graph.ProjectItems.Values) + { + if (!IsUnderAnyRoot(item.Path, selectedRoots)) continue; + var fromVisible = CachedAncestor(item.Path); + if (fromVisible is null) continue; + + foreach (var (depTarget, dep) in graph.DependenciesFrom(item.Path)) { - var hasLocal = localEdges.TryGetValue(key, out var localEdge); - var hasRemote = remoteEdges.TryGetValue(key, out var remoteEdge); + if (!IsUnderAnyRoot(depTarget, selectedRoots)) continue; + var toVisible = CachedAncestor(depTarget); + if (toVisible is null || fromVisible.Value.Equals(toVisible.Value)) continue; - var localCount = hasLocal ? localEdge!.Count : 0; - var remoteCount = hasRemote ? remoteEdge!.Count : 0; - var delta = localCount - remoteCount; + var key = (fromVisible.Value, toVisible.Value); - var state = - delta > 0 ? RenderState.CREATED : - delta < 0 ? RenderState.DELETED : - RenderState.NEUTRAL; + edgeMap[key] = edgeMap.TryGetValue(key, out var existing) + ? existing with { Count = existing.Count + dep.Count } + : dep; - IReadOnlyList relations = state switch - { - RenderState.DELETED => hasRemote ? remoteEdge!.Relations : [], - _ => hasLocal ? localEdge!.Relations : [] - }; - - return new RenderEdge( - From: key.From, - To: key.To, - Count: localCount, - Delta: delta, - Type: hasLocal ? localEdge!.Type : remoteEdge!.Type, - State: state, - Relations: relations - ); - }) - .OrderBy(e => e.From.Value, StringComparer.OrdinalIgnoreCase) - .ThenBy(e => e.To.Value, StringComparer.OrdinalIgnoreCase)]; + AccumulateRelation(key, item.Path, depTarget, relationsMap, relationsKeySet); + } + } + + return (edgeMap, relationsMap); + } + + private static void AccumulateRelation( + (RelativePath From, RelativePath To) key, + RelativePath itemPath, + RelativePath depTarget, + Dictionary<(RelativePath From, RelativePath To), List> relationsMap, + Dictionary<(RelativePath From, RelativePath To), HashSet<(RelativePath, RelativePath)>> relationsKeySet) + { + if (!relationsMap.TryGetValue(key, out var rels)) + { + rels = []; + relationsMap[key] = rels; + relationsKeySet[key] = []; + } + + if (relationsKeySet[key].Add((itemPath, depTarget))) + rels.Add(new RenderRelation(itemPath, depTarget)); } private static void AddVisibleDirectories( @@ -347,82 +425,6 @@ private static void AddVisibleDirectories( } } - private static List AggregateEdgesToVisibleDirectories( - ProjectDependencyGraph graph, - IReadOnlySet selectedRoots, - IReadOnlySet visibleDirs) - { - - var ancestorCache = new Dictionary(); - - RelativePath? CachedAncestor(RelativePath path) - { - if (!ancestorCache.TryGetValue(path, out var result)) - ancestorCache[path] = result = NearestVisibleAncestor(graph, path, visibleDirs); - return result; - } - - var edgeMap = new Dictionary<(RelativePath From, RelativePath To), Dependency>(); - var relationsMap = new Dictionary<(RelativePath From, RelativePath To), List>(); - var relationsKeySet = new Dictionary<(RelativePath From, RelativePath To), HashSet<(RelativePath, RelativePath)>>(); - - foreach (var item in graph.ProjectItems.Values) - { - if (!IsUnderAnyRoot(item.Path, selectedRoots)) - continue; - - var fromVisible = CachedAncestor(item.Path); - if (fromVisible is null) - continue; - - foreach (var (depTarget, dep) in graph.DependenciesFrom(item.Path)) - { - if (!IsUnderAnyRoot(depTarget, selectedRoots)) - continue; - - var toVisible = CachedAncestor(depTarget); - if (toVisible is null || fromVisible.Value.Equals(toVisible.Value)) - continue; - - var key = (fromVisible.Value, toVisible.Value); - - if (edgeMap.TryGetValue(key, out var existing)) - edgeMap[key] = existing with { Count = existing.Count + dep.Count }; - else - edgeMap[key] = dep; - - if (!relationsMap.TryGetValue(key, out var rels)) - { - rels = []; - relationsMap[key] = rels; - relationsKeySet[key] = []; - } - - if (relationsKeySet[key].Add((item.Path, depTarget))) - rels.Add(new RenderRelation(item.Path, depTarget)); - } - } - - return [.. edgeMap - .OrderBy(kv => kv.Key.From.Value, StringComparer.OrdinalIgnoreCase) - .ThenBy(kv => kv.Key.To.Value, StringComparer.OrdinalIgnoreCase) - .Select(kv => - { - relationsMap.TryGetValue(kv.Key, out var rels); - rels ??= []; - - return new RenderEdge( - From: kv.Key.From, - To: kv.Key.To, - Count: kv.Value.Count, - Delta: 0, - Type: kv.Value.Type, - State: RenderState.NEUTRAL, - rels - ); - })]; - } - private static RelativePath? NearestVisibleAncestor( ProjectDependencyGraph graph, RelativePath path, @@ -446,9 +448,6 @@ private static List AggregateEdgesToVisibleDirectories( private static bool IsUnderAnyRoot(RelativePath path, IReadOnlySet roots) { - foreach (var root in roots) - if (path.Value.StartsWith(root.Value, StringComparison.OrdinalIgnoreCase)) - return true; - return false; + return roots.Any(root => path.Value.StartsWith(root.Value, StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +} diff --git a/src/c-sharp/Infra/ConfigManager.cs b/src/c-sharp/Infra/ConfigManager.cs index 5225d011..20818445 100644 --- a/src/c-sharp/Infra/ConfigManager.cs +++ b/src/c-sharp/Infra/ConfigManager.cs @@ -48,6 +48,7 @@ private sealed class PackageDto public int? Depth { get; set; } } + private readonly JsonSerializerOptions jsonOptions = new() { PropertyNameCaseInsensitive = true }; public async Task<(BaseOptions, ParserOptions, RenderOptions, SnapshotOptions)> LoadAsync(bool diff = false, string format = "puml", CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(_path)) @@ -61,7 +62,7 @@ private sealed class PackageDto var dto = await JsonSerializer.DeserializeAsync( fileStream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, + jsonOptions, ct ) ?? throw new InvalidOperationException($"Could not parse JSON in {configFile}."); @@ -116,7 +117,7 @@ private static RenderOptions MapRenderOptions(ConfigDto dto, string baseDir, Bas { var format = MapFormat(dto.Format ?? formatString); var views = MapViews(dto.Views); - var saveLoc = MapPath(baseDir, dto.SaveLocation); + var saveLoc = MapPath(baseDir, dto.SaveLocation) ?? $"{baseDir}/diagrams"; return new RenderOptions( BaseOptions: options, @@ -177,7 +178,10 @@ private static string MapName(ConfigDto dto) return string.Empty; } +#pragma warning disable CA1859 // Use concrete types when possible for improved performance private static IReadOnlyList MapLanguage(string[] fileExtensions) +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + { List languages = []; diff --git a/src/c-sharp/Infra/Factories/DependencyParserFactory.cs b/src/c-sharp/Infra/Factories/DependencyParserFactory.cs index a886f1ad..0d9ca635 100644 --- a/src/c-sharp/Infra/Factories/DependencyParserFactory.cs +++ b/src/c-sharp/Infra/Factories/DependencyParserFactory.cs @@ -7,7 +7,7 @@ namespace Archlens.Infra.Factories; -public sealed class DependencyParserFactory +public static class DependencyParserFactory { public static IReadOnlyList SelectDependencyParser(ParserOptions o) { diff --git a/src/c-sharp/Infra/Factories/RendererFactory.cs b/src/c-sharp/Infra/Factories/RendererFactory.cs index 922392c2..4ba573fa 100644 --- a/src/c-sharp/Infra/Factories/RendererFactory.cs +++ b/src/c-sharp/Infra/Factories/RendererFactory.cs @@ -6,7 +6,7 @@ namespace Archlens.Infra.Factories; -public sealed class RendererFactory +public static class RendererFactory { public static RendererBase SelectRenderer(RenderOptions options) => options.Format switch { diff --git a/src/c-sharp/Infra/Factories/SnapshotManagerFactory.cs b/src/c-sharp/Infra/Factories/SnapshotManagerFactory.cs index 530babc8..44698203 100644 --- a/src/c-sharp/Infra/Factories/SnapshotManagerFactory.cs +++ b/src/c-sharp/Infra/Factories/SnapshotManagerFactory.cs @@ -6,7 +6,7 @@ namespace Archlens.Infra.Factories; -public sealed class SnapshotManagerFactory +public static class SnapshotManagerFactory { public static ISnapshotManager SelectSnapshotManager(SnapshotOptions o) => o.SnapshotManager switch { diff --git a/src/c-sharp/Infra/Parsers/CsharpDependencyParser.cs b/src/c-sharp/Infra/Parsers/CsharpDependencyParser.cs deleted file mode 100644 index 82981f06..00000000 --- a/src/c-sharp/Infra/Parsers/CsharpDependencyParser.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Archlens.Domain.Interfaces; -using Archlens.Domain.Models.Records; - -namespace Archlens.Infra.Parsers; - -class CsharpDependencyParser(ParserOptions _options) : IDependencyParser -{ - public async Task> ParseFileDependencies(string path, CancellationToken ct = default) - { - /* - open file from given path - match regex "Using.ProjectName" - take all matches and put in list - return list - */ - List usings = []; - - try - { - StreamReader sr = new(path); - - string? line = await sr.ReadLineAsync(ct); - - while (line != null) - { - string regex = $$"""using\s+{{_options.BaseOptions.ProjectName}}\.(.+);"""; - var match = Regex.Match(line, regex); - if (match.Success) - { - var relativePath = RelativePath.Directory(_options.BaseOptions.FullRootPath, match.Groups[1].Value); - usings.Add(relativePath); - } - line = await sr.ReadLineAsync(ct); - } - - sr.Close(); - return usings; - } - catch (Exception e) - { - Console.WriteLine("Exception: " + e.Message); - return []; - } - - } -} diff --git a/src/c-sharp/Infra/Parsers/CsharpSyntaxWalkerParser.cs b/src/c-sharp/Infra/Parsers/CsharpSyntaxWalkerParser.cs index 1e05a3ad..be78b8ec 100644 --- a/src/c-sharp/Infra/Parsers/CsharpSyntaxWalkerParser.cs +++ b/src/c-sharp/Infra/Parsers/CsharpSyntaxWalkerParser.cs @@ -19,14 +19,17 @@ private sealed class UsingCollector(string projectName) : CSharpSyntaxWalker public override void VisitUsingDirective(UsingDirectiveSyntax node) { +#pragma warning disable CS8602 // Dereference of a possibly null reference. if (node.Name.ToString().StartsWith(projectName)) Usings.Add(node); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } } - public async Task> ParseFileDependencies(string path, CancellationToken ct = default) + public async Task> ParseFileDependencies(string absPath, CancellationToken ct = default) { - var text = await File.ReadAllTextAsync(path, ct); + var text = await File.ReadAllTextAsync(absPath, ct); var tree = CSharpSyntaxTree.ParseText(text, cancellationToken: ct); var walker = new UsingCollector(_options.BaseOptions.ProjectName); walker.Visit(tree.GetCompilationUnitRoot(ct)); @@ -34,7 +37,7 @@ public async Task> ParseFileDependencies(string path return [.. walker.Usings .Select(u => { - var rel = u.Name.ToString() + var rel = u?.Name?.ToString() .Replace(".", "/") .Replace(_options.BaseOptions.ProjectName, ".") + "/"; return RelativePath.Directory(_options.BaseOptions.FullRootPath, rel); diff --git a/src/c-sharp/Infra/Parsers/GoDependencyParser.cs b/src/c-sharp/Infra/Parsers/GoDependencyParser.cs index fdc410dc..3e271e67 100644 --- a/src/c-sharp/Infra/Parsers/GoDependencyParser.cs +++ b/src/c-sharp/Infra/Parsers/GoDependencyParser.cs @@ -31,43 +31,43 @@ public async Task> ParseFileDependencies( while (!reader.EndOfStream) { - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + reader.Close(); + ct.ThrowIfCancellationRequested(); + } var line = await reader.ReadLineAsync(ct); if (line is null) break; - var trimmed = line.Trim(); - - if (!insideBlock) - { - if (!trimmed.StartsWith("import", StringComparison.Ordinal)) - continue; - - if (!trimmed.Contains('(')) - { - ExtractImportFromLine(trimmed, deps); - continue; - } - - insideBlock = true; - ExtractImportFromLine(trimmed, deps); - if (trimmed.Contains(')')) - insideBlock = false; - } - else - { - if (trimmed.StartsWith(")", StringComparison.Ordinal)) - { - insideBlock = false; - continue; - } - ExtractImportFromLine(trimmed, deps); - } + insideBlock = ProcessImportLine(line.Trim(), insideBlock, deps); } + reader.Close(); return deps; } + private bool ProcessImportLine(string trimmed, bool insideBlock, List deps) + { + if (insideBlock) + { + if (trimmed.StartsWith(')')) + return false; + + ExtractImportFromLine(trimmed, deps); + return true; + } + + if (!trimmed.StartsWith("import", StringComparison.Ordinal)) + return false; + + ExtractImportFromLine(trimmed, deps); + + if (!trimmed.Contains('(')) + return false; + + return !trimmed.Contains(')'); + } private void ExtractImportFromLine(string line, List deps) { diff --git a/src/c-sharp/Infra/Parsers/JavaDependencyParser.cs b/src/c-sharp/Infra/Parsers/JavaDependencyParser.cs index fbf710dc..99b73be6 100644 --- a/src/c-sharp/Infra/Parsers/JavaDependencyParser.cs +++ b/src/c-sharp/Infra/Parsers/JavaDependencyParser.cs @@ -37,7 +37,7 @@ take all matches and put in list } string regex = $$"""import\s+(static\s+)?{{_options.BaseOptions.ProjectName}}\.(.+);"""; - var match = Regex.Match(line, regex); + var match = Regex.Match(line, regex, RegexOptions.None, TimeSpan.FromMilliseconds(200)); if (match.Success) { var packagePath = match.Groups[2].Value.TrimEnd('*').TrimEnd('.').Replace('.', '/'); diff --git a/src/c-sharp/Infra/Parsers/KotlinDependencyParser.cs b/src/c-sharp/Infra/Parsers/KotlinDependencyParser.cs index ddbd4d93..07cb5013 100644 --- a/src/c-sharp/Infra/Parsers/KotlinDependencyParser.cs +++ b/src/c-sharp/Infra/Parsers/KotlinDependencyParser.cs @@ -25,7 +25,7 @@ public async Task> ParseFileDependencies( var regex = new Regex( $@"^\s*import\s+{Regex.Escape(_rootPackage)}\.(.+?)(\s+as\s+\w+)?\s*$", - RegexOptions.Compiled); + RegexOptions.Compiled, TimeSpan.FromMilliseconds(200)); try { diff --git a/src/c-sharp/Infra/Renderers/PlantUMLRenderer.cs b/src/c-sharp/Infra/Renderers/PlantUMLRenderer.cs index fe9d9d84..a4200397 100644 --- a/src/c-sharp/Infra/Renderers/PlantUMLRenderer.cs +++ b/src/c-sharp/Infra/Renderers/PlantUMLRenderer.cs @@ -8,7 +8,7 @@ namespace Archlens.Infra.Renderers; -public sealed class PlantUMLRenderer : RendererBase +public sealed partial class PlantUMLRenderer : RendererBase { public override string FileExtension => "puml"; @@ -112,7 +112,7 @@ private static string FormatLabel(int count, int delta) private static string ToAlias(string path) { - var alias = Regex.Replace(path, @"[^\w]", ""); + var alias = AliasRegex().Replace(path, ""); if (string.IsNullOrWhiteSpace(alias)) return "node"; @@ -125,4 +125,9 @@ private static string ToAlias(string path) private static string Escape(string value) => value.Replace("\"", "\\\""); + + + [GeneratedRegex(@"[^\w]")] + private static partial Regex AliasRegex(); + } \ No newline at end of file diff --git a/src/c-sharp/Program.cs b/src/c-sharp/Program.cs index dde5a48d..323dad16 100644 --- a/src/c-sharp/Program.cs +++ b/src/c-sharp/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using Archlens.Application; @@ -19,52 +19,28 @@ public static async Task Main(string[] args) : new FileInfo(path).Directory!; var format = args.Length < 2 ? "puml" : args[1].Trim(); - var diff = args.Length < 3 ? false : args[2].Trim() == "diff"; + var diff = args.Length >= 3 && args[2].Trim() == "diff"; await CLI(root.FullName, format, diff); } - public static string CLISync(string config_path, string format = "puml", bool diff = false) + public static string CLISync(string configPath, string format = "puml", bool diff = false) { - return CLI(config_path, format, diff).GetAwaiter().GetResult(); + return CLI(configPath, format, diff).GetAwaiter().GetResult(); } - public static async Task CLI(string config_path, string format = "puml", bool diff = false) + public static async Task CLI(string configPath, string format = "puml", bool diff = false) { try { - var (baseOptions, parserOptions, renderOptions, snapshotOptions) = await GetOptions(config_path, diff, format); + var (baseOptions, parserOptions, renderOptions, snapshotOptions) = await LoadOptions(configPath, diff, format); var snapshotManager = SnapshotManagerFactory.SelectSnapshotManager(snapshotOptions); var parsers = DependencyParserFactory.SelectDependencyParser(parserOptions); var renderer = RendererFactory.SelectRenderer(renderOptions); - if (diff) - { - var updateDiffGraphUseCase = new UpdateDiffGraphUseCase( - baseOptions, - parserOptions, - renderOptions, - snapshotOptions, - parsers, - renderer, - snapshotManager); - - await updateDiffGraphUseCase.RunAsync(); - } - else - { - var updateGraphUseCase = new UpdateGraphUseCase( - baseOptions, - parserOptions, - renderOptions, - snapshotOptions, - parsers, - renderer, - snapshotManager); - - await updateGraphUseCase.RunAsync(); - } + var useCase = new UpdateGraphUseCase(baseOptions, parserOptions, renderOptions, snapshotOptions, parsers, renderer, snapshotManager, diff); + await useCase.RunAsync(); return ""; } @@ -75,12 +51,10 @@ public static async Task CLI(string config_path, string format = "puml", } } - private static async Task<(BaseOptions, ParserOptions, RenderOptions, SnapshotOptions)> GetOptions(string args, bool diff = false, string format = "puml") + private static async Task<(BaseOptions, ParserOptions, RenderOptions, SnapshotOptions)> LoadOptions(string configPath, bool diff = false, string format = "puml") { - var configPath = args.Length > 0 ? args : FindConfigFile("archlens.json"); - - var configManager = new ConfigManager(configPath); - + var resolvedPath = configPath.Length > 0 ? configPath : FindConfigFile("archlens.json"); + var configManager = new ConfigManager(resolvedPath); return await configManager.LoadAsync(diff, format); } @@ -99,4 +73,4 @@ private static string FindConfigFile(string fileName) throw new FileNotFoundException($"Could not find '{fileName}' starting from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +}