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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,6 @@ $RECYCLE.BIN/

# Vim temporary swap files
*.swp

# Generated schema files
*.dbml
1 change: 1 addition & 0 deletions src/AL2DBML.Application/Interfaces/IAlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public interface IAlParser
DBMLTable ParseTableExtension(string alTableExtensionFileContent);
DBMLColumn ParseField(string alFieldContent);
OutputSchema GetOutputSchema();
void ClearOutputSchema();
}
10 changes: 10 additions & 0 deletions src/AL2DBML.CLI/AL2DBML.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,14 @@
<ProjectReference Include="../AL2DBML.DI/AL2DBML.DI.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Spectre.Console.Cli" Version="1.0.0-alpha.0.15" />
</ItemGroup>

<ItemGroup>
<Folder Include="Enums/" />
<Folder Include="Services/" />
<Folder Include="Strategies/" />
</ItemGroup>

</Project>
60 changes: 60 additions & 0 deletions src/AL2DBML.CLI/Commands/GenerateCommand.cs
Original file line number Diff line number Diff line change
@@ -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 <INPUT_PATH>")]
[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 <OUTPUT_PATH>")]
[Description("The path to the output directory.")]
public string OutputPath { get; init; } = ".";

[CommandOption("-n|--name <OUTPUT_NAME>")]
[Description("The name of the output file (without extension). 'schema' by default.")]
public string OutputName { get; init; } = "schema";
}

public class GenerateCommand : AsyncCommand<GenerateSettings>
{
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<int> 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;
Comment on lines +40 to +44

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputType is an AL2DBML.CLI.Enums.InputType, but the comparison uses Enums.InputType.NotSupported, which won’t resolve from this namespace/import set and should fail to compile. Compare against InputType.NotSupported (add the appropriate using) or fully-qualify AL2DBML.CLI.Enums.InputType.NotSupported.

Copilot uses AI. Check for mistakes.
}

var outputPath = Path.Combine(settings.OutputPath, $"{settings.OutputName}.dbml");

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GenerateSettings.OutputPath is documented as "file or directory", but the implementation always treats it as a directory via Path.Combine(settings.OutputPath, ...). If the user passes a file path (e.g., ./out.dbml), this will generate an incorrect path. Consider either (a) documenting --output as a directory only, or (b) detecting when OutputPath is an existing file / ends with .dbml and using it directly, otherwise combining as a directory.

Suggested change
var outputPath = Path.Combine(settings.OutputPath, $"{settings.OutputName}.dbml");
var outputPath = settings.OutputPath;
if (!outputPath.EndsWith(".dbml", StringComparison.OrdinalIgnoreCase))
{
outputPath = Path.Combine(settings.OutputPath, $"{settings.OutputName}.dbml");
}

Copilot uses AI. Check for mistakes.
Directory.CreateDirectory(settings.OutputPath);

var factory = new InputStrategyFactory(inputType, _alParser, _tracker);
var outputSchema = factory.Strategy.Execute(settings.InputPath);

Comment on lines +38 to +52

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because IAlParser is now registered as a singleton and accumulates schema state, this command should clear parser state at the start of execution to avoid leaking results between multiple generate invocations in the same process (or between tests/hosted runs). Call _alParser.ClearOutputSchema() before executing the selected input strategy.

Copilot uses AI. Check for mistakes.
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;
}
}
9 changes: 9 additions & 0 deletions src/AL2DBML.CLI/Enums/InputType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace AL2DBML.CLI.Enums;

public enum InputType
{
Directory,
ALFile,
WorkspaceFile,
NotSupported
}
43 changes: 41 additions & 2 deletions src/AL2DBML.CLI/Program.cs
Original file line number Diff line number Diff line change
@@ -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<GenerateCommand>()
.AddScoped<IParsingTracker, ParsingTracker>();

var registrar = new TypeRegistrar(services);

var app = new CommandApp(registrar);

app.Configure(config =>
{
config.AddCommand<GenerateCommand>("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<object> factory) => services.AddScoped(service, _ => factory());
}

public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver
{
public object? Resolve(Type? type) => type == null ? null : provider.GetService(type);
}
Comment on lines +10 to +41

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeRegistrar.Build() constructs a root ServiceProvider, but the app never creates a scope for command execution. Registering GenerateCommand (and other services) as Scoped won’t behave as intended when resolved from the root provider, and scoped/disposable services may never be disposed. Consider creating a scope per command execution (or switching command/service registrations to transient/singleton as appropriate), and ensure the underlying ServiceProvider is disposed when the app exits.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +41

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DI setup registers several services as AddScoped(...), but TypeRegistrar.Build() builds a root ServiceProvider and TypeResolver.Resolve() pulls services directly from it (no scope created). In Microsoft.Extensions.DependencyInjection this effectively promotes scoped services to root singletons and they won’t be disposed until process exit. Either create a scope per command execution (and resolve from that scope), or switch the relevant registrations to singleton, or use Spectre.Console’s official DI integration to manage scopes/disposal correctly.

Copilot uses AI. Check for mistakes.
29 changes: 29 additions & 0 deletions src/AL2DBML.CLI/Services/FileSystemService.cs
Original file line number Diff line number Diff line change
@@ -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<string> ScanDirectory(string directoryPath)
{
return Directory.GetFiles(directoryPath, "*.al", SearchOption.AllDirectories)
.ToList();
}
}
8 changes: 8 additions & 0 deletions src/AL2DBML.CLI/Services/IParsingTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace AL2DBML.CLI.Services;

public interface IParsingTracker
{
void RecordFile();
int FileCount { get; }
TimeSpan Elapsed { get; }
}
13 changes: 13 additions & 0 deletions src/AL2DBML.CLI/Services/ParsingTracker.cs
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions src/AL2DBML.CLI/Strategies/FolderInputStrategy.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
8 changes: 8 additions & 0 deletions src/AL2DBML.CLI/Strategies/IInputStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using AL2DBML.Core.Models;

namespace AL2DBML.CLI.Strategies;

public interface IInputStrategy
{
OutputSchema Execute(string inputPath);
}
27 changes: 27 additions & 0 deletions src/AL2DBML.CLI/Strategies/InputStrategyFactory.cs
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
45 changes: 45 additions & 0 deletions src/AL2DBML.CLI/Strategies/SingleFileInputStrategy.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
53 changes: 53 additions & 0 deletions src/AL2DBML.CLI/Strategies/WorkspaceInputStrategy.cs
Original file line number Diff line number Diff line change
@@ -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.");
}

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

folders.EnumerateArray() will throw if the workspace JSON has a folders property that isn’t an array (e.g., malformed workspace). Since you’re already validating the schema, consider checking folders.ValueKind == JsonValueKind.Array and throwing a clearer InvalidDataException when it’s not.

Suggested change
}
}
if (folders.ValueKind != JsonValueKind.Array)
{
throw new InvalidDataException("Invalid workspace file: 'folders' property must be an array.");
}

Copilot uses AI. Check for mistakes.
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();
}
}
1 change: 1 addition & 0 deletions src/AL2DBML.DI/ParserServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAlParser, AlParser>();
return services;
}
Expand Down
6 changes: 6 additions & 0 deletions src/AL2DBML.Parser/AlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,10 @@ public OutputSchema GetOutputSchema()
{
return OutputSchemaHelper.DeepCopy(_outputSchema);
}

public void ClearOutputSchema()
{
_outputSchema.Enums.Clear();
_outputSchema.Tables.Clear();
}
}
2 changes: 1 addition & 1 deletion src/AL2DBML.Tests/TestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ protected TestBase()

protected void ResetParser()
{
_parser = Services.GetRequiredService<IAlParser>();
_parser.ClearOutputSchema();
}

protected static string LoadFixture(string path)
Expand Down
Loading