Skip to content
Closed
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
3 changes: 3 additions & 0 deletions samples/erd/simple-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ The tool generates a **Mermaid ERD diagram** showing:
### Example Output

```mermaid
---
title: MyDbContext
---
erDiagram
Author {
int Id PK
Expand Down
8 changes: 8 additions & 0 deletions src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ public static string Render(EfModel model)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");

if (!string.IsNullOrEmpty(model.ContextName))
{
sb.AppendLine("---");
sb.AppendLine($"title: {model.ContextName}");
sb.AppendLine("---");
}

sb.AppendLine("erDiagram");

RenderEntities(model, sb);
Expand Down
52 changes: 40 additions & 12 deletions src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ namespace ProjGraph.Lib.Services.ClassAnalysis;
/// </summary>
public static class WorkspaceTypeDiscovery
{
/// <summary>
/// Directories to skip during recursive file search for better performance.
/// </summary>
private static readonly HashSet<string> ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", ".git", "node_modules"
};
/// <summary>
/// Finds the file containing the definition of a specific type within a given directory or its subdirectories.
/// The method first attempts to search in common subdirectories for better performance, and if not found,
Expand Down Expand Up @@ -58,20 +65,25 @@ public static class WorkspaceTypeDiscovery
/// </returns>
private static async Task<string?> SearchDirectoryForTypeAsync(string directory, string typeName)
{
var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true };
return await SearchDirectoryRecursiveAsync(directory, typeName);
}

foreach (var file in Directory.EnumerateFiles(directory, "*.cs", options))
/// <summary>
/// Recursively searches a directory and its subdirectories for a C# file containing a specific type definition.
/// This method manually handles recursion to avoid descending into common non-source directories for better performance.
/// </summary>
/// <param name="directory">The path of the directory to search.</param>
/// <param name="typeName">The name of the type to search for.</param>
/// <returns>
/// The full path of the file containing the type definition if found; otherwise, null.
/// </returns>
private static async Task<string?> SearchDirectoryRecursiveAsync(string directory, string typeName)
{
// Search files in the current directory
foreach (var file in Directory.EnumerateFiles(directory, "*.cs", new EnumerationOptions { IgnoreInaccessible = true }))
{
var fullPath = Path.GetFullPath(file);
// Skip common non-source directories
var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules"))
{
continue;
}

// Simple string check first for performance
var content = await File.ReadAllTextAsync(fullPath);
var content = await File.ReadAllTextAsync(file);
if (!content.Contains($"class {typeName}") &&
!content.Contains($"interface {typeName}") &&
!content.Contains($"struct {typeName}") &&
Expand All @@ -90,7 +102,23 @@ public static class WorkspaceTypeDiscovery

if (hasType)
{
return fullPath;
return file;
}
}

// Recursively search subdirectories, skipping excluded directories
foreach (var subDir in Directory.EnumerateDirectories(directory, "*", new EnumerationOptions { IgnoreInaccessible = true }))
{
var dirName = Path.GetFileName(subDir);
if (ExcludedDirectories.Contains(dirName))
{
continue;
}

var result = await SearchDirectoryRecursiveAsync(subDir, typeName);
if (result != null)
{
return result;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ public static class DataTypes
public const string Long = "long";
public const string Single = "Single";
public const string Short = "short";
public const string Byte = "byte";
public const string SByte = "sbyte";
public const string UShort = "ushort";
public const string UInt = "uint";
public const string UInt32 = "UInt32";
public const string ULong = "ulong";
public const string UInt64 = "UInt64";
public const string Char = "char";

/// <summary>
/// A set of common .NET value types that are treated as having a default value in EF.
Expand All @@ -46,7 +54,15 @@ public static class DataTypes
Double,
Float,
Single,
Short
Short,
Byte,
SByte,
UShort,
UInt,
UInt32,
ULong,
UInt64,
Char
};
}

Expand Down
139 changes: 106 additions & 33 deletions src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ namespace ProjGraph.Lib.Services.EfAnalysis;
/// </remarks>
public static class EntityFileDiscovery
{
/// <summary>
/// Maximum recursion depth when searching for base class files to prevent infinite recursion
/// and limit search scope to reasonable project structures.
/// </summary>
private const int MaxSearchDepth = 10;
/// <summary>
/// Discovers the file paths of entity files within the specified search directories.
/// </summary>
Expand Down Expand Up @@ -86,18 +91,16 @@ public static async Task<Dictionary<string, string>> DiscoverBaseClassFilesAsync
}

/// <summary>
/// Builds a list of directories to search for entity files based on the provided context directory
/// and a list of entity namespaces.
/// Builds a list of directories to search for entity files based on the provided context directory.
/// </summary>
/// <param name="contextDirectory">The directory containing the context file.</param>
/// <returns>
/// A list of directories to search for entity files, including the context directory, its parent directory,
/// and any sibling directories that are likely to contain entity files.
/// A list of directories to search for entity files, including the context directory and its parent directory.
/// </returns>
/// <remarks>
/// This method starts with the context directory and then its parent directory.
/// Since the parent directory scan is recursive, it will naturally include the context directory
/// and all siblings. The sibling check is kept as a fallback for non-nested structures.
/// and all siblings through the recursive search.
/// </remarks>
public static List<string> BuildSearchDirectories(
string contextDirectory)
Expand Down Expand Up @@ -173,37 +176,67 @@ public static HashSet<string> ExtractEntityTypeNames(ClassDeclarationSyntax cont
/// For each file, it calls <see cref="ProcessSourceFileAsync"/> to process the file and add matching
/// entity type names and their file paths to the <paramref name="entityFiles"/> dictionary.
/// Any access errors encountered during directory traversal are ignored.
/// Directories like bin, obj, .git, and node_modules are skipped during traversal for performance.
/// </remarks>
private static async Task SearchDirectoryForEntitiesAsync(
string searchDir,
HashSet<string> entityTypeNames,
string normalizedContextPath,
Dictionary<string, string> entityFiles)
{
await SearchDirectoryRecursiveAsync(searchDir, entityTypeNames, normalizedContextPath, entityFiles);
}

/// <summary>
/// Recursively searches a directory for C# files, skipping common build and version control directories.
/// </summary>
/// <param name="currentDir">The current directory to search.</param>
/// <param name="entityTypeNames">A set of entity type names to search for in the C# files.</param>
/// <param name="normalizedContextPath">The normalized file path of the context file to exclude from the search.</param>
/// <param name="entityFiles">
/// A dictionary where the keys are entity type names and the values are the corresponding file paths.
/// </param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// This method implements manual recursion to avoid descending into directories that typically
/// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance
/// for large projects.
/// </remarks>
private static async Task SearchDirectoryRecursiveAsync(
string currentDir,
HashSet<string> entityTypeNames,
string normalizedContextPath,
Dictionary<string, string> entityFiles)
{
try
{
// Process files in the current directory
var options = new EnumerationOptions
{
RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.System
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.System
};

foreach (var csFile in Directory.EnumerateFiles(searchDir, EfAnalysisConstants.FilePatterns.CSharpFiles,
options))
var filesToProcess = Directory.EnumerateFiles(currentDir, EfAnalysisConstants.FilePatterns.CSharpFiles, options)
.Select(Path.GetFullPath)
.Where(fullPath => !fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase));

foreach (var fullPath in filesToProcess)
{
var fullPath = Path.GetFullPath(csFile);
if (fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
await ProcessSourceFileAsync(fullPath, entityTypeNames, entityFiles);
}

// Skip common non-source directories that can be large
var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules"))
// Recursively process subdirectories, skipping excluded directories
foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options))
{
var dirName = Path.GetFileName(subDir);
if (dirName is "bin" or "obj" or ".git" or "node_modules")
{
continue;
}

await ProcessSourceFileAsync(fullPath, entityTypeNames, entityFiles);
await SearchDirectoryRecursiveAsync(subDir, entityTypeNames, normalizedContextPath, entityFiles);
}
}
catch
Expand Down Expand Up @@ -386,51 +419,91 @@ private static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLeve
/// if the files are found; otherwise, the dictionary will be empty.
/// </returns>
/// <remarks>
/// This method iterates through the provided base class names and attempts to locate their corresponding
/// file paths by calling the <see cref="TryFindBaseClassFile"/> method. If a file is found, it is added
/// to the resulting dictionary. If no file is found for a base class name, it is skipped.
/// This method recursively searches for base class files while skipping common build and version control
/// directories (bin, obj, .git, node_modules) to improve performance for large projects.
/// </remarks>
public static Dictionary<string, string> SearchForBaseClassFiles(
HashSet<string> baseClassNames,
DirectoryInfo solutionRoot)
{
var baseClassFiles = new Dictionary<string, string>();
SearchForBaseClassFilesRecursive(solutionRoot.FullName, baseClassNames, baseClassFiles, 0, MaxSearchDepth);
return baseClassFiles;
}

/// <summary>
/// Recursively searches for base class files, skipping common build and version control directories.
/// </summary>
/// <param name="currentDir">The current directory to search.</param>
/// <param name="baseClassNames">A set of base class names to search for.</param>
/// <param name="baseClassFiles">
/// A dictionary to store the found base class files where the keys are base class names
/// and the values are the corresponding file paths.
/// </param>
/// <param name="currentDepth">The current recursion depth.</param>
/// <param name="maxDepth">The maximum recursion depth to prevent infinite recursion.</param>
/// <remarks>
/// This method implements manual recursion to avoid descending into directories that typically
/// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance
/// for large projects.
/// </remarks>
private static void SearchForBaseClassFilesRecursive(
string currentDir,
HashSet<string> baseClassNames,
Dictionary<string, string> baseClassFiles,
int currentDepth,
int maxDepth)
{
if (currentDepth >= maxDepth || baseClassFiles.Count == baseClassNames.Count)
{
return;
}

try
{
var options = new EnumerationOptions
{
RecurseSubdirectories = true, IgnoreInaccessible = true, MaxRecursionDepth = 10
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.System
};

foreach (var file in Directory.EnumerateFiles(solutionRoot.FullName, "*.cs", options))
// Process files in the current directory
foreach (var file in Directory.EnumerateFiles(currentDir, "*.cs", options))
{
var fullPath = Path.GetFullPath(file);
// Skip common non-source directories that can be large
var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules"))
var fileName = Path.GetFileNameWithoutExtension(file);
if (baseClassNames.Contains(fileName))
{
continue;
var fullPath = Path.GetFullPath(file);
baseClassFiles.TryAdd(fileName, fullPath);

if (baseClassFiles.Count == baseClassNames.Count)
{
return;
}
}
}

var fileName = Path.GetFileNameWithoutExtension(file);
if (!baseClassNames.Contains(fileName))
// Recursively process subdirectories, skipping excluded directories
foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options))
{
var dirName = Path.GetFileName(subDir);
if (dirName is "bin" or "obj" or ".git" or "node_modules")
{
continue;
}

baseClassFiles.TryAdd(fileName, fullPath);
SearchForBaseClassFilesRecursive(subDir, baseClassNames, baseClassFiles, currentDepth + 1, maxDepth);

if (baseClassFiles.Count == baseClassNames.Count)
{
break;
return;
}
}
}
catch
{
// Ignore access errors
}

return baseClassFiles;
}
}
2 changes: 1 addition & 1 deletion tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ private static string Normalize(string input)
return NormalizeRegex().Replace(input, "\n").Trim();
}

[GeneratedRegex(@"```mermaid\s+(erDiagram[\s\S]*?)\s+```")]
[GeneratedRegex(@"```mermaid\s+([\s\S]*?)\s+```")]
private static partial Regex ExtractMermaidRegex();

[GeneratedRegex(@"\r\n|\n|\r")]
Expand Down