Initial commit

This commit is contained in:
mdnapo 2024-09-15 17:23:27 +02:00
commit 8d1069e477
69 changed files with 2296 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Visual Studio Code
.vscode/
# Rider
.idea/
# Visual Studio
.vs/
# Fleet
.fleet/
# Code Rush
.cr/
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
msbuild.log
msbuild.err
msbuild.wrn

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="NUnit" Version="3.13.3"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DevDisciples.Json.Parser\DevDisciples.Json.Parser.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1 @@
global using NUnit.Framework;

View File

@ -0,0 +1,193 @@
namespace DevDisciples.Json.Parser.Tests;
public class JsonLexerTests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Can_lex_null()
{
const string source = "null";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_null), source);
Assert.That(tokens is
[
{ Type: JsonToken.Null, Lexeme: "null" },
{ Type: JsonToken.EndOfSource }
]);
}
[Test]
public void Can_lex_booleans()
{
const string trueSource = "true";
const string falseSource = "false";
var trueTokens = JsonLexer.Default.Lex(nameof(Can_lex_booleans), trueSource);
var falseTokens = JsonLexer.Default.Lex(nameof(Can_lex_booleans), falseSource);
Assert.Multiple(() =>
{
Assert.That(trueTokens is
[
{ Type: JsonToken.True, Lexeme: "true" },
{ Type: JsonToken.EndOfSource }
]);
Assert.That(falseTokens is
[
{ Type: JsonToken.False, Lexeme: "false" },
{ Type: JsonToken.EndOfSource }
]);
});
}
[Test]
public void Can_lex_an_integer()
{
const string source = "1";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_integer), source);
Assert.That(tokens is
[
{ Type: JsonToken.Number, Lexeme: "1" },
{ Type: JsonToken.EndOfSource }
]);
}
[Test]
public void Can_lex_a_decimal()
{
const string source = "1.0";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_a_decimal), source);
Assert.That(tokens is
[
{ Type: JsonToken.Number, Lexeme: "1.0" },
{ Type: JsonToken.EndOfSource }
]);
}
[Test]
public void Can_lex_a_string()
{
const string source = "\"Hello world!\"";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_a_string), source);
Assert.That(tokens is
[
{ Type: JsonToken.String, Lexeme: "Hello world!" },
{ Type: JsonToken.EndOfSource }
]);
}
[Test]
public void Can_lex_an_empty_array()
{
const string source = "[]";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_empty_array), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBracket, Lexeme: "[" },
{ Type: JsonToken.RightBracket, Lexeme: "]" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
[Test]
public void Can_lex_a_single_item_array()
{
const string source = "[1]";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_a_single_item_array), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBracket, Lexeme: "[" },
{ Type: JsonToken.Number, Lexeme: "1" },
{ Type: JsonToken.RightBracket, Lexeme: "]" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
[Test]
public void Can_lex_an_array_with_multiple_items()
{
const string source = "[1,true,{},[],null]";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_array_with_multiple_items), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBracket, Lexeme: "[" },
{ Type: JsonToken.Number, Lexeme: "1" },
{ Type: JsonToken.Comma, Lexeme: "," },
{ Type: JsonToken.True, Lexeme: "true" },
{ Type: JsonToken.Comma, Lexeme: "," },
{ Type: JsonToken.LeftBrace, Lexeme: "{" },
{ Type: JsonToken.RightBrace, Lexeme: "}" },
{ Type: JsonToken.Comma, Lexeme: "," },
{ Type: JsonToken.LeftBracket, Lexeme: "[" },
{ Type: JsonToken.RightBracket, Lexeme: "]" },
{ Type: JsonToken.Comma, Lexeme: "," },
{ Type: JsonToken.Null, Lexeme: "null" },
{ Type: JsonToken.RightBracket, Lexeme: "]" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
[Test]
public void Can_lex_an_empty_object()
{
const string source = "{}";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_empty_object), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBrace, Lexeme: "{" },
{ Type: JsonToken.RightBrace, Lexeme: "}" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
[Test]
public void Can_lex_an_object_with_one_entry()
{
const string source = "{\"first_name\":\"John\"}";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_object_with_one_entry), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBrace, Lexeme: "{" },
{ Type: JsonToken.String, Lexeme: "first_name" },
{ Type: JsonToken.Colon, Lexeme: ":" },
{ Type: JsonToken.String, Lexeme: "John" },
{ Type: JsonToken.RightBrace, Lexeme: "}" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
[Test]
public void Can_lex_an_object_with_multiple_entries()
{
const string source = "{\"first_name\":\"John\", \"last_name\": \"Doe\"}";
var tokens = JsonLexer.Default.Lex(nameof(Can_lex_an_object_with_multiple_entries), source);
Assert.That(tokens is
[
{ Type: JsonToken.LeftBrace, Lexeme: "{" },
{ Type: JsonToken.String, Lexeme: "first_name" },
{ Type: JsonToken.Colon, Lexeme: ":" },
{ Type: JsonToken.String, Lexeme: "John" },
{ Type: JsonToken.Comma, Lexeme: "," },
{ Type: JsonToken.String, Lexeme: "last_name" },
{ Type: JsonToken.Colon, Lexeme: ":" },
{ Type: JsonToken.String, Lexeme: "Doe" },
{ Type: JsonToken.RightBrace, Lexeme: "}" },
{ Type: JsonToken.EndOfSource }
], Is.True);
}
}

View File

@ -0,0 +1,132 @@
namespace DevDisciples.Json.Parser.Tests;
public class JsonParserTests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Can_parse_null()
{
const string source = "null";
var node = JsonParser.Parse(nameof(Can_parse_null), source);
Assert.That(node is JsonNull);
}
[Test]
public void Can_parse_booleans()
{
const string trueSource = "true";
const string falseSource = "false";
var trueNode = JsonParser.Parse(nameof(Can_parse_booleans), trueSource);
var falseNode = JsonParser.Parse(nameof(Can_parse_booleans), falseSource);
Assert.Multiple(() =>
{
Assert.That(trueNode is JsonBool { Value: true });
Assert.That(falseNode is JsonBool { Value: false });
});
}
[Test]
public void Can_parse_an_integer()
{
const string source = "1";
var node = JsonParser.Parse(nameof(Can_parse_an_integer), source);
Assert.That(node is JsonNumber { Value: 1 });
}
[Test]
public void Can_lex_a_decimal()
{
const string source = "1.0";
var node = JsonParser.Parse(nameof(Can_lex_a_decimal), source);
Assert.That(node is JsonNumber { Value: 1.0 });
}
[Test]
public void Can_parse_a_string()
{
const string source = "\"Hello world!\"";
var tokens = JsonParser.Parse(nameof(Can_parse_a_string), source);
Assert.That(tokens is JsonString { Value: "Hello world!" });
}
[Test]
public void Can_parse_an_empty_array()
{
const string source = "[]";
var tokens = JsonParser.Parse(nameof(Can_parse_an_empty_array), source);
Assert.That(tokens is JsonArray);
}
[Test]
public void Can_parse_a_single_item_array()
{
const string source = "[1]";
var tokens = JsonParser.Parse(nameof(Can_parse_a_single_item_array), source);
Assert.That(tokens is JsonArray
{
Elements: [JsonNumber { Value: 1 }]
});
}
[Test]
public void Can_parse_an_array_with_multiple_items()
{
const string source = "[1,true,{},[],null]";
var node = JsonParser.Parse(nameof(Can_parse_an_array_with_multiple_items), source);
Assert.That(node is JsonArray
{
Elements:
[
JsonNumber { Value: 1 },
JsonBool { Value: true },
JsonObject { Properties: null},
JsonArray { Elements: null },
JsonNull
]
});
}
[Test]
public void Can_parse_an_empty_object()
{
const string source = "{}";
var tokens = JsonParser.Parse(nameof(Can_parse_an_empty_object), source);
Assert.That(tokens is JsonObject { Properties: null });
}
[Test]
public void Can_parse_an_object_with_one_entry()
{
const string source = "{\"first_name\":\"John\nDoe\"}";
var node = JsonParser.Parse(nameof(Can_parse_an_object_with_one_entry), source);
Assert.That(node is JsonObject { Properties.Count: 1 });
var @object = (JsonObject)node;
Assert.That(@object.Properties.ContainsKey("first_name"));
Assert.That(@object.Properties["first_name"] is JsonString { Value: "John" });
}
[Test]
public void Can_parse_an_object_with_multiple_entries()
{
const string source = "{\"first_name\":\"John\", \"last_name\": \"Doe\"}";
var node = JsonParser.Parse(nameof(Can_parse_an_object_with_one_entry), source);
Assert.Multiple(() =>
{
Assert.That(node is JsonObject { Properties.Count: 2 });
var @object = (JsonObject)node;
Assert.That(@object.Properties.ContainsKey("first_name"));
Assert.That(@object.Properties["first_name"] is JsonString { Value: "John" });
Assert.That(@object.Properties.ContainsKey("last_name"));
Assert.That(@object.Properties["last_name"] is JsonString { Value: "Doe" });
});
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DevDisciples.Parsing\DevDisciples.Parsing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="3.0.0-beta.54" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,15 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonArray : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public List<ISyntaxNode> Elements { get; }
public JsonArray(Lexer<JsonToken>.Token token, List<ISyntaxNode>? elements)
{
Token = token;
Elements = elements ?? new();
}
}

View File

@ -0,0 +1,14 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonBool : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public bool Value => bool.TryParse(Token.Lexeme, out var val) && val;
public JsonBool(Lexer<JsonToken>.Token token)
{
Token = token;
}
}

View File

@ -0,0 +1,32 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public class JsonLexer : Lexer<JsonToken>
{
public static readonly JsonLexer Default = new();
protected override JsonToken EndOfSource => JsonToken.EndOfSource;
public JsonLexer()
{
Rules =
[
DefaultRule.NewLine,
DefaultRule.Number(JsonToken.Number),
DefaultRule.DoubleQuoteString(JsonToken.String),
ctx => DefaultRule.IgnoreWhitespace(ctx),
ctx => Match(ctx, JsonToken.Minus, '-'),
ctx => Match(ctx, JsonToken.LeftBrace, '{'),
ctx => Match(ctx, JsonToken.Colon, ':'),
ctx => Match(ctx, JsonToken.RightBrace, '}'),
ctx => Match(ctx, JsonToken.LeftBracket, '['),
ctx => Match(ctx, JsonToken.Comma, ','),
ctx => Match(ctx, JsonToken.RightBracket, ']'),
ctx => Match(ctx, JsonToken.True, "true"),
ctx => Match(ctx, JsonToken.False, "false"),
ctx => Match(ctx, JsonToken.Null, "null"),
];
}
}

View File

@ -0,0 +1,13 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonNull : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public JsonNull(Lexer<JsonToken>.Token token)
{
Token = token;
}
}

View File

@ -0,0 +1,14 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonNumber : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public double Value => double.TryParse(Token.Lexeme.Replace('.', ','), out var val) ? val : default;
public JsonNumber(Lexer<JsonToken>.Token token)
{
Token = token;
}
}

View File

@ -0,0 +1,15 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonObject : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public Dictionary<string, ISyntaxNode> Properties { get; set; }
public JsonObject(Lexer<JsonToken>.Token token, Dictionary<string, ISyntaxNode>? properties)
{
Token = token;
Properties = properties ?? new();
}
}

View File

@ -0,0 +1,13 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public static partial class JsonParser
{
private class Context : ParserContext<JsonToken>
{
public Context(Memory<Lexer<JsonToken>.Token> tokens) : base(tokens, JsonToken.EndOfSource)
{
}
}
}

View File

@ -0,0 +1,95 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public static partial class JsonParser
{
public static ISyntaxNode Parse(string file, string source)
{
var tokens = JsonLexer.Default.Lex(file, source).ToArray();
var context = new Context(tokens);
var nodes = Expression(context);
return nodes;
}
private static ISyntaxNode Expression(ParserContext<JsonToken> ctx)
{
if (ctx.Match(JsonToken.LeftBracket))
return Array(ctx);
if (ctx.Match(JsonToken.LeftBrace))
return Object(ctx);
if (ctx.Match(JsonToken.Minus) || ctx.Match(JsonToken.Number))
return Number(ctx);
if (ctx.Match(JsonToken.String))
return String(ctx);
if (ctx.Match(JsonToken.Null))
return Null(ctx);
if (ctx.Match(JsonToken.True) || ctx.Match(JsonToken.False))
return Bool(ctx);
throw Report.Error(ctx.Current, $"Expected a JSON expression, got '{ctx.Current.Lexeme}'");
}
private static ISyntaxNode Array(ParserContext<JsonToken> ctx)
{
var previous = ctx.Previous();
List<ISyntaxNode>? elements = null;
if (!ctx.Check(JsonToken.RightBracket))
{
do
{
elements ??= new();
elements.Add(Expression(ctx));
} while (ctx.Match(JsonToken.Comma));
}
ctx.Consume(JsonToken.RightBracket, "Expected ']'");
return new JsonArray(previous, elements);
}
private static ISyntaxNode Object(ParserContext<JsonToken> ctx)
{
var previous = ctx.Previous();
Dictionary<string, ISyntaxNode>? properties = null;
if (!ctx.Check(JsonToken.RightBrace))
{
do
{
var key = ctx.Consume(JsonToken.String, "Expected property name");
ctx.Consume(JsonToken.Colon, "Expected ':' after property name");
properties ??= new();
properties[key.Lexeme] = Expression(ctx);
} while (ctx.Match(JsonToken.Comma));
}
ctx.Consume(JsonToken.RightBrace, "Expected '}'");
return new JsonObject(previous, properties);
}
private static ISyntaxNode Number(ParserContext<JsonToken> ctx)
{
if (ctx.Previous().Type != JsonToken.Minus) return new JsonNumber(ctx.Previous());
var minus = ctx.Previous();
var number = ctx.Consume(JsonToken.Number, "Expected a number after '-'.");
return new JsonNumber(
new Lexer<JsonToken>.Token(minus.File, JsonToken.Number, $"-{number.Lexeme}", minus.Line, minus.Column)
);
}
private static ISyntaxNode String(ParserContext<JsonToken> ctx) => new JsonString(ctx.Previous());
private static ISyntaxNode Null(ParserContext<JsonToken> ctx) => new JsonNull(ctx.Previous());
private static ISyntaxNode Bool(ParserContext<JsonToken> ctx) => new JsonBool(ctx.Previous());
}

View File

@ -0,0 +1,14 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Parser;
public struct JsonString : ISyntaxNode
{
public Lexer<JsonToken>.Token Token { get; }
public string Value => Token.Lexeme;
public JsonString(Lexer<JsonToken>.Token token)
{
Token = token;
}
}

View File

@ -0,0 +1,18 @@
namespace DevDisciples.Json.Parser;
public enum JsonToken
{
LeftBracket,
RightBracket,
LeftBrace,
RightBrace,
String,
Minus,
Number,
Comma,
Colon,
Null,
True,
False,
EndOfSource
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,44 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:57723",
"sslPort": 44385
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5238",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7137;http://localhost:5238",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,10 @@
namespace DevDisciples.Json.Tools.CLI;
public class CommandOptions
{
public InputOptions Input { get; set; }
public FileInfo? InputFile { get; set; }
public string? InputArgument { get; set; }
public OutputOptions Output { get; set; }
public FileInfo? OutputFile { get; set; }
}

View File

@ -0,0 +1,10 @@
using System.CommandLine.Binding;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public class CommandOptionsBinder : BinderBase<CommandOptions>
{
protected override CommandOptions GetBoundValue(BindingContext context) =>
new CommandOptions().WithCommonBindings(context);
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackAsTool>true</PackAsTool>
<ToolCommandName>jtr</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="TextCopy" Version="6.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DevDisciples.Json.Parser\DevDisciples.Json.Parser.csproj" />
<ProjectReference Include="..\DevDisciples.Json.Tools\DevDisciples.Json.Tools.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
using System.CommandLine;
namespace DevDisciples.Json.Tools.CLI.Extensions;
public static class CommandExtensions
{
public static void AddIOCommandOptions(this Command command)
{
command.AddOption(SharedCommandOptions.InputOption);
command.AddOption(SharedCommandOptions.InputFileOption);
command.AddOption(SharedCommandOptions.OutputOption);
command.AddOption(SharedCommandOptions.OutputFileOption);
command.AddArgument(SharedCommandOptions.InputArgument);
}
}

View File

@ -0,0 +1,17 @@
using System.CommandLine.Binding;
namespace DevDisciples.Json.Tools.CLI.Extensions;
public static class CommandOptionsExtensions
{
public static T WithCommonBindings<T>(this T options, BindingContext context)
where T : CommandOptions
{
options.Input = context.ParseResult.GetValueForOption(SharedCommandOptions.InputOption);
options.InputFile = context.ParseResult.GetValueForOption(SharedCommandOptions.InputFileOption);
options.InputArgument = context.ParseResult.GetValueForArgument(SharedCommandOptions.InputArgument);
options.Output = context.ParseResult.GetValueForOption(SharedCommandOptions.OutputOption);
options.OutputFile = context.ParseResult.GetValueForOption(SharedCommandOptions.OutputFileOption);
return options;
}
}

View File

@ -0,0 +1,74 @@
using TextCopy;
namespace DevDisciples.Json.Tools.CLI;
public static class IOHandler
{
public static async Task<string> HandleInput(InputOptions input, string? inputArgument, FileInfo? inputFile)
{
string json;
switch (input)
{
case InputOptions.s:
case InputOptions.std:
case InputOptions.stdin:
json = inputArgument ?? string.Empty;
break;
case InputOptions.c:
case InputOptions.clip:
case InputOptions.clipboard:
json = await ClipboardService.GetTextAsync() ?? string.Empty;
break;
case InputOptions.f:
case InputOptions.file:
if (inputFile is null)
throw new ArgumentException("Input file was not specified.");
if (!inputFile.Exists)
throw new FileNotFoundException($"File {inputFile.FullName} does not exist.");
json = await File.ReadAllTextAsync(inputFile.FullName);
break;
default:
throw new Exception($"Input type '{input}' is not supported.");
}
return json.Trim();
}
public static async Task HandleOutput(OutputOptions output, FileInfo? outputFile, string result)
{
switch (output)
{
case OutputOptions.s:
case OutputOptions.std:
case OutputOptions.stdout:
Console.WriteLine(result);
break;
case OutputOptions.c:
case OutputOptions.clip:
case OutputOptions.clipboard:
await ClipboardService.SetTextAsync(result);
break;
case OutputOptions.f:
case OutputOptions.file:
if (outputFile is null)
throw new ArgumentException("Output file was not specified.");
if (outputFile.Exists)
throw new Exception($"File {outputFile.FullName} already exists.");
await File.WriteAllTextAsync(outputFile.FullName, result);
break;
default:
throw new Exception($"Output type '{output}' is not supported.");
}
}
}

View File

@ -0,0 +1,18 @@
namespace DevDisciples.Json.Tools.CLI;
public enum InputOptions
{
// Standard input
stdin,
std,
s,
// Clipboard
clipboard,
clip,
c,
// File
file,
f,
}

View File

@ -0,0 +1,10 @@
namespace DevDisciples.Json.Tools.CLI;
public partial class Json2CSharpCommand
{
public class CommandOptions : CLI.CommandOptions
{
public string? RootClassName { get; set; }
public string? Namespace { get; set; }
}
}

View File

@ -0,0 +1,17 @@
using System.CommandLine.Binding;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public partial class Json2CSharpCommand
{
public class CommandOptionsBinder : BinderBase<CommandOptions>
{
protected override CommandOptions GetBoundValue(BindingContext context) =>
new CommandOptions
{
RootClassName = context.ParseResult.GetValueForOption(RootClassNameOption),
Namespace = context.ParseResult.GetValueForOption(NamespaceOption),
}.WithCommonBindings(context);
}
}

View File

@ -0,0 +1,45 @@
using System.CommandLine;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public partial class Json2CSharpCommand : Command
{
private static readonly Option<string> RootClassNameOption =
new(
aliases: ["--root-class", "--root", "-r"],
description: "The name of the root class",
getDefaultValue: () => Json2CSharpTranslator.Context.DefaultRootClassName
);
private static readonly Option<string> NamespaceOption = new(
aliases: ["--namespace", "--ns", "-n",],
description: "The namespace to use",
getDefaultValue: () => Json2CSharpTranslator.Context.DefaultNamespace
);
public Json2CSharpCommand() : base("2csharp", "Transform a JSON object into a C# class")
{
AddAlias("2cs");
AddAlias("2c#");
this.AddIOCommandOptions();
AddOption(RootClassNameOption);
AddOption(NamespaceOption);
this.SetHandler(ExecuteAsync, new CommandOptionsBinder());
}
private static async Task ExecuteAsync(CommandOptions options)
{
var json = await IOHandler.HandleInput(options.Input, options.InputArgument, options.InputFile);
var output = Json2CSharpTranslator.Translate(json, new()
{
RootClassName = options.RootClassName ?? Json2CSharpTranslator.Context.DefaultRootClassName,
Namespace = options.Namespace ?? Json2CSharpTranslator.Context.DefaultNamespace
});
await IOHandler.HandleOutput(options.Output, options.OutputFile, output);
}
}

View File

@ -0,0 +1,9 @@
namespace DevDisciples.Json.Tools.CLI;
public partial class JsonPrettifyCommand
{
public class CommandOptions : CLI.CommandOptions
{
public int IndentSize { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using System.CommandLine.Binding;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public partial class JsonPrettifyCommand
{
public class CommandOptionsBinder : BinderBase<CommandOptions>
{
protected override CommandOptions GetBoundValue(BindingContext context) =>
new CommandOptions
{
IndentSize = context.ParseResult.GetValueForOption(IndentSizeOption)
}.WithCommonBindings(context);
}
}

View File

@ -0,0 +1,32 @@
using System.CommandLine;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public partial class JsonPrettifyCommand : Command
{
private static readonly Option<int> IndentSizeOption =
new(aliases: ["--indent", "--is"], description: "The indent size", getDefaultValue: () => 2);
public JsonPrettifyCommand() : base("prettify", "Prettify JSON")
{
AddAlias("p");
this.AddIOCommandOptions();
AddOption(IndentSizeOption);
this.SetHandler(ExecuteAsync, new CommandOptionsBinder());
}
private static async Task ExecuteAsync(CommandOptions options)
{
var json = await IOHandler.HandleInput(options.Input, options.InputArgument, options.InputFile);
var output = JsonFormatter.Format(json, new()
{
Beautify = true,
IndentSize = options.IndentSize
});
await IOHandler.HandleOutput(options.Output, options.OutputFile, output);
}
}

View File

@ -0,0 +1,23 @@
using System.CommandLine;
using DevDisciples.Json.Tools.CLI.Extensions;
namespace DevDisciples.Json.Tools.CLI;
public class JsonUglifyCommand : Command
{
public JsonUglifyCommand() : base("uglify", "Uglify JSON")
{
AddAlias("u");
this.AddIOCommandOptions();
this.SetHandler(ExecuteAsync, new CommandOptionsBinder());
}
private static async Task ExecuteAsync(CommandOptions options)
{
var json = await IOHandler.HandleInput(options.Input, options.InputArgument, options.InputFile);
var output = JsonFormatter.Format(json, new() { Beautify = false });
await IOHandler.HandleOutput(options.Output, options.OutputFile, output);
}
}

View File

@ -0,0 +1,18 @@
namespace DevDisciples.Json.Tools.CLI;
public enum OutputOptions
{
// Standard output
stdout,
std,
s,
// Clipboard
clipboard,
clip,
c,
// File
file,
f,
}

View File

@ -0,0 +1,21 @@
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using DevDisciples.Json.Tools.CLI;
var parser = new CommandLineBuilder(new RootCommand())
.UseDefaults()
.UseExceptionHandler((ex, ctx) =>
{
/*
* Use a custom ExceptionHandler to prevent the System.CommandLine library from printing the StackTrace by default.
*/
Console.WriteLine(ex.Message);
/*
* Set the exit code to a non-zero value to indicate to the shell that the process has failed.
*/
Environment.ExitCode = -1;
})
.Build();
await parser.InvokeAsync(args);

View File

@ -0,0 +1,13 @@
namespace DevDisciples.Json.Tools.CLI;
public class RootCommand : System.CommandLine.RootCommand
{
public override string Name => "jtr";
public RootCommand() : base("A JSON transform CLI tool by DevDisciples.")
{
AddCommand(new JsonUglifyCommand());
AddCommand(new JsonPrettifyCommand());
AddCommand(new Json2CSharpCommand());
}
}

View File

@ -0,0 +1,25 @@
using System.CommandLine;
namespace DevDisciples.Json.Tools.CLI;
public static class SharedCommandOptions
{
public static readonly Option<InputOptions> InputOption =
new(aliases: ["--input", "-i"], getDefaultValue: () => InputOptions.stdin) { IsRequired = false };
public static readonly Argument<string?> InputArgument =
new(name: "input", description: "The input argument.", getDefaultValue: () => default);
public static readonly Option<FileInfo> InputFileOption = new(
aliases: ["--input-file", "--if"],
description: "Read the input from a file. This option is required when using an input of type file."
);
public static readonly Option<FileInfo> OutputFileOption = new(
aliases: ["--output-file", "--of"],
description: "Write the output to a file. This option is required when using an output of type file."
);
public static readonly Option<OutputOptions> OutputOption =
new(aliases: ["--output", "-o"], getDefaultValue: () => OutputOptions.stdout) { IsRequired = false };
}

View File

@ -0,0 +1,5 @@
#!/usr/bin/bash
dotnet pack -v d
dotnet tool update --global --add-source ./nupkg DevDisciples.Json.Tools.CLI -v d

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DevDisciples.Json.Parser\DevDisciples.Json.Parser.csproj" />
<ProjectReference Include="..\DevDisciples.Parsing\DevDisciples.Parsing.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,27 @@
using Humanizer;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public struct ClassTranslation : ITranslation
{
public string Name { get; set; }
public List<PropertyTranslation> Properties { get; set; }
public void Translate(Context context)
{
context.Builder.Append($"public class {Name.Pascalize()}\n");
context.Builder.Append("{\n");
var last = Properties.Last();
foreach (var property in Properties)
{
property.Translate(context);
context.Builder.Append(property.Equals(last) ? string.Empty : "\n");
}
context.Builder.Append("}\n");
}
}
}

View File

@ -0,0 +1,17 @@
using System.Text;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public class Context
{
public const string DefaultRootClassName = "Root";
public const string DefaultNamespace = "My.Namespace";
public string RootClassName { get; set; } = DefaultRootClassName;
public string Namespace { get; set; } = DefaultNamespace;
public List<ClassTranslation> Classes { get; set; } = new();
public readonly StringBuilder Builder = new();
}
}

View File

@ -0,0 +1,11 @@
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public interface ITranslation
{
public string Name { get; }
public void Translate(Context context);
}
}

View File

@ -0,0 +1,64 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
using Humanizer;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonArrayTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
var context = ContextFromArgs(args);
var name = NameFromArgs(args);
var array = (JsonArray)visitee;
var type = "object";
if (array.Elements.All(e => e is JsonObject))
{
context.Classes.Add(SquashObjects(name, array.Elements, args));
type = context.Classes.Last().Name;
}
if (array.Elements.All(e => e is JsonString es && DateTime.TryParse(es.Value, out _)))
{
type = "DateTime";
}
else if (array.Elements.All(e => e is JsonString))
{
type = "string";
}
else if (array.Elements.All(e => e is JsonNumber))
{
type = array.Elements.Any(e => ((JsonNumber)e).Token.Lexeme.Contains('.')) ? "double" : "int";
}
return new PropertyTranslation
{
Type = $"List<{type}>",
Name = name,
};
}
private static ClassTranslation SquashObjects(string className, List<ISyntaxNode> objects, object[] args)
{
var classes = objects
.Select(@object => JsonObjectTranslator.Translate(@object, args))
.ToArray();
var squashed = new ClassTranslation
{
Name = className.Singularize(),
Properties = new()
};
foreach (var @class in classes)
foreach (var prop in ((ClassTranslation)@class).Properties)
if (squashed.Properties.All(p => p.Name != prop.Name))
squashed.Properties.Add(prop);
return squashed;
}
}
}

View File

@ -0,0 +1,18 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonBoolTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
return new PropertyTranslation
{
Type = "bool",
Name = NameFromArgs(args),
};
}
}
}

View File

@ -0,0 +1,18 @@
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonNullTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
return new PropertyTranslation
{
Type = "object",
Name = NameFromArgs(args),
};
}
}
}

View File

@ -0,0 +1,19 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonNumberTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
return new PropertyTranslation
{
Type = ((JsonNumber)visitee).Token.Lexeme.Contains('.') ? "double" : "int",
Name = NameFromArgs(args),
};
}
}
}

View File

@ -0,0 +1,42 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonObjectTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
var context = ContextFromArgs(args);
var name = NameFromArgs(args);
var @class = new ClassTranslation { Name = name, Properties = new() };
var @object = (JsonObject)visitee;
context.Classes.Add(@class);
foreach (var prop in @object.Properties)
{
var visitor = Visitors[prop.Value.GetType()];
var translation = visitor(prop.Value, context, prop.Key);
switch (translation)
{
case ClassTranslation:
@class.Properties.Add(new PropertyTranslation
{
Type = translation.Name,
Name = translation.Name,
});
break;
case PropertyTranslation property:
@class.Properties.Add(property);
break;
}
}
return @class;
}
}
}

View File

@ -0,0 +1,22 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static class JsonStringTranslator
{
public static ITranslation Translate(ISyntaxNode visitee, object[] args)
{
var @string = (JsonString)visitee;
var type = DateTime.TryParse(@string.Value, out _) ? "DateTime" : "string";
return new PropertyTranslation
{
Type = type,
Name = NameFromArgs(args),
};
}
}
}

View File

@ -0,0 +1,17 @@
using Humanizer;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public struct PropertyTranslation : ITranslation
{
public string Name { get; set; }
public string Type { get; set; }
public void Translate(Context context)
{
context.Builder.Append($"\tpublic {Type} {Name.Pascalize()} {{ get; set; }}\n");
}
}
}

View File

@ -0,0 +1,44 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
using Humanizer;
namespace DevDisciples.Json.Tools;
public static partial class Json2CSharpTranslator
{
public static readonly VisitorContainer<ISyntaxNode, ITranslation> Visitors;
static Json2CSharpTranslator()
{
Visitors = new();
Visitors.Register<JsonObject>(JsonObjectTranslator.Translate);
Visitors.Register<JsonArray>(JsonArrayTranslator.Translate);
Visitors.Register<JsonString>(JsonStringTranslator.Translate);
Visitors.Register<JsonNumber>(JsonNumberTranslator.Translate);
Visitors.Register<JsonBool>(JsonBoolTranslator.Translate);
Visitors.Register<JsonNull>(JsonNullTranslator.Translate);
}
public static string Translate(string source, Context? context = null)
{
if (JsonParser.Parse("<source>", source) is not JsonObject root)
throw new Exception("Expected a JSON object.");
context ??= new();
var visitor = Visitors[typeof(JsonObject)];
visitor(root, context, context.RootClassName);
context.Builder.Append("//using System;\n");
context.Builder.Append("//using System.Collections.Generic;\n");
context.Builder.Append('\n');
context.Builder.Append($"namespace {context.Namespace};\n\n");
context.Classes.ForEach(@class => @class.Translate(context));
return context.Builder.ToString();
}
private static Context ContextFromArgs(object[] args) => (args[0] as Context)!;
private static string NameFromArgs(object[] args) => ((string)args[1]).Camelize();
}

View File

@ -0,0 +1,20 @@
using System.Text;
namespace DevDisciples.Json.Tools;
public static partial class JsonFormatter
{
public class Context
{
protected int Depth { get; set; } = 0;
public StringBuilder Builder { get; set; } = new();
public bool Beautify { get; set; } = false;
public string Indent => new(' ', Depth);
public int IndentSize { get; set; } = 2;
public string NewLine => Beautify ? "\n" : "";
public string Space => Beautify ? " " : "";
public void IncrementDepth() => Depth += Beautify ? IndentSize : 0;
public void DecrementDepth() => Depth -= Beautify ? IndentSize : 0;
}
}

View File

@ -0,0 +1,106 @@
using DevDisciples.Json.Parser;
using DevDisciples.Parsing;
namespace DevDisciples.Json.Tools;
public static partial class JsonFormatter
{
public static VisitorContainer Visitors { get; }
static JsonFormatter()
{
Visitors = new();
Visitors.Register<JsonArray>(PrintArray);
Visitors.Register<JsonObject>(PrintObject);
Visitors.Register<JsonString>(PrintString);
Visitors.Register<JsonNumber>(PrintNumber);
Visitors.Register<JsonBool>(PrintBool);
Visitors.Register<JsonNull>(PrintNull);
}
public static string Format(string source, Context? context)
{
var nodes = JsonParser.Parse("<source>", source);
return Format(nodes, context);
}
private static string Format(ISyntaxNode visitee, Context? context = null)
{
var ctx = context ?? new();
Visitors[visitee.GetType()](visitee, ctx);
return context!.Builder.ToString();
}
private static Context ContextFromArgs(object[] args)
{
return (args[0] as Context)!;
}
private static void PrintArray(object visitee, object[] args)
{
var context = ContextFromArgs(args);
var array = (JsonArray)visitee;
context.Builder.Append($"[{context.NewLine}");
context.IncrementDepth();
for (var i = 0; i < array.Elements.Count; i++)
{
var node = array.Elements[i];
context.Builder.Append(context.Indent);
Visitors[node.GetType()](node, args);
if (i < array.Elements.Count - 1) context.Builder.Append($",{context.NewLine}");
}
context.DecrementDepth();
context.Builder.Append($"{context.NewLine}{context.Indent}]");
}
private static void PrintObject(object visitee, object[] args)
{
var context = ContextFromArgs(args);
var @object = (JsonObject)visitee;
context.Builder.Append($"{{{context.NewLine}");
context.IncrementDepth();
var count = @object.Properties.Count;
for (var i = 0; i < count; i++)
{
var (key, node) = @object.Properties.ElementAt(i);
context.Builder.Append($"{context.Indent}\"{key}\":{context.Space}");
Visitors[node.GetType()](node, args);
if (i < count - 1) context.Builder.Append($",{context.NewLine}");
}
context.DecrementDepth();
context.Builder.Append($"{context.NewLine}{context.Indent}}}");
}
private static void PrintString(object visitee, object[] args)
{
var context = ContextFromArgs(args);
var @string = (JsonString)visitee;
context.Builder.Append($"\"{@string.Token.Lexeme}\"");
}
private static void PrintNumber(object visitee, object[] args)
{
var context = ContextFromArgs(args);
var number = (JsonNumber)visitee;
context.Builder.Append($"{number.Value}");
}
private static void PrintBool(object visitee, object[] args)
{
var context = ContextFromArgs(args);
var @bool = (JsonBool)visitee;
context.Builder.Append($"{@bool.Value.ToString().ToLower()}");
}
private static void PrintNull(object visitee, object[] args)
{
var context = ContextFromArgs(args);
context.Builder.Append("null");
}
}

46
DevDisciples.Parsing.sln Normal file
View File

@ -0,0 +1,46 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Parsing", "DevDisciples.Parsing\DevDisciples.Parsing.csproj", "{DE9A4F6A-08EF-4DFE-99B6-1986BEDB4FA2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Json.Parser", "DevDisciples.Json.Parser\DevDisciples.Json.Parser.csproj", "{50AB3E1D-B9E0-481D-936E-9ADE737EF593}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Json.Parser.Tests", "DevDisciples.Json.Parser.Tests\DevDisciples.Json.Parser.Tests.csproj", "{F89D0862-4DBD-4D71-8B1B-D6E9E684005D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Json.Tools.CLI", "DevDisciples.Json.Tools.CLI\DevDisciples.Json.Tools.CLI.csproj", "{A3CFA61D-0EA5-4D31-B438-885035B51198}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Json.Tools", "DevDisciples.Json.Tools\DevDisciples.Json.Tools.csproj", "{44BBB630-B403-49C0-B973-9A2E9CAF93CD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevDisciples.Json.Tools.API", "DevDisciples.Json.Tools.API\DevDisciples.Json.Tools.API.csproj", "{0E19CB86-C12D-4A6E-B53F-F36FD14BA200}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DE9A4F6A-08EF-4DFE-99B6-1986BEDB4FA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DE9A4F6A-08EF-4DFE-99B6-1986BEDB4FA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE9A4F6A-08EF-4DFE-99B6-1986BEDB4FA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE9A4F6A-08EF-4DFE-99B6-1986BEDB4FA2}.Release|Any CPU.Build.0 = Release|Any CPU
{50AB3E1D-B9E0-481D-936E-9ADE737EF593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{50AB3E1D-B9E0-481D-936E-9ADE737EF593}.Debug|Any CPU.Build.0 = Debug|Any CPU
{50AB3E1D-B9E0-481D-936E-9ADE737EF593}.Release|Any CPU.ActiveCfg = Release|Any CPU
{50AB3E1D-B9E0-481D-936E-9ADE737EF593}.Release|Any CPU.Build.0 = Release|Any CPU
{F89D0862-4DBD-4D71-8B1B-D6E9E684005D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F89D0862-4DBD-4D71-8B1B-D6E9E684005D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F89D0862-4DBD-4D71-8B1B-D6E9E684005D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F89D0862-4DBD-4D71-8B1B-D6E9E684005D}.Release|Any CPU.Build.0 = Release|Any CPU
{A3CFA61D-0EA5-4D31-B438-885035B51198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A3CFA61D-0EA5-4D31-B438-885035B51198}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3CFA61D-0EA5-4D31-B438-885035B51198}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3CFA61D-0EA5-4D31-B438-885035B51198}.Release|Any CPU.Build.0 = Release|Any CPU
{44BBB630-B403-49C0-B973-9A2E9CAF93CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{44BBB630-B403-49C0-B973-9A2E9CAF93CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44BBB630-B403-49C0-B973-9A2E9CAF93CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44BBB630-B403-49C0-B973-9A2E9CAF93CD}.Release|Any CPU.Build.0 = Release|Any CPU
{0E19CB86-C12D-4A6E-B53F-F36FD14BA200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E19CB86-C12D-4A6E-B53F-F36FD14BA200}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E19CB86-C12D-4A6E-B53F-F36FD14BA200}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E19CB86-C12D-4A6E-B53F-F36FD14BA200}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace DevDisciples.Parsing;
public interface ISourceLocation
{
public string File { get; }
public int Line { get; }
public int Column { get; }
}

View File

@ -0,0 +1,3 @@
namespace DevDisciples.Parsing;
public interface ISyntaxNode { }

View File

@ -0,0 +1,29 @@
namespace DevDisciples.Parsing;
public abstract partial class Lexer<TToken> where TToken : Enum
{
public class Context
{
public string File { get; }
public Source Source { get; }
public List<Token> Tokens { get; }
public Context(string file, Source source, List<Token> tokens)
{
File = file;
Source = source;
Tokens = tokens;
}
public void AddToken(TToken type, string lexeme, int line = -1, int column = -1)
{
Tokens.Add(new Token(
File,
type,
lexeme,
line == -1 ? Source.Line : line,
column == -1 ? Source.Column : column
));
}
}
}

View File

@ -0,0 +1,213 @@
namespace DevDisciples.Parsing;
public abstract partial class Lexer<TToken> where TToken : Enum
{
public delegate bool Rule(Context ctx);
protected static class DefaultRule
{
private const int DefaultSpaceSize = 1;
private const int DefaultTabSize = 4;
public static bool IgnoreWhitespace(Context context, int space = DefaultSpaceSize, int tab = DefaultTabSize)
{
space = space <= 0 ? DefaultSpaceSize : space;
tab = tab <= 0 ? DefaultTabSize : tab;
var src = context.Source;
if (src.Match('\r'))
{
src.Ignore();
return true;
}
if (src.Match(' '))
{
src.Ignore();
src.Column += space;
return true;
}
if (src.Match('\t'))
{
src.Ignore();
src.Column += tab;
return true;
}
return false;
}
public static bool NewLine(Context context)
{
if (!context.Source.Match('\n')) return false;
context.Source.Line++;
context.Source.Column = 1;
context.Source.Ignore();
return true;
}
public static Rule SingleQuoteString(TToken token)
{
return context =>
{
if (!context.Source.Match('\'')) return false;
var src = context.Source;
int line = src.Line, column = src.Column;
while (src.Peek() != '\'' && !src.Ended())
{
// Account for source lines and columns when dealing with a string spanning multiple lines.
if (src.Peek() == '\n')
{
src.Line++;
src.Column = 1;
}
src.Advance();
}
if (src.Ended())
{
throw new ParsingException(
$"[line: {src.Line}, column: {src.Column}] Unterminated string near '{src.Last}'."
);
}
// Include the closing quotation mark.
src.Advance();
var lexeme = src.Extract();
context.AddToken(
token,
lexeme.Trim('\''),
line,
column
);
return true;
};
}
public static Rule DoubleQuoteString(TToken token)
{
return context =>
{
if (!context.Source.Match('"')) return false;
var src = context.Source;
int line = src.Line, column = src.Column;
while (src.Peek() != '"' && !src.Ended())
{
// Account for source lines and columns when dealing with a string spanning multiple lines.
if (src.Peek() == '\n')
{
src.Line++;
src.Column = 0;
}
src.Advance();
}
if (src.Ended())
{
throw new ParsingException(
$"[line: {src.Line}, column: {src.Column}] Unterminated string near '{src.Last}'."
);
}
// Include the closing quotation mark.
src.Advance();
var lexeme = src.Extract();
context.AddToken(
token,
lexeme.Trim('"'),
line,
column
);
return true;
};
}
public static Rule Identifier(TToken token)
{
return context =>
{
var src = context.Source;
if (!IsAlpha(src.Peek())) return false;
int line = src.Line, column = src.Column;
while (IsAlphaNumeric(src.Peek())) src.Advance();
var lexeme = src.Extract();
context.AddToken(token, lexeme, line, column);
return true;
};
}
// public static bool DoubleSlashComment(Context context)
// {
// var src = context.Source;
//
// if (!src.Match("//")) return false;
//
// while (!src.Ended() && src.Peek() != '\n') src.Advance();
//
// src.Ignore();
//
// return true;
// }
private static bool IsAlpha(char c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_';
private static bool IsAlphaNumeric(char c) => IsAlpha(c) || IsDigit(c);
public static Rule Number(TToken token)
{
return context =>
{
var src = context.Source;
if (!IsDigit(src.Peek())) return false;
int line = src.Line, column = src.Column;
ProcessDigits(src);
if (src.Peek() == '.' && IsDigit(src.Peek(1)))
{
// Consume the "."
src.Advance();
ProcessDigits(src);
}
var lexeme = src.Extract();
context.AddToken(token, lexeme, line, column);
return true;
};
}
private static bool IsDigit(char c) => c is >= '0' and <= '9';
private static void ProcessDigits(Source src)
{
while (IsDigit(src.Peek()))
src.Advance();
}
}
}

View File

@ -0,0 +1,23 @@
namespace DevDisciples.Parsing;
public abstract partial class Lexer<TToken> where TToken : Enum
{
public struct Token : ISourceLocation
{
public string File { get; }
public TToken Type { get; }
public string Lexeme { get; }
public int Line { get; }
public int Column { get; }
public Token(string file, TToken type, string lexeme, int line, int column)
{
File = file;
Type = type;
Lexeme = lexeme;
Line = line;
Column = column;
}
}
}

View File

@ -0,0 +1,68 @@
namespace DevDisciples.Parsing;
public abstract partial class Lexer<TToken> where TToken : Enum
{
protected List<Rule> Rules { get; init; } = default!;
protected abstract TToken EndOfSource { get; }
public List<Token> Lex(string file, string source)
{
var ctx = new Context(file, new Source(file, source), new List<Token>());
while (!ctx.Source.Ended())
{
var matched = false;
for (var i = 0; i < Rules.Count; i++)
{
if (Rules[i](ctx))
{
matched = true;
break;
}
}
if (!matched)
{
Report.Halt(ctx.Source, $"Unexpected character '{ctx.Source.Current}'.");
}
}
ctx.AddToken(EndOfSource, "<EOF>", ctx.Source.Line, ctx.Source.Column);
return ctx.Tokens;
}
protected static bool Match(Context ctx, TToken type, char @char)
{
if (!ctx.Source.Match(@char)) return false;
var line = ctx.Source.Line;
var column = ctx.Source.Column;
var lexeme = ctx.Source.Extract();
ctx.Source.Column += 1;
ctx.AddToken(type, lexeme, line, column);
return true;
}
/*
* Do not use this method for keywords!
* This will treat an identifier named 'ifelse' as separated 'if' and 'else' tokens.
*/
protected static bool Match(Context ctx, TToken token, string sequence)
{
if (!ctx.Source.Match(sequence)) return false;
var line = ctx.Source.Line;
var column = ctx.Source.Column;
var lexeme = ctx.Source.Extract();
ctx.Source.Column += sequence.Length;
ctx.Tokens.Add(new Token(ctx.File, token, lexeme, line, column));
return true;
}
}

View File

@ -0,0 +1,32 @@
namespace DevDisciples.Parsing;
public abstract class ParsableStream<T>
{
private readonly ReadOnlyMemory<T> _tokens;
protected ReadOnlySpan<T> Tokens => _tokens.Span;
public int Position { get; set; }
public T Current => Position < Tokens.Length ? Tokens[Position] : default!;
public ParsableStream(ReadOnlyMemory<T> tokens)
{
_tokens = tokens;
}
public virtual T Advance()
{
return Position + 1 <= Tokens.Length ? Tokens[++Position - 1] : default!;
}
public virtual T Peek(int offset = 0)
{
return Position + offset < Tokens.Length ? Tokens[Position + offset] : default!;
}
public virtual bool Ended()
{
return Position >= Tokens.Length;
}
}

View File

@ -0,0 +1,106 @@
namespace DevDisciples.Parsing;
public class ParserContext<TToken> : ParsableStream<Lexer<TToken>.Token> where TToken : Enum
{
protected readonly TToken _endOfSource;
public ParserContext(Memory<Lexer<TToken>.Token> tokens, TToken endOfSource) : base(tokens)
{
_endOfSource = endOfSource;
}
public bool Check(TToken type, int offset = 0)
{
if (Ended()) return false;
if (Equals(Peek(offset).Type, _endOfSource)) return false;
return Equals(Peek(offset).Type, type);
}
/// <summary>
/// Checks whether the passed sequence can be matched against the current parsing context.
/// </summary>
/// <param name="sequence"></param>
/// <returns></returns>
public bool CheckSequence(params TToken[] sequence)
{
for (var i = 0; i < sequence.Length; i++)
{
if (!Check(sequence[i], i))
{
return false;
}
}
return true;
}
public override bool Ended()
{
return base.Ended() || Equals(Current.Type, _endOfSource);
}
public bool Match(TToken token)
{
var matched = Check(token);
if (matched) Advance();
return matched;
}
public bool MatchAny(params TToken[] types)
{
for (var i = 0; i < types.Length; i++)
{
if (Check(types[i]))
{
Advance();
return true;
}
}
return false;
}
public bool MatchSequence(params TToken[] sequence)
{
for (var i = 0; i < sequence.Length; i++)
{
if (!Check(sequence[i], i))
{
return false;
}
}
for (var i = 0; i < sequence.Length; i++)
{
Advance();
}
return true;
}
public Lexer<TToken>.Token Previous()
{
return Peek(-1);
}
public Lexer<TToken>.Token Consume(TToken type, string message)
{
if (Check(type)) return Advance();
throw Error(message);
}
public Exception Error(string message)
{
return new ParsingException(Report.FormatMessage(Current, message));
}
public Exception Error(Lexer<TToken>.Token token, string message)
{
return new ParsingException(Report.FormatMessage(token, message));
}
public void Halt(Lexer<TToken>.Token token, string message)
{
throw new ParsingException(Report.FormatMessage(token, message));
}
}

View File

@ -0,0 +1,16 @@
namespace DevDisciples.Parsing;
public class ParsingException : Exception
{
public ParsingException()
{
}
public ParsingException(string? message) : base(message)
{
}
public ParsingException(string? message, Exception? innerException) : base(message, innerException)
{
}
}

View File

@ -0,0 +1,19 @@
namespace DevDisciples.Parsing;
public static class Report
{
public static Exception Error(ISourceLocation token, string message)
{
return new(FormatMessage(token, message));
}
public static void Halt(ISourceLocation token, string message)
{
throw new(FormatMessage(token, message));
}
public static string FormatMessage(ISourceLocation token, string msg)
{
return $"{token.File}\n\t[line: {token.Line}, column: {token.Column}] {msg}";
}
}

View File

@ -0,0 +1,64 @@
namespace DevDisciples.Parsing;
public class Source : ParsableStream<char>, ISourceLocation
{
private readonly string _source;
public string File { get; }
public int Start { get; set; }
public int Line { get; set; } = 1;
public int Column { get; set; } = 1;
public char Last => Tokens[^1];
public Source(string file, string source) : base(source.AsMemory())
{
File = file;
_source = source;
}
public override char Advance()
{
Column++;
return base.Advance();
}
public override bool Ended()
{
return Current == '\0' || base.Ended();
}
public string Extract()
{
var position = (Start, Length: Position - Start);
Start = Position;
return _source.Substring(position.Start, position.Length);
}
public void Ignore()
{
Start = Position;
}
public bool Match(char expected)
{
if (Tokens[Position] != expected) return false;
Position += 1;
return true;
}
public bool Match(ReadOnlySpan<char> expected)
{
if (Position + expected.Length > Tokens.Length) return false;
for (var index = 0; index < expected.Length; index++)
{
if (Tokens[Position + index] != expected[index])
{
return false;
}
}
Position += expected.Length;
return true;
}
}

View File

@ -0,0 +1,8 @@
namespace DevDisciples.Parsing;
public static class Visitor
{
public delegate void Visit(object visitee, params object[] args);
public delegate TOut Visit<TOut>(object visitee, params object[] args);
public delegate TOut Visit<TIn, TOut>(TIn visitee, params object[] args);
}

View File

@ -0,0 +1,42 @@
namespace DevDisciples.Parsing;
public class VisitorContainer
{
private Dictionary<Type, Visitor.Visit> Visitors { get; } = new();
private Visitor.Visit Default { get; set; } = default!;
public void Register<TVisitee>(Visitor.Visit visitor)
{
Visitors[typeof(TVisitee)] = visitor;
}
public Visitor.Visit this[Type type] => Visitors.GetValueOrDefault(type, Default);
}
public class VisitorContainer<T>
{
protected Dictionary<Type, Visitor.Visit<T>> Visitors { get; } = new();
public Visitor.Visit<T> Default { get; set; } = default!;
public VisitorContainer<T> Register<TVisitee>(Visitor.Visit<T> visitor)
{
Visitors[typeof(TVisitee)] = visitor;
return this;
}
public Visitor.Visit<T> this[Type type] => Visitors.ContainsKey(type) ? Visitors[type] : Default;
}
public class VisitorContainer<TIn, TOut>
{
protected Dictionary<Type, Visitor.Visit<TIn, TOut>> Visitors { get; } = new();
public Visitor.Visit<TIn, TOut> Default { get; set; } = default!;
public void Register<TVisitee>(Visitor.Visit<TIn, TOut> visitor)
{
Visitors[typeof(TVisitee)] = visitor;
}
public Visitor.Visit<TIn, TOut> this[Type type] => Visitors.ContainsKey(type) ? Visitors[type] : Default;
}