commit 8d1069e477f9bfb4974be1728776793920e057b4 Author: mdnapo Date: Sun Sep 15 17:23:27 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..052619c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/DevDisciples.Json.Parser.Tests/DevDisciples.Json.Parser.Tests.csproj b/DevDisciples.Json.Parser.Tests/DevDisciples.Json.Parser.Tests.csproj new file mode 100644 index 0000000..b108866 --- /dev/null +++ b/DevDisciples.Json.Parser.Tests/DevDisciples.Json.Parser.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + diff --git a/DevDisciples.Json.Parser.Tests/GlobalUsings.cs b/DevDisciples.Json.Parser.Tests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/DevDisciples.Json.Parser.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/DevDisciples.Json.Parser.Tests/JsonLexerTests.cs b/DevDisciples.Json.Parser.Tests/JsonLexerTests.cs new file mode 100644 index 0000000..a8ad26a --- /dev/null +++ b/DevDisciples.Json.Parser.Tests/JsonLexerTests.cs @@ -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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser.Tests/JsonParserTests.cs b/DevDisciples.Json.Parser.Tests/JsonParserTests.cs new file mode 100644 index 0000000..5942408 --- /dev/null +++ b/DevDisciples.Json.Parser.Tests/JsonParserTests.cs @@ -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" }); + }); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/DevDisciples.Json.Parser.csproj b/DevDisciples.Json.Parser/DevDisciples.Json.Parser.csproj new file mode 100644 index 0000000..f64304c --- /dev/null +++ b/DevDisciples.Json.Parser/DevDisciples.Json.Parser.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/DevDisciples.Json.Parser/JsonArray.cs b/DevDisciples.Json.Parser/JsonArray.cs new file mode 100644 index 0000000..7ddbdd5 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonArray.cs @@ -0,0 +1,15 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonArray : ISyntaxNode +{ + public Lexer.Token Token { get; } + public List Elements { get; } + + public JsonArray(Lexer.Token token, List? elements) + { + Token = token; + Elements = elements ?? new(); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonBool.cs b/DevDisciples.Json.Parser/JsonBool.cs new file mode 100644 index 0000000..a7501ae --- /dev/null +++ b/DevDisciples.Json.Parser/JsonBool.cs @@ -0,0 +1,14 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonBool : ISyntaxNode +{ + public Lexer.Token Token { get; } + public bool Value => bool.TryParse(Token.Lexeme, out var val) && val; + + public JsonBool(Lexer.Token token) + { + Token = token; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonLexer.cs b/DevDisciples.Json.Parser/JsonLexer.cs new file mode 100644 index 0000000..71c5335 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonLexer.cs @@ -0,0 +1,32 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public class JsonLexer : Lexer +{ + 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"), + ]; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonNull.cs b/DevDisciples.Json.Parser/JsonNull.cs new file mode 100644 index 0000000..ecd3610 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonNull.cs @@ -0,0 +1,13 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonNull : ISyntaxNode +{ + public Lexer.Token Token { get; } + + public JsonNull(Lexer.Token token) + { + Token = token; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonNumber.cs b/DevDisciples.Json.Parser/JsonNumber.cs new file mode 100644 index 0000000..89019c9 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonNumber.cs @@ -0,0 +1,14 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonNumber : ISyntaxNode +{ + public Lexer.Token Token { get; } + public double Value => double.TryParse(Token.Lexeme.Replace('.', ','), out var val) ? val : default; + + public JsonNumber(Lexer.Token token) + { + Token = token; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonObject.cs b/DevDisciples.Json.Parser/JsonObject.cs new file mode 100644 index 0000000..6a68d6b --- /dev/null +++ b/DevDisciples.Json.Parser/JsonObject.cs @@ -0,0 +1,15 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonObject : ISyntaxNode +{ + public Lexer.Token Token { get; } + public Dictionary Properties { get; set; } + + public JsonObject(Lexer.Token token, Dictionary? properties) + { + Token = token; + Properties = properties ?? new(); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonParser.Context.cs b/DevDisciples.Json.Parser/JsonParser.Context.cs new file mode 100644 index 0000000..aa04735 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonParser.Context.cs @@ -0,0 +1,13 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public static partial class JsonParser +{ + private class Context : ParserContext + { + public Context(Memory.Token> tokens) : base(tokens, JsonToken.EndOfSource) + { + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonParser.cs b/DevDisciples.Json.Parser/JsonParser.cs new file mode 100644 index 0000000..0cc62f1 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonParser.cs @@ -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 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 ctx) + { + var previous = ctx.Previous(); + List? 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 ctx) + { + var previous = ctx.Previous(); + Dictionary? 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 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.Token(minus.File, JsonToken.Number, $"-{number.Lexeme}", minus.Line, minus.Column) + ); + } + + private static ISyntaxNode String(ParserContext ctx) => new JsonString(ctx.Previous()); + + private static ISyntaxNode Null(ParserContext ctx) => new JsonNull(ctx.Previous()); + + private static ISyntaxNode Bool(ParserContext ctx) => new JsonBool(ctx.Previous()); +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonString.cs b/DevDisciples.Json.Parser/JsonString.cs new file mode 100644 index 0000000..68c5cac --- /dev/null +++ b/DevDisciples.Json.Parser/JsonString.cs @@ -0,0 +1,14 @@ +using DevDisciples.Parsing; + +namespace DevDisciples.Json.Parser; + +public struct JsonString : ISyntaxNode +{ + public Lexer.Token Token { get; } + public string Value => Token.Lexeme; + + public JsonString(Lexer.Token token) + { + Token = token; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Parser/JsonToken.cs b/DevDisciples.Json.Parser/JsonToken.cs new file mode 100644 index 0000000..e4a88e6 --- /dev/null +++ b/DevDisciples.Json.Parser/JsonToken.cs @@ -0,0 +1,18 @@ +namespace DevDisciples.Json.Parser; + +public enum JsonToken +{ + LeftBracket, + RightBracket, + LeftBrace, + RightBrace, + String, + Minus, + Number, + Comma, + Colon, + Null, + True, + False, + EndOfSource +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.API/DevDisciples.Json.Tools.API.csproj b/DevDisciples.Json.Tools.API/DevDisciples.Json.Tools.API.csproj new file mode 100644 index 0000000..c92ff7c --- /dev/null +++ b/DevDisciples.Json.Tools.API/DevDisciples.Json.Tools.API.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + true + + + + + + + + diff --git a/DevDisciples.Json.Tools.API/Program.cs b/DevDisciples.Json.Tools.API/Program.cs new file mode 100644 index 0000000..7bc4203 --- /dev/null +++ b/DevDisciples.Json.Tools.API/Program.cs @@ -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); +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.API/Properties/launchSettings.json b/DevDisciples.Json.Tools.API/Properties/launchSettings.json new file mode 100644 index 0000000..5633991 --- /dev/null +++ b/DevDisciples.Json.Tools.API/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/DevDisciples.Json.Tools.API/appsettings.Development.json b/DevDisciples.Json.Tools.API/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/DevDisciples.Json.Tools.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DevDisciples.Json.Tools.API/appsettings.json b/DevDisciples.Json.Tools.API/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/DevDisciples.Json.Tools.API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DevDisciples.Json.Tools.CLI/CommandOptions.cs b/DevDisciples.Json.Tools.CLI/CommandOptions.cs new file mode 100644 index 0000000..ac581ca --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/CommandOptions.cs @@ -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; } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/CommandOptionsBinder.cs b/DevDisciples.Json.Tools.CLI/CommandOptionsBinder.cs new file mode 100644 index 0000000..2e10315 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/CommandOptionsBinder.cs @@ -0,0 +1,10 @@ +using System.CommandLine.Binding; +using DevDisciples.Json.Tools.CLI.Extensions; + +namespace DevDisciples.Json.Tools.CLI; + +public class CommandOptionsBinder : BinderBase +{ + protected override CommandOptions GetBoundValue(BindingContext context) => + new CommandOptions().WithCommonBindings(context); +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/DevDisciples.Json.Tools.CLI.csproj b/DevDisciples.Json.Tools.CLI/DevDisciples.Json.Tools.CLI.csproj new file mode 100644 index 0000000..91f6eb8 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/DevDisciples.Json.Tools.CLI.csproj @@ -0,0 +1,22 @@ + + + Exe + net8.0 + enable + enable + true + jtr + ./nupkg + + + + + + + + + + + + + diff --git a/DevDisciples.Json.Tools.CLI/Extensions/CommandExtensions.cs b/DevDisciples.Json.Tools.CLI/Extensions/CommandExtensions.cs new file mode 100644 index 0000000..b3646ed --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Extensions/CommandExtensions.cs @@ -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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/Extensions/CommandOptionsExtensions.cs b/DevDisciples.Json.Tools.CLI/Extensions/CommandOptionsExtensions.cs new file mode 100644 index 0000000..98e2c06 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Extensions/CommandOptionsExtensions.cs @@ -0,0 +1,17 @@ +using System.CommandLine.Binding; + +namespace DevDisciples.Json.Tools.CLI.Extensions; + +public static class CommandOptionsExtensions +{ + public static T WithCommonBindings(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; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/IOHandler.cs b/DevDisciples.Json.Tools.CLI/IOHandler.cs new file mode 100644 index 0000000..5133402 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/IOHandler.cs @@ -0,0 +1,74 @@ +using TextCopy; + +namespace DevDisciples.Json.Tools.CLI; + +public static class IOHandler +{ + public static async Task 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."); + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/InputOptions.cs b/DevDisciples.Json.Tools.CLI/InputOptions.cs new file mode 100644 index 0000000..f610416 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/InputOptions.cs @@ -0,0 +1,18 @@ +namespace DevDisciples.Json.Tools.CLI; + +public enum InputOptions +{ + // Standard input + stdin, + std, + s, + + // Clipboard + clipboard, + clip, + c, + + // File + file, + f, +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptions.cs b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptions.cs new file mode 100644 index 0000000..57ee812 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptions.cs @@ -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; } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptionsBinder.cs b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptionsBinder.cs new file mode 100644 index 0000000..b923fa7 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.CommandOptionsBinder.cs @@ -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 + { + protected override CommandOptions GetBoundValue(BindingContext context) => + new CommandOptions + { + RootClassName = context.ParseResult.GetValueForOption(RootClassNameOption), + Namespace = context.ParseResult.GetValueForOption(NamespaceOption), + }.WithCommonBindings(context); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.cs b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.cs new file mode 100644 index 0000000..1cb9202 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Json2CSharpCommand.cs @@ -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 RootClassNameOption = + new( + aliases: ["--root-class", "--root", "-r"], + description: "The name of the root class", + getDefaultValue: () => Json2CSharpTranslator.Context.DefaultRootClassName + ); + + private static readonly Option 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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptions.cs b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptions.cs new file mode 100644 index 0000000..96f522a --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptions.cs @@ -0,0 +1,9 @@ +namespace DevDisciples.Json.Tools.CLI; + +public partial class JsonPrettifyCommand +{ + public class CommandOptions : CLI.CommandOptions + { + public int IndentSize { get; set; } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptionsBinder.cs b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptionsBinder.cs new file mode 100644 index 0000000..8b14d7b --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.CommandOptionsBinder.cs @@ -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 + { + protected override CommandOptions GetBoundValue(BindingContext context) => + new CommandOptions + { + IndentSize = context.ParseResult.GetValueForOption(IndentSizeOption) + }.WithCommonBindings(context); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.cs b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.cs new file mode 100644 index 0000000..797ec29 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/JsonPrettifyCommand.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/JsonUglifyCommand.cs b/DevDisciples.Json.Tools.CLI/JsonUglifyCommand.cs new file mode 100644 index 0000000..04aaba7 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/JsonUglifyCommand.cs @@ -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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/OutputOptions.cs b/DevDisciples.Json.Tools.CLI/OutputOptions.cs new file mode 100644 index 0000000..a41990c --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/OutputOptions.cs @@ -0,0 +1,18 @@ +namespace DevDisciples.Json.Tools.CLI; + +public enum OutputOptions +{ + // Standard output + stdout, + std, + s, + + // Clipboard + clipboard, + clip, + c, + + // File + file, + f, +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/Program.cs b/DevDisciples.Json.Tools.CLI/Program.cs new file mode 100644 index 0000000..c3c60c8 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/Program.cs @@ -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); \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/RootCommand.cs b/DevDisciples.Json.Tools.CLI/RootCommand.cs new file mode 100644 index 0000000..113cd72 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/RootCommand.cs @@ -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()); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/SharedCommandOptions.cs b/DevDisciples.Json.Tools.CLI/SharedCommandOptions.cs new file mode 100644 index 0000000..0937047 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/SharedCommandOptions.cs @@ -0,0 +1,25 @@ +using System.CommandLine; + +namespace DevDisciples.Json.Tools.CLI; + +public static class SharedCommandOptions +{ + public static readonly Option InputOption = + new(aliases: ["--input", "-i"], getDefaultValue: () => InputOptions.stdin) { IsRequired = false }; + + public static readonly Argument InputArgument = + new(name: "input", description: "The input argument.", getDefaultValue: () => default); + + public static readonly Option 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 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 OutputOption = + new(aliases: ["--output", "-o"], getDefaultValue: () => OutputOptions.stdout) { IsRequired = false }; +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools.CLI/scripts/publish_as_tool.sh b/DevDisciples.Json.Tools.CLI/scripts/publish_as_tool.sh new file mode 100644 index 0000000..43ba886 --- /dev/null +++ b/DevDisciples.Json.Tools.CLI/scripts/publish_as_tool.sh @@ -0,0 +1,5 @@ +#!/usr/bin/bash + +dotnet pack -v d + +dotnet tool update --global --add-source ./nupkg DevDisciples.Json.Tools.CLI -v d diff --git a/DevDisciples.Json.Tools/DevDisciples.Json.Tools.csproj b/DevDisciples.Json.Tools/DevDisciples.Json.Tools.csproj new file mode 100644 index 0000000..0a62e9f --- /dev/null +++ b/DevDisciples.Json.Tools/DevDisciples.Json.Tools.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.ClassTranslation.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.ClassTranslation.cs new file mode 100644 index 0000000..43a5abe --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.ClassTranslation.cs @@ -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 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"); + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.Context.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.Context.cs new file mode 100644 index 0000000..f6090a4 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.Context.cs @@ -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 Classes { get; set; } = new(); + public readonly StringBuilder Builder = new(); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.ITranslation.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.ITranslation.cs new file mode 100644 index 0000000..175a69a --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.ITranslation.cs @@ -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); + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonArrayTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonArrayTranslator.cs new file mode 100644 index 0000000..6f27fcb --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonArrayTranslator.cs @@ -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 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; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonBoolTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonBoolTranslator.cs new file mode 100644 index 0000000..b4b31f6 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonBoolTranslator.cs @@ -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), + }; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNullTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNullTranslator.cs new file mode 100644 index 0000000..1930638 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNullTranslator.cs @@ -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), + }; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNumberTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNumberTranslator.cs new file mode 100644 index 0000000..47abceb --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonNumberTranslator.cs @@ -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), + }; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonObjectTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonObjectTranslator.cs new file mode 100644 index 0000000..b070f1e --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonObjectTranslator.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonStringTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonStringTranslator.cs new file mode 100644 index 0000000..a526d39 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.JsonStringTranslator.cs @@ -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), + }; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.PropertyTranslation.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.PropertyTranslation.cs new file mode 100644 index 0000000..f206624 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.PropertyTranslation.cs @@ -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"); + } + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/Json2CSharpTranslator.cs b/DevDisciples.Json.Tools/Json2CSharpTranslator.cs new file mode 100644 index 0000000..867f902 --- /dev/null +++ b/DevDisciples.Json.Tools/Json2CSharpTranslator.cs @@ -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 Visitors; + + static Json2CSharpTranslator() + { + Visitors = new(); + Visitors.Register(JsonObjectTranslator.Translate); + Visitors.Register(JsonArrayTranslator.Translate); + Visitors.Register(JsonStringTranslator.Translate); + Visitors.Register(JsonNumberTranslator.Translate); + Visitors.Register(JsonBoolTranslator.Translate); + Visitors.Register(JsonNullTranslator.Translate); + } + + public static string Translate(string source, Context? context = null) + { + if (JsonParser.Parse("", 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(); +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/JsonFormatter.Context.cs b/DevDisciples.Json.Tools/JsonFormatter.Context.cs new file mode 100644 index 0000000..e994a48 --- /dev/null +++ b/DevDisciples.Json.Tools/JsonFormatter.Context.cs @@ -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; + } +} \ No newline at end of file diff --git a/DevDisciples.Json.Tools/JsonFormatter.cs b/DevDisciples.Json.Tools/JsonFormatter.cs new file mode 100644 index 0000000..4871a9e --- /dev/null +++ b/DevDisciples.Json.Tools/JsonFormatter.cs @@ -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(PrintArray); + Visitors.Register(PrintObject); + Visitors.Register(PrintString); + Visitors.Register(PrintNumber); + Visitors.Register(PrintBool); + Visitors.Register(PrintNull); + } + + public static string Format(string source, Context? context) + { + var nodes = JsonParser.Parse("", 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"); + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing.sln b/DevDisciples.Parsing.sln new file mode 100644 index 0000000..1336d80 --- /dev/null +++ b/DevDisciples.Parsing.sln @@ -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 diff --git a/DevDisciples.Parsing/DevDisciples.Parsing.csproj b/DevDisciples.Parsing/DevDisciples.Parsing.csproj new file mode 100644 index 0000000..595335a --- /dev/null +++ b/DevDisciples.Parsing/DevDisciples.Parsing.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/DevDisciples.Parsing/ISourceLocation.cs b/DevDisciples.Parsing/ISourceLocation.cs new file mode 100644 index 0000000..a2fd64a --- /dev/null +++ b/DevDisciples.Parsing/ISourceLocation.cs @@ -0,0 +1,8 @@ +namespace DevDisciples.Parsing; + +public interface ISourceLocation +{ + public string File { get; } + public int Line { get; } + public int Column { get; } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/ISyntaxNode.cs b/DevDisciples.Parsing/ISyntaxNode.cs new file mode 100644 index 0000000..8080922 --- /dev/null +++ b/DevDisciples.Parsing/ISyntaxNode.cs @@ -0,0 +1,3 @@ +namespace DevDisciples.Parsing; + +public interface ISyntaxNode { } \ No newline at end of file diff --git a/DevDisciples.Parsing/Lexer.Context.cs b/DevDisciples.Parsing/Lexer.Context.cs new file mode 100644 index 0000000..b464009 --- /dev/null +++ b/DevDisciples.Parsing/Lexer.Context.cs @@ -0,0 +1,29 @@ +namespace DevDisciples.Parsing; + +public abstract partial class Lexer where TToken : Enum +{ + public class Context + { + public string File { get; } + public Source Source { get; } + public List Tokens { get; } + + public Context(string file, Source source, List 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 + )); + } + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/Lexer.Rule.cs b/DevDisciples.Parsing/Lexer.Rule.cs new file mode 100644 index 0000000..f5c4f8f --- /dev/null +++ b/DevDisciples.Parsing/Lexer.Rule.cs @@ -0,0 +1,213 @@ +namespace DevDisciples.Parsing; + +public abstract partial class Lexer 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(); + } + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/Lexer.Token.cs b/DevDisciples.Parsing/Lexer.Token.cs new file mode 100644 index 0000000..4955bb2 --- /dev/null +++ b/DevDisciples.Parsing/Lexer.Token.cs @@ -0,0 +1,23 @@ +namespace DevDisciples.Parsing; + +public abstract partial class Lexer 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; + } + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/Lexer.cs b/DevDisciples.Parsing/Lexer.cs new file mode 100644 index 0000000..83dd486 --- /dev/null +++ b/DevDisciples.Parsing/Lexer.cs @@ -0,0 +1,68 @@ +namespace DevDisciples.Parsing; + +public abstract partial class Lexer where TToken : Enum +{ + protected List Rules { get; init; } = default!; + + protected abstract TToken EndOfSource { get; } + + public List Lex(string file, string source) + { + var ctx = new Context(file, new Source(file, source), new List()); + + 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, "", 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; + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/ParsableStream.cs b/DevDisciples.Parsing/ParsableStream.cs new file mode 100644 index 0000000..1fbbc02 --- /dev/null +++ b/DevDisciples.Parsing/ParsableStream.cs @@ -0,0 +1,32 @@ +namespace DevDisciples.Parsing; + +public abstract class ParsableStream +{ + private readonly ReadOnlyMemory _tokens; + + protected ReadOnlySpan Tokens => _tokens.Span; + + public int Position { get; set; } + + public T Current => Position < Tokens.Length ? Tokens[Position] : default!; + + public ParsableStream(ReadOnlyMemory 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; + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/ParserContext.cs b/DevDisciples.Parsing/ParserContext.cs new file mode 100644 index 0000000..92c1aae --- /dev/null +++ b/DevDisciples.Parsing/ParserContext.cs @@ -0,0 +1,106 @@ +namespace DevDisciples.Parsing; + +public class ParserContext : ParsableStream.Token> where TToken : Enum +{ + protected readonly TToken _endOfSource; + + public ParserContext(Memory.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); + } + + /// + /// Checks whether the passed sequence can be matched against the current parsing context. + /// + /// + /// + 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.Token Previous() + { + return Peek(-1); + } + + public Lexer.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.Token token, string message) + { + return new ParsingException(Report.FormatMessage(token, message)); + } + + public void Halt(Lexer.Token token, string message) + { + throw new ParsingException(Report.FormatMessage(token, message)); + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/ParsingException.cs b/DevDisciples.Parsing/ParsingException.cs new file mode 100644 index 0000000..1f13796 --- /dev/null +++ b/DevDisciples.Parsing/ParsingException.cs @@ -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) + { + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/Report.cs b/DevDisciples.Parsing/Report.cs new file mode 100644 index 0000000..7f13a6c --- /dev/null +++ b/DevDisciples.Parsing/Report.cs @@ -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}"; + } +} diff --git a/DevDisciples.Parsing/Source.cs b/DevDisciples.Parsing/Source.cs new file mode 100644 index 0000000..63d3979 --- /dev/null +++ b/DevDisciples.Parsing/Source.cs @@ -0,0 +1,64 @@ +namespace DevDisciples.Parsing; + +public class Source : ParsableStream, 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 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; + } +} \ No newline at end of file diff --git a/DevDisciples.Parsing/Visitor.cs b/DevDisciples.Parsing/Visitor.cs new file mode 100644 index 0000000..b912406 --- /dev/null +++ b/DevDisciples.Parsing/Visitor.cs @@ -0,0 +1,8 @@ +namespace DevDisciples.Parsing; + +public static class Visitor +{ + public delegate void Visit(object visitee, params object[] args); + public delegate TOut Visit(object visitee, params object[] args); + public delegate TOut Visit(TIn visitee, params object[] args); +} \ No newline at end of file diff --git a/DevDisciples.Parsing/VisitorContainer.cs b/DevDisciples.Parsing/VisitorContainer.cs new file mode 100644 index 0000000..f9f9eb0 --- /dev/null +++ b/DevDisciples.Parsing/VisitorContainer.cs @@ -0,0 +1,42 @@ +namespace DevDisciples.Parsing; + +public class VisitorContainer +{ + private Dictionary Visitors { get; } = new(); + private Visitor.Visit Default { get; set; } = default!; + + public void Register(Visitor.Visit visitor) + { + Visitors[typeof(TVisitee)] = visitor; + } + + public Visitor.Visit this[Type type] => Visitors.GetValueOrDefault(type, Default); +} + +public class VisitorContainer +{ + protected Dictionary> Visitors { get; } = new(); + public Visitor.Visit Default { get; set; } = default!; + + + public VisitorContainer Register(Visitor.Visit visitor) + { + Visitors[typeof(TVisitee)] = visitor; + return this; + } + + public Visitor.Visit this[Type type] => Visitors.ContainsKey(type) ? Visitors[type] : Default; +} + +public class VisitorContainer +{ + protected Dictionary> Visitors { get; } = new(); + public Visitor.Visit Default { get; set; } = default!; + + public void Register(Visitor.Visit visitor) + { + Visitors[typeof(TVisitee)] = visitor; + } + + public Visitor.Visit this[Type type] => Visitors.ContainsKey(type) ? Visitors[type] : Default; +} \ No newline at end of file