diff --git a/.gitignore b/.gitignore index bc78471..bbd09d9 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Generated schema files +*.dbml diff --git a/src/AL2DBML.Application/Interfaces/IAlParser.cs b/src/AL2DBML.Application/Interfaces/IAlParser.cs index 0f3689b..9839253 100644 --- a/src/AL2DBML.Application/Interfaces/IAlParser.cs +++ b/src/AL2DBML.Application/Interfaces/IAlParser.cs @@ -12,4 +12,5 @@ public interface IAlParser DBMLTable ParseTableExtension(string alTableExtensionFileContent); DBMLColumn ParseField(string alFieldContent); OutputSchema GetOutputSchema(); + void ClearOutputSchema(); } diff --git a/src/AL2DBML.CLI/AL2DBML.CLI.csproj b/src/AL2DBML.CLI/AL2DBML.CLI.csproj index 66629b4..2962959 100644 --- a/src/AL2DBML.CLI/AL2DBML.CLI.csproj +++ b/src/AL2DBML.CLI/AL2DBML.CLI.csproj @@ -12,4 +12,14 @@ + + + + + + + + + + diff --git a/src/AL2DBML.CLI/Commands/GenerateCommand.cs b/src/AL2DBML.CLI/Commands/GenerateCommand.cs new file mode 100644 index 0000000..7ed30fb --- /dev/null +++ b/src/AL2DBML.CLI/Commands/GenerateCommand.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using AL2DBML.Application.Interfaces; +using AL2DBML.CLI.Services; +using AL2DBML.CLI.Strategies; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace AL2DBML.CLI.Commands; + +public class GenerateSettings : CommandSettings +{ + [CommandOption("-i|--input ")] + [Description("The path to the AL project folder, AL file, or vscode workspace file of AL projects to parse.")] + public string InputPath { get; init; } = "."; + + [CommandOption("-o|--output ")] + [Description("The path to the output directory.")] + public string OutputPath { get; init; } = "."; + + [CommandOption("-n|--name ")] + [Description("The name of the output file (without extension). 'schema' by default.")] + public string OutputName { get; init; } = "schema"; +} + +public class GenerateCommand : AsyncCommand +{ + private readonly IAlParser _alParser; + private readonly IDBMLWriter _dbmlWriter; + private readonly IParsingTracker _tracker; + + public GenerateCommand(IAlParser alParser, IDBMLWriter dbmlWriter, IParsingTracker tracker) + { + _alParser = alParser; + _dbmlWriter = dbmlWriter; + _tracker = tracker; + } + + protected override async Task ExecuteAsync(CommandContext context, GenerateSettings settings, CancellationToken cancellationToken) + { + var inputType = FileSystemService.GetInputType(settings.InputPath); + if (inputType == Enums.InputType.NotSupported) + { + AnsiConsole.MarkupLine("[red]Error:[/] Unsupported input type. Please provide a valid directory, AL file, or vscode workspace file."); + return -1; + } + + var outputPath = Path.Combine(settings.OutputPath, $"{settings.OutputName}.dbml"); + Directory.CreateDirectory(settings.OutputPath); + + var factory = new InputStrategyFactory(inputType, _alParser, _tracker); + var outputSchema = factory.Strategy.Execute(settings.InputPath); + + var dbmlContent = await _dbmlWriter.WriteDBMLAsync(outputSchema); + await File.WriteAllTextAsync(outputPath, dbmlContent, cancellationToken); + + AnsiConsole.MarkupLine($"[green]Done:[/] {_tracker.FileCount} file(s) parsed in {_tracker.Elapsed.TotalSeconds:F2}s → {Markup.Escape(outputPath)}"); + + return 0; + } +} diff --git a/src/AL2DBML.CLI/Enums/InputType.cs b/src/AL2DBML.CLI/Enums/InputType.cs new file mode 100644 index 0000000..7083567 --- /dev/null +++ b/src/AL2DBML.CLI/Enums/InputType.cs @@ -0,0 +1,9 @@ +namespace AL2DBML.CLI.Enums; + +public enum InputType +{ + Directory, + ALFile, + WorkspaceFile, + NotSupported +} diff --git a/src/AL2DBML.CLI/Program.cs b/src/AL2DBML.CLI/Program.cs index 3751555..1bcdaf0 100644 --- a/src/AL2DBML.CLI/Program.cs +++ b/src/AL2DBML.CLI/Program.cs @@ -1,2 +1,41 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using AL2DBML.CLI.Commands; +using AL2DBML.CLI.Services; +using AL2DBML.DI; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + + + +// Register services +var services = new ServiceCollection(); +services + .AddAL2Dbml() + .AddScoped() + .AddScoped(); + +var registrar = new TypeRegistrar(services); + +var app = new CommandApp(registrar); + +app.Configure(config => +{ + config.AddCommand("generate"); +}); + +return await app.RunAsync(args); + +public sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar +{ + public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider().CreateScope().ServiceProvider); + + public void Register(Type service, Type implementation) => services.AddScoped(service, implementation); + + public void RegisterInstance(Type service, object implementation) => services.AddSingleton(service, implementation); + + public void RegisterLazy(Type service, Func factory) => services.AddScoped(service, _ => factory()); +} + +public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver +{ + public object? Resolve(Type? type) => type == null ? null : provider.GetService(type); +} diff --git a/src/AL2DBML.CLI/Services/FileSystemService.cs b/src/AL2DBML.CLI/Services/FileSystemService.cs new file mode 100644 index 0000000..2e38128 --- /dev/null +++ b/src/AL2DBML.CLI/Services/FileSystemService.cs @@ -0,0 +1,29 @@ +using AL2DBML.CLI.Enums; + +namespace AL2DBML.CLI.Services; + +public static class FileSystemService +{ + public static InputType GetInputType(string path) + { + if (Directory.Exists(path)) + return InputType.Directory; + + if (File.Exists(path)) + { + var extension = Path.GetExtension(path); + if (extension.Equals(".al", StringComparison.OrdinalIgnoreCase)) + return InputType.ALFile; + if (extension.Equals(".code-workspace", StringComparison.OrdinalIgnoreCase)) + return InputType.WorkspaceFile; + } + + return InputType.NotSupported; + } + + public static List ScanDirectory(string directoryPath) + { + return Directory.GetFiles(directoryPath, "*.al", SearchOption.AllDirectories) + .ToList(); + } +} diff --git a/src/AL2DBML.CLI/Services/IParsingTracker.cs b/src/AL2DBML.CLI/Services/IParsingTracker.cs new file mode 100644 index 0000000..6c85681 --- /dev/null +++ b/src/AL2DBML.CLI/Services/IParsingTracker.cs @@ -0,0 +1,8 @@ +namespace AL2DBML.CLI.Services; + +public interface IParsingTracker +{ + void RecordFile(); + int FileCount { get; } + TimeSpan Elapsed { get; } +} diff --git a/src/AL2DBML.CLI/Services/ParsingTracker.cs b/src/AL2DBML.CLI/Services/ParsingTracker.cs new file mode 100644 index 0000000..dd6f248 --- /dev/null +++ b/src/AL2DBML.CLI/Services/ParsingTracker.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace AL2DBML.CLI.Services; + +public class ParsingTracker : IParsingTracker +{ + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + private int _fileCount; + + public void RecordFile() => _fileCount++; + public int FileCount => _fileCount; + public TimeSpan Elapsed => _stopwatch.Elapsed; +} diff --git a/src/AL2DBML.CLI/Strategies/FolderInputStrategy.cs b/src/AL2DBML.CLI/Strategies/FolderInputStrategy.cs new file mode 100644 index 0000000..cf8bcf2 --- /dev/null +++ b/src/AL2DBML.CLI/Strategies/FolderInputStrategy.cs @@ -0,0 +1,28 @@ +using AL2DBML.Application.Interfaces; +using AL2DBML.CLI.Services; +using AL2DBML.Core.Models; + +namespace AL2DBML.CLI.Strategies; + +public class FolderInputStrategy : IInputStrategy +{ + private readonly IAlParser _alParser; + private readonly IParsingTracker _tracker; + + public FolderInputStrategy(IAlParser alParser, IParsingTracker tracker) + { + _alParser = alParser; + _tracker = tracker; + } + + public OutputSchema Execute(string inputPath) + { + var files = FileSystemService.ScanDirectory(inputPath); + var singleFileStrategy = new SingleFileInputStrategy(_alParser, _tracker); + + foreach (var file in files) + singleFileStrategy.Execute(file); + + return _alParser.GetOutputSchema(); + } +} diff --git a/src/AL2DBML.CLI/Strategies/IInputStrategy.cs b/src/AL2DBML.CLI/Strategies/IInputStrategy.cs new file mode 100644 index 0000000..6be96e5 --- /dev/null +++ b/src/AL2DBML.CLI/Strategies/IInputStrategy.cs @@ -0,0 +1,8 @@ +using AL2DBML.Core.Models; + +namespace AL2DBML.CLI.Strategies; + +public interface IInputStrategy +{ + OutputSchema Execute(string inputPath); +} diff --git a/src/AL2DBML.CLI/Strategies/InputStrategyFactory.cs b/src/AL2DBML.CLI/Strategies/InputStrategyFactory.cs new file mode 100644 index 0000000..8346fef --- /dev/null +++ b/src/AL2DBML.CLI/Strategies/InputStrategyFactory.cs @@ -0,0 +1,27 @@ +using AL2DBML.Application.Interfaces; +using AL2DBML.CLI.Enums; +using AL2DBML.CLI.Services; + +namespace AL2DBML.CLI.Strategies; + +public class InputStrategyFactory +{ + public IInputStrategy Strategy { get; } + public InputStrategyFactory(InputType inputType, IAlParser alParser, IParsingTracker tracker) + { + switch (inputType) + { + case InputType.Directory: + Strategy = new FolderInputStrategy(alParser, tracker); + break; + case InputType.ALFile: + Strategy = new SingleFileInputStrategy(alParser, tracker); + break; + case InputType.WorkspaceFile: + Strategy = new WorkspaceInputStrategy(alParser, tracker); + break; + default: + throw new NotSupportedException($"Input type {inputType} is not supported."); + } + } +} diff --git a/src/AL2DBML.CLI/Strategies/SingleFileInputStrategy.cs b/src/AL2DBML.CLI/Strategies/SingleFileInputStrategy.cs new file mode 100644 index 0000000..0abc595 --- /dev/null +++ b/src/AL2DBML.CLI/Strategies/SingleFileInputStrategy.cs @@ -0,0 +1,45 @@ +using AL2DBML.Application.Interfaces; +using AL2DBML.CLI.Services; +using AL2DBML.Core.Enums; +using AL2DBML.Core.Models; + +namespace AL2DBML.CLI.Strategies; + +public class SingleFileInputStrategy : IInputStrategy +{ + private readonly IAlParser _alParser; + private readonly IParsingTracker _tracker; + + public SingleFileInputStrategy(IAlParser alParser, IParsingTracker tracker) + { + _alParser = alParser; + _tracker = tracker; + } + + public OutputSchema Execute(string inputPath) + { + var content = File.ReadAllText(inputPath); + var fileType = _alParser.DetectFileType(content); + switch (fileType) + { + case AlFileType.Enum: + _alParser.ParseEnum(content); + _tracker.RecordFile(); + break; + case AlFileType.EnumExtension: + _alParser.ParseEnumExtension(content); + _tracker.RecordFile(); + break; + case AlFileType.Table: + _alParser.ParseTable(content); + _tracker.RecordFile(); + break; + case AlFileType.TableExtension: + _alParser.ParseTableExtension(content); + _tracker.RecordFile(); + break; + // Unknown: skip silently (unsupported files in a folder) + } + return _alParser.GetOutputSchema(); + } +} diff --git a/src/AL2DBML.CLI/Strategies/WorkspaceInputStrategy.cs b/src/AL2DBML.CLI/Strategies/WorkspaceInputStrategy.cs new file mode 100644 index 0000000..bab883d --- /dev/null +++ b/src/AL2DBML.CLI/Strategies/WorkspaceInputStrategy.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using AL2DBML.Application.Interfaces; +using AL2DBML.CLI.Services; +using AL2DBML.Core.Models; +using Spectre.Console; + +namespace AL2DBML.CLI.Strategies; + +class WorkspaceInputStrategy : IInputStrategy +{ + private readonly IAlParser _alParser; + private readonly IParsingTracker _tracker; + + public WorkspaceInputStrategy(IAlParser alParser, IParsingTracker tracker) + { + _alParser = alParser; + _tracker = tracker; + } + + public OutputSchema Execute(string inputPath) + { + // Read the vscode workspace file to get the list of projects + var workspaceContent = File.ReadAllText(inputPath); + using var workspaceJson = JsonDocument.Parse(workspaceContent); + if (!workspaceJson.RootElement.TryGetProperty("folders", out var folders)) + { + throw new InvalidDataException("Invalid workspace file: 'folders' property not found."); + } + if (folders.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Invalid workspace file: 'folders' property must be an array."); + } + foreach (var folder in folders.EnumerateArray()) + { + if (!folder.TryGetProperty("path", out var path)) + { + AnsiConsole.MarkupLine($"[orange]Warning:[/] A folder entry has no 'path' property. Skipping."); + continue; // Skip if no path property + } + var projectPath = Path.Combine(Path.GetDirectoryName(inputPath) ?? string.Empty, path.GetString() ?? string.Empty); + if (Directory.Exists(projectPath)) + { + var folderStrategy = new FolderInputStrategy(_alParser, _tracker); + folderStrategy.Execute(projectPath); + } + else + { + AnsiConsole.MarkupLine($"[orange]Warning:[/] Project path '{Markup.Escape(projectPath)}' does not exist. Skipping this entry."); + } + } + return _alParser.GetOutputSchema(); + } +} diff --git a/src/AL2DBML.DI/ParserServiceExtensions.cs b/src/AL2DBML.DI/ParserServiceExtensions.cs index f3082fd..e290986 100644 --- a/src/AL2DBML.DI/ParserServiceExtensions.cs +++ b/src/AL2DBML.DI/ParserServiceExtensions.cs @@ -8,6 +8,7 @@ public static class ParserServiceExtensions { public static IServiceCollection AddParser(this IServiceCollection services) { + // Scoped because we want to maintain state across multiple parsing operations services.AddScoped(); return services; } diff --git a/src/AL2DBML.Parser/AlParser.cs b/src/AL2DBML.Parser/AlParser.cs index facc431..e8665b7 100644 --- a/src/AL2DBML.Parser/AlParser.cs +++ b/src/AL2DBML.Parser/AlParser.cs @@ -180,4 +180,10 @@ public OutputSchema GetOutputSchema() { return OutputSchemaHelper.DeepCopy(_outputSchema); } + + public void ClearOutputSchema() + { + _outputSchema.Enums.Clear(); + _outputSchema.Tables.Clear(); + } } diff --git a/src/AL2DBML.Tests/TestBase.cs b/src/AL2DBML.Tests/TestBase.cs index 4beeb0b..2c5b985 100644 --- a/src/AL2DBML.Tests/TestBase.cs +++ b/src/AL2DBML.Tests/TestBase.cs @@ -21,7 +21,7 @@ protected TestBase() protected void ResetParser() { - _parser = Services.GetRequiredService(); + _parser.ClearOutputSchema(); } protected static string LoadFixture(string path)