diff --git a/docs/Program.cs b/docs/Program.cs index 361c5c233..f5f7fa05d 100644 --- a/docs/Program.cs +++ b/docs/Program.cs @@ -23,7 +23,8 @@ await Bootstrapper.Factory { "../../src/Spectre.Console/**/{!bin,!obj,!packages,!*.Tests,}/**/*.cs", "../../src/Spectre.Console.Cli/**/{!bin,!obj,!packages,!*.Tests,}/**/*.cs", - "../../src/Spectre.Console.ImageSharp/**/{!bin,!obj,!packages,!*.Tests,}/**/*.cs" + "../../src/Spectre.Console.ImageSharp/**/{!bin,!obj,!packages,!*.Tests,}/**/*.cs", + "../../src/Spectre.Console.Json/**/{!bin,!obj,!packages,!*.Tests,}/**/*.cs" }) .AddSetting(Constants.ExampleSourceFiles, new List { diff --git a/docs/input/assets/casts/json-plain.cast b/docs/input/assets/casts/json-plain.cast new file mode 100644 index 000000000..d4cca0785 --- /dev/null +++ b/docs/input/assets/casts/json-plain.cast @@ -0,0 +1,3 @@ +{"version": 2, "width": 62, "height": 20, "title": "json (plain)", "env": {"TERM": "Spectre.Console"}} +[0, "o", "\u001B[37m\u250C\u2500Some JSON in a panel\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m{\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022hello\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[32m32\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022world\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[37m{\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022foo\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[32m21\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022bar\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[32m255\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022baz\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[37m[\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[32m0.32\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[32m0.33e-32\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[32m0.42e32\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[32m0.55e\u002B32\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m{\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022hello\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[31m\u0022world\u0022\u001B[0m\u001B[37m,\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[34m\u0022lol\u0022\u001B[0m\u001B[37m:\u001B[0m \u001B[37mnull\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m}\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m]\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m}\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2502\u001B[0m \u001B[37m}\u001B[0m \u001B[37m\u2502\u001B[0m\r\n\u001B[37m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u001B[0m\r\n"] + diff --git a/docs/input/assets/casts/json-rich.cast b/docs/input/assets/casts/json-rich.cast new file mode 100644 index 000000000..b970d1294 --- /dev/null +++ b/docs/input/assets/casts/json-rich.cast @@ -0,0 +1,3 @@ +{"version": 2, "width": 62, "height": 20, "title": "json (rich)", "env": {"TERM": "Spectre.Console"}} +[0, "o", "\u001B[38;5;11m\u256D\u2500Some JSON in a panel\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m{\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022hello\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;2m32\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022world\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;8m{\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022foo\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;2m21\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022bar\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;2m255\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022baz\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;8m[\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;2m0.32\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;2m0.33e-32\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;2m0.42e32\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;2m0.55e\u002B32\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m{\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022hello\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;9m\u0022world\u0022\u001B[0m\u001B[38;5;8m,\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;12m\u0022lol\u0022\u001B[0m\u001B[38;5;11m:\u001B[0m \u001B[38;5;8mnull\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m}\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m]\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m}\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2502\u001B[0m \u001B[38;5;8m}\u001B[0m \u001B[38;5;11m\u2502\u001B[0m\r\n\u001B[38;5;11m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F\u001B[0m\r\n"] + diff --git a/docs/input/widgets/json.md b/docs/input/widgets/json.md new file mode 100644 index 000000000..d37c33349 --- /dev/null +++ b/docs/input/widgets/json.md @@ -0,0 +1,69 @@ +Title: JSON +Order: 70 +Description: "Use *ImageSharp* to parse images and render them as Ascii art to the console." +Reference: T:Spectre.Console.Json.JsonText +--- + +To add JSON superpowers to +your console application to render JSON text, you will need to install +the [Spectre.Console.Json](https://www.nuget.org/packages/Spectre.Console.Json) NuGet package. + +```text +> dotnet add package Spectre.Console.Json +``` + +## Loading images + +Once you've added the `Spectre.Console.Json` NuGet package, +you can start rendering JSON to the console. + +```csharp +using Spectre.Console.Json; + +var json = new JsonText( + """ + { + "hello": 32, + "world": { + "foo": 21, + "bar": 255, + "baz": [ + 0.32, 0.33e-32, + 0.42e32, 0.55e+32, + { + "hello": "world", + "lol": null + } + ] + } + } + """); + +AnsiConsole.Write( + new Panel(json) + .Header("Some JSON in a panel") + .Collapse() + .RoundedBorder() + .BorderColor(Color.Yellow)); +``` + +### Result + + + +## Styling + +All the different JSON parts can be customized to have unique styles. + +```csharp +AnsiConsole.Write( + new JsonText(json) + .BracesColor(Color.Red) + .BracketColor(Color.Green) + .ColonColor(Color.Blue) + .CommaColor(Color.Red) + .StringColor(Color.Green) + .NumberColor(Color.Blue) + .BooleanColor(Color.Red) + .NullColor(Color.Green)); +``` \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json index a0b331cdd..958377a8f 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -15,7 +15,7 @@ ] }, "dotnet-example": { - "version": "1.6.0", + "version": "2.0.0", "commands": [ "dotnet-example" ] diff --git a/examples/Console/Json/Json.csproj b/examples/Console/Json/Json.csproj new file mode 100644 index 000000000..709a57b99 --- /dev/null +++ b/examples/Console/Json/Json.csproj @@ -0,0 +1,16 @@ + + + + Exe + net7.0 + Json + Demonstrates how to print syntax highlighted JSON. + Widgets + + + + + + + + diff --git a/examples/Console/Json/Program.cs b/examples/Console/Json/Program.cs new file mode 100644 index 000000000..2dc4df691 --- /dev/null +++ b/examples/Console/Json/Program.cs @@ -0,0 +1,36 @@ +using Spectre.Console; +using Spectre.Console.Json; + +namespace Json; + +public static class Program +{ + public static void Main() + { + var json = new JsonText( + """ + { + "hello": 32, + "world": { + "foo": 21, + "bar": 255, + "baz": [ + 0.32, 0.33e-32, + 0.42e32, 0.55e+32, + { + "hello": "world", + "lol": null + } + ] + } + } + """); + + AnsiConsole.Write( + new Panel(json) + .Header("Some JSON in a panel") + .Collapse() + .RoundedBorder() + .BorderColor(Color.Yellow)); + } +} diff --git a/examples/Examples.sln b/examples/Examples.sln index 01a07001f..c0f1b689d 100644 --- a/examples/Examples.sln +++ b/examples/Examples.sln @@ -79,6 +79,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Cli", "..\s EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Layout", "Console\Layout\Layout.csproj", "{A9FDE73A-8452-4CA3-B366-3F900597E132}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Json", "Console\Json\Json.csproj", "{ABE3E734-0756-4D5A-B28A-E6E526D9927D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -521,6 +523,18 @@ Global {A9FDE73A-8452-4CA3-B366-3F900597E132}.Release|x64.Build.0 = Release|Any CPU {A9FDE73A-8452-4CA3-B366-3F900597E132}.Release|x86.ActiveCfg = Release|Any CPU {A9FDE73A-8452-4CA3-B366-3F900597E132}.Release|x86.Build.0 = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|x64.ActiveCfg = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|x64.Build.0 = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Debug|x86.Build.0 = Debug|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|Any CPU.Build.0 = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|x64.ActiveCfg = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|x64.Build.0 = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|x86.ActiveCfg = Release|Any CPU + {ABE3E734-0756-4D5A-B28A-E6E526D9927D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/resources/scripts/Generator/Commands/AsciiCast/Samples/FigletSample.cs b/resources/scripts/Generator/Commands/AsciiCast/Samples/FigletSample.cs index 4814a8f42..eb926b574 100644 --- a/resources/scripts/Generator/Commands/AsciiCast/Samples/FigletSample.cs +++ b/resources/scripts/Generator/Commands/AsciiCast/Samples/FigletSample.cs @@ -8,9 +8,9 @@ public class FigletSample : BaseSample public override void Run(IAnsiConsole console) { - console.Write(new FigletText("Left aligned").LeftAligned().Color(Color.Red)); + console.Write(new FigletText("Left aligned").LeftJustified().Color(Color.Red)); console.Write(new FigletText("Centered").Centered().Color(Color.Green)); - console.Write(new FigletText("Right aligned").RightAligned().Color(Color.Blue)); + console.Write(new FigletText("Right aligned").RightJustified().Color(Color.Blue)); } } } \ No newline at end of file diff --git a/resources/scripts/Generator/Commands/AsciiCast/Samples/InputSample.cs b/resources/scripts/Generator/Commands/AsciiCast/Samples/InputSample.cs index d14a4f989..d4171281c 100644 --- a/resources/scripts/Generator/Commands/AsciiCast/Samples/InputSample.cs +++ b/resources/scripts/Generator/Commands/AsciiCast/Samples/InputSample.cs @@ -19,7 +19,7 @@ public override void Run(IAnsiConsole console) console.DisplayThenType(c => password = AskPassword(c), "hunter2↲"); console.DisplayThenType(c => color = AskColor(c), "↲"); - AnsiConsole.Write(new Rule("[yellow]Results[/]").RuleStyle("grey").LeftAligned()); + AnsiConsole.Write(new Rule("[yellow]Results[/]").RuleStyle("grey").LeftJustified()); AnsiConsole.Write(new Table().AddColumns("[grey]Question[/]", "[grey]Answer[/]") .RoundedBorder() .BorderColor(Color.Grey) @@ -33,7 +33,7 @@ public override void Run(IAnsiConsole console) private static string AskName(IAnsiConsole console) { console.WriteLine(); - console.Write(new Rule("[yellow]Strings[/]").RuleStyle("grey").LeftAligned()); + console.Write(new Rule("[yellow]Strings[/]").RuleStyle("grey").LeftJustified()); var name = console.Ask("What's your [green]name[/]?"); return name; } @@ -42,7 +42,7 @@ private static string AskName(IAnsiConsole console) private static string AskSport(IAnsiConsole console) { console.WriteLine(); - console.Write(new Rule("[yellow]Choices[/]").RuleStyle("grey").LeftAligned()); + console.Write(new Rule("[yellow]Choices[/]").RuleStyle("grey").LeftJustified()); return console.Prompt( new TextPrompt("What's your [green]favorite sport[/]?") @@ -56,7 +56,7 @@ private static string AskSport(IAnsiConsole console) private static int AskAge(IAnsiConsole console) { console.WriteLine(); - console.Write(new Rule("[yellow]Integers[/]").RuleStyle("grey").LeftAligned()); + console.Write(new Rule("[yellow]Integers[/]").RuleStyle("grey").LeftJustified()); return console.Prompt( new TextPrompt("How [green]old[/] are you?") @@ -76,7 +76,7 @@ private static int AskAge(IAnsiConsole console) private static string AskPassword(IAnsiConsole console) { console.WriteLine(); - console.Write(new Rule("[yellow]Secrets[/]").RuleStyle("grey").LeftAligned()); + console.Write(new Rule("[yellow]Secrets[/]").RuleStyle("grey").LeftJustified()); return console.Prompt( new TextPrompt("Enter [green]password[/]?") @@ -87,7 +87,7 @@ private static string AskPassword(IAnsiConsole console) private static string AskColor(IAnsiConsole console) { console.WriteLine(); - console.Write(new Rule("[yellow]Optional[/]").RuleStyle("grey").LeftAligned()); + console.Write(new Rule("[yellow]Optional[/]").RuleStyle("grey").LeftJustified()); return console.Prompt( new TextPrompt("[grey][[Optional]][/] What is your [green]favorite color[/]?") diff --git a/resources/scripts/Generator/Commands/AsciiCast/Samples/JsonSample.cs b/resources/scripts/Generator/Commands/AsciiCast/Samples/JsonSample.cs new file mode 100644 index 000000000..07331de4b --- /dev/null +++ b/resources/scripts/Generator/Commands/AsciiCast/Samples/JsonSample.cs @@ -0,0 +1,39 @@ +using Spectre.Console; +using Spectre.Console.Json; + +namespace Generator.Commands.Samples +{ + public class JsonSample : BaseSample + { + public override (int Cols, int Rows) ConsoleSize => (60, 20); + + public override void Run(IAnsiConsole console) + { + var json = new JsonText( + """ + { + "hello": 32, + "world": { + "foo": 21, + "bar": 255, + "baz": [ + 0.32, 0.33e-32, + 0.42e32, 0.55e+32, + { + "hello": "world", + "lol": null + } + ] + } + } + """); + + AnsiConsole.Write( + new Panel(json) + .Header("Some JSON in a panel") + .Collapse() + .RoundedBorder() + .BorderColor(Color.Yellow)); + } + } +} \ No newline at end of file diff --git a/resources/scripts/Generator/Commands/AsciiCast/Samples/RuleSample.cs b/resources/scripts/Generator/Commands/AsciiCast/Samples/RuleSample.cs index 68aa4b735..dd3eb6ae5 100644 --- a/resources/scripts/Generator/Commands/AsciiCast/Samples/RuleSample.cs +++ b/resources/scripts/Generator/Commands/AsciiCast/Samples/RuleSample.cs @@ -10,11 +10,11 @@ public override void Run(IAnsiConsole console) { console.Write(new Rule()); console.WriteLine(); - console.Write(new Rule("[blue]Left aligned[/]").LeftAligned().RuleStyle("red")); + console.Write(new Rule("[blue]Left aligned[/]").LeftJustified().RuleStyle("red")); console.WriteLine(); console.Write(new Rule("[green]Centered[/]").Centered().RuleStyle("green")); console.WriteLine(); - console.Write(new Rule("[red]Right aligned[/]").RightAligned().RuleStyle("blue")); + console.Write(new Rule("[red]Right aligned[/]").RightJustified().RuleStyle("blue")); console.WriteLine(); } } diff --git a/resources/scripts/Generator/Commands/AsciiCast/Samples/TextPathSample.cs b/resources/scripts/Generator/Commands/AsciiCast/Samples/TextPathSample.cs index 6e6543f4e..007aa4d17 100644 --- a/resources/scripts/Generator/Commands/AsciiCast/Samples/TextPathSample.cs +++ b/resources/scripts/Generator/Commands/AsciiCast/Samples/TextPathSample.cs @@ -34,9 +34,9 @@ public override void Run(IAnsiConsole console) console.Write( new Panel( new Padder(new Rows( - new TextPath("/This/Is/A/Long/Path/That/Will/Be/Truncated.txt").LeftAligned(), + new TextPath("/This/Is/A/Long/Path/That/Will/Be/Truncated.txt").LeftJustified(), new TextPath("/This/Is/A/Long/Path/That/Will/Be/Truncated.txt").Centered(), - new TextPath("/This/Is/A/Long/Path/That/Will/Be/Truncated.txt").RightAligned()), new Padding(0,1))) + new TextPath("/This/Is/A/Long/Path/That/Will/Be/Truncated.txt").RightJustified()), new Padding(0,1))) .BorderStyle(new Style(foreground: Color.Grey)) .Header("Alignment")); } diff --git a/resources/scripts/Generator/Generator.csproj b/resources/scripts/Generator/Generator.csproj index 7b9238034..645bf9fc4 100644 --- a/resources/scripts/Generator/Generator.csproj +++ b/resources/scripts/Generator/Generator.csproj @@ -1,4 +1,4 @@ - + Exe @@ -51,8 +51,9 @@ + - + diff --git a/src/Spectre.Console.Json/IJsonParser.cs b/src/Spectre.Console.Json/IJsonParser.cs new file mode 100644 index 000000000..dffdbe2e9 --- /dev/null +++ b/src/Spectre.Console.Json/IJsonParser.cs @@ -0,0 +1,14 @@ +namespace Spectre.Console.Json; + +/// +/// Represents a JSON parser. +/// +public interface IJsonParser +{ + /// + /// Parses the provided JSON into an abstract syntax tree. + /// + /// The JSON to parse. + /// An instance. + JsonSyntax Parse(string json); +} diff --git a/src/Spectre.Console.Json/JsonBuilder.cs b/src/Spectre.Console.Json/JsonBuilder.cs new file mode 100644 index 000000000..a4ec7ff4a --- /dev/null +++ b/src/Spectre.Console.Json/JsonBuilder.cs @@ -0,0 +1,101 @@ +namespace Spectre.Console.Json; + +internal sealed class JsonBuilderContext +{ + public Paragraph Paragraph { get; } + public int Indentation { get; set; } + public JsonTextStyles Styling { get; } + + public JsonBuilderContext(JsonTextStyles styling) + { + Paragraph = new Paragraph(); + Styling = styling; + } + + public void InsertIndentation() + { + Paragraph.Append(new string(' ', Indentation * 3)); + } +} + +internal sealed class JsonBuilder : JsonSyntaxVisitor +{ + public static JsonBuilder Shared { get; } = new JsonBuilder(); + + public override void VisitObject(JsonObject syntax, JsonBuilderContext context) + { + context.Paragraph.Append("{", context.Styling.BracesStyle); + context.Paragraph.Append("\n"); + context.Indentation++; + + foreach (var (_, _, last, property) in syntax.Members.Enumerate()) + { + context.InsertIndentation(); + property.Accept(this, context); + + if (!last) + { + context.Paragraph.Append(",", context.Styling.CommaStyle); + } + + context.Paragraph.Append("\n"); + } + + context.Indentation--; + context.InsertIndentation(); + context.Paragraph.Append("}", context.Styling.BracesStyle); + } + + public override void VisitArray(JsonArray syntax, JsonBuilderContext context) + { + context.Paragraph.Append("[", context.Styling.BracketsStyle); + context.Paragraph.Append("\n"); + context.Indentation++; + + foreach (var (_, _, last, item) in syntax.Items.Enumerate()) + { + context.InsertIndentation(); + item.Accept(this, context); + + if (!last) + { + context.Paragraph.Append(",", context.Styling.CommaStyle); + } + + context.Paragraph.Append("\n"); + } + + context.Indentation--; + context.InsertIndentation(); + context.Paragraph.Append("]", context.Styling.BracketsStyle); + } + + public override void VisitMember(JsonMember syntax, JsonBuilderContext context) + { + context.Paragraph.Append(syntax.Name, context.Styling.MemberStyle); + context.Paragraph.Append(":", context.Styling.ColonStyle); + context.Paragraph.Append(" "); + + syntax.Value.Accept(this, context); + } + + public override void VisitNumber(JsonNumber syntax, JsonBuilderContext context) + { + context.Paragraph.Append(syntax.Lexeme, context.Styling.NumberStyle); + } + + public override void VisitString(JsonString syntax, JsonBuilderContext context) + { + context.Paragraph.Append(syntax.Lexeme, context.Styling.StringStyle); + } + + public override void VisitBoolean(JsonBoolean syntax, JsonBuilderContext context) + { + context.Paragraph.Append(syntax.Lexeme, context.Styling.BooleanStyle); + } + + public override void VisitNull(JsonNull syntax, JsonBuilderContext context) + { + context.Paragraph.Append(syntax.Lexeme, context.Styling.NullStyle); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Json/JsonParser.cs b/src/Spectre.Console.Json/JsonParser.cs new file mode 100644 index 000000000..4bfc4896c --- /dev/null +++ b/src/Spectre.Console.Json/JsonParser.cs @@ -0,0 +1,146 @@ +namespace Spectre.Console.Json; + +internal sealed class JsonParser : IJsonParser +{ + public static JsonParser Shared { get; } = new JsonParser(); + + public JsonSyntax Parse(string json) + { + try + { + var tokens = JsonTokenizer.Tokenize(json); + var reader = new JsonTokenReader(tokens); + return ParseElement(reader); + } + catch + { + throw new InvalidOperationException("Invalid JSON"); + } + } + + private static JsonSyntax ParseElement(JsonTokenReader reader) + { + return ParseValue(reader); + } + + private static List ParseElements(JsonTokenReader reader) + { + var members = new List(); + + while (!reader.Eof) + { + members.Add(ParseElement(reader)); + + if (reader.Peek()?.Type != JsonTokenType.Comma) + { + break; + } + + reader.Consume(JsonTokenType.Comma); + } + + return members; + } + + private static JsonSyntax ParseValue(JsonTokenReader reader) + { + var current = reader.Peek(); + if (current == null) + { + throw new InvalidOperationException("Could not parse value (EOF)"); + } + + if (current.Type == JsonTokenType.LeftBrace) + { + return ParseObject(reader); + } + + if (current.Type == JsonTokenType.LeftBracket) + { + return ParseArray(reader); + } + + if (current.Type == JsonTokenType.Number) + { + reader.Consume(JsonTokenType.Number); + return new JsonNumber(current.Lexeme); + } + + if (current.Type == JsonTokenType.String) + { + reader.Consume(JsonTokenType.String); + return new JsonString(current.Lexeme); + } + + if (current.Type == JsonTokenType.Boolean) + { + reader.Consume(JsonTokenType.Boolean); + return new JsonBoolean(current.Lexeme); + } + + if (current.Type == JsonTokenType.Null) + { + reader.Consume(JsonTokenType.Null); + return new JsonNull(current.Lexeme); + } + + throw new InvalidOperationException($"Unknown value token: {current.Type}"); + } + + private static JsonSyntax ParseObject(JsonTokenReader reader) + { + reader.Consume(JsonTokenType.LeftBrace); + + var result = new JsonObject(); + + if (reader.Peek()?.Type != JsonTokenType.RightBrace) + { + result.Members.AddRange(ParseMembers(reader)); + } + + reader.Consume(JsonTokenType.RightBrace); + return result; + } + + private static JsonSyntax ParseArray(JsonTokenReader reader) + { + reader.Consume(JsonTokenType.LeftBracket); + + var result = new JsonArray(); + + if (reader.Peek()?.Type != JsonTokenType.RightBracket) + { + result.Items.AddRange(ParseElements(reader)); + } + + reader.Consume(JsonTokenType.RightBracket); + return result; + } + + private static List ParseMembers(JsonTokenReader reader) + { + var members = new List(); + + while (!reader.Eof) + { + members.Add(ParseMember(reader)); + + if (reader.Peek()?.Type != JsonTokenType.Comma) + { + break; + } + + reader.Consume(JsonTokenType.Comma); + } + + return members; + } + + private static JsonMember ParseMember(JsonTokenReader reader) + { + var name = reader.Consume(JsonTokenType.String); + reader.Consume(JsonTokenType.Colon); + var value = ParseElement(reader); + return new JsonMember(name.Lexeme, value); + } +} diff --git a/src/Spectre.Console.Json/JsonText.cs b/src/Spectre.Console.Json/JsonText.cs new file mode 100644 index 000000000..21a83daf3 --- /dev/null +++ b/src/Spectre.Console.Json/JsonText.cs @@ -0,0 +1,106 @@ +namespace Spectre.Console.Json; + +/// +/// A renderable piece of JSON text. +/// +public sealed class JsonText : JustInTimeRenderable +{ + private readonly string _json; + private JsonSyntax? _syntax; + private IJsonParser? _parser; + + /// + /// Gets or sets the style used for braces. + /// + public Style? BracesStyle { get; set; } + + /// + /// Gets or sets the style used for brackets. + /// + public Style? BracketsStyle { get; set; } + + /// + /// Gets or sets the style used for member names. + /// + public Style? MemberStyle { get; set; } + + /// + /// Gets or sets the style used for colons. + /// + public Style? ColonStyle { get; set; } + + /// + /// Gets or sets the style used for commas. + /// + public Style? CommaStyle { get; set; } + + /// + /// Gets or sets the style used for string literals. + /// + public Style? StringStyle { get; set; } + + /// + /// Gets or sets the style used for number literals. + /// + public Style? NumberStyle { get; set; } + + /// + /// Gets or sets the style used for boolean literals. + /// + public Style? BooleanStyle { get; set; } + + /// + /// Gets or sets the style used for null literals. + /// + public Style? NullStyle { get; set; } + + /// + /// Gets or sets the JSON parser. + /// + public IJsonParser? Parser + { + get + { + return _parser; + } + set + { + _syntax = null; + _parser = value; + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The JSON to render. + public JsonText(string json) + { + _json = json ?? throw new ArgumentNullException(nameof(json)); + } + + /// + protected override IRenderable Build() + { + if (_syntax == null) + { + _syntax = (Parser ?? JsonParser.Shared).Parse(_json); + } + + var context = new JsonBuilderContext(new JsonTextStyles + { + BracesStyle = BracesStyle ?? new Style(Color.Grey), + BracketsStyle = BracketsStyle ?? new Style(Color.Grey), + MemberStyle = MemberStyle ?? new Style(Color.Blue), + ColonStyle = ColonStyle ?? new Style(Color.Yellow), + CommaStyle = CommaStyle ?? new Style(Color.Grey), + StringStyle = StringStyle ?? new Style(Color.Red), + NumberStyle = NumberStyle ?? new Style(Color.Green), + BooleanStyle = BooleanStyle ?? new Style(Color.Green), + NullStyle = NullStyle ?? new Style(Color.Grey), + }); + + _syntax.Accept(JsonBuilder.Shared, context); + return context.Paragraph; + } +} diff --git a/src/Spectre.Console.Json/JsonTextExtensions.cs b/src/Spectre.Console.Json/JsonTextExtensions.cs new file mode 100644 index 000000000..407b0415c --- /dev/null +++ b/src/Spectre.Console.Json/JsonTextExtensions.cs @@ -0,0 +1,313 @@ +namespace Spectre.Console.Json; + +/// +/// Contains extension methods for . +/// +public static class JsonTextExtensions +{ + /// + /// Sets the style used for braces. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BracesStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BracesStyle = style; + return text; + } + + /// + /// Sets the style used for brackets. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BracketStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BracketsStyle = style; + return text; + } + + /// + /// Sets the style used for member names. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText MemberStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.MemberStyle = style; + return text; + } + + /// + /// Sets the style used for colons. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText ColonStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.ColonStyle = style; + return text; + } + + /// + /// Sets the style used for commas. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText CommaStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.CommaStyle = style; + return text; + } + + /// + /// Sets the style used for string literals. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText StringStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.StringStyle = style; + return text; + } + + /// + /// Sets the style used for number literals. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText NumberStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.NumberStyle = style; + return text; + } + + /// + /// Sets the style used for boolean literals. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BooleanStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BooleanStyle = style; + return text; + } + + /// + /// Sets the style used for null literals. + /// + /// The JSON text instance. + /// The style to set. + /// The same instance so that multiple calls can be chained. + public static JsonText NullStyle(this JsonText text, Style? style) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.NullStyle = style; + return text; + } + + /// + /// Sets the color used for braces. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BracesColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BracesStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for brackets. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BracketColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BracketsStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for member names. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText MemberColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.MemberStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for colons. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText ColonColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.ColonStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for commas. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText CommaColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.CommaStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for string literals. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText StringColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.StringStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for number literals. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText NumberColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.NumberStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for boolean literals. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText BooleanColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.BooleanStyle = new Style(color); + return text; + } + + /// + /// Sets the color used for null literals. + /// + /// The JSON text instance. + /// The color to set. + /// The same instance so that multiple calls can be chained. + public static JsonText NullColor(this JsonText text, Color color) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + text.NullStyle = new Style(color); + return text; + } +} diff --git a/src/Spectre.Console.Json/JsonTextStyles.cs b/src/Spectre.Console.Json/JsonTextStyles.cs new file mode 100644 index 000000000..388fa79e1 --- /dev/null +++ b/src/Spectre.Console.Json/JsonTextStyles.cs @@ -0,0 +1,14 @@ +namespace Spectre.Console.Json; + +internal sealed class JsonTextStyles +{ + public Style BracesStyle { get; set; } = null!; + public Style BracketsStyle { get; set; } = null!; + public Style MemberStyle { get; set; } = null!; + public Style ColonStyle { get; set; } = null!; + public Style CommaStyle { get; set; } = null!; + public Style StringStyle { get; set; } = null!; + public Style NumberStyle { get; set; } = null!; + public Style BooleanStyle { get; set; } = null!; + public Style NullStyle { get; set; } = null!; +} diff --git a/src/Spectre.Console.Json/JsonToken.cs b/src/Spectre.Console.Json/JsonToken.cs new file mode 100644 index 000000000..50e0c505a --- /dev/null +++ b/src/Spectre.Console.Json/JsonToken.cs @@ -0,0 +1,13 @@ +namespace Spectre.Console.Json; + +internal sealed class JsonToken +{ + public JsonTokenType Type { get; } + public string Lexeme { get; } + + public JsonToken(JsonTokenType type, string lexeme) + { + Type = type; + Lexeme = lexeme ?? throw new ArgumentNullException(nameof(lexeme)); + } +} diff --git a/src/Spectre.Console.Json/JsonTokenReader.cs b/src/Spectre.Console.Json/JsonTokenReader.cs new file mode 100644 index 000000000..324c51150 --- /dev/null +++ b/src/Spectre.Console.Json/JsonTokenReader.cs @@ -0,0 +1,55 @@ +namespace Spectre.Console.Json; + +internal sealed class JsonTokenReader +{ + private readonly List _reader; + private readonly int _length; + + public int Position { get; private set; } + public bool Eof => Position >= _length; + + public JsonTokenReader(List tokens) + { + _reader = tokens; + _length = tokens.Count; + + Position = 0; + } + + public JsonToken Consume(JsonTokenType type) + { + var read = Read(); + if (read == null) + { + throw new InvalidOperationException("Could not read token"); + } + + if (read.Type != type) + { + throw new InvalidOperationException($"Expected '{type}' token, but found '{read.Type}'"); + } + + return read; + } + + public JsonToken? Peek() + { + if (Eof) + { + return null; + } + + return _reader[Position]; + } + + public JsonToken? Read() + { + if (Eof) + { + return null; + } + + Position++; + return _reader[Position - 1]; + } +} diff --git a/src/Spectre.Console.Json/JsonTokenType.cs b/src/Spectre.Console.Json/JsonTokenType.cs new file mode 100644 index 000000000..1731239ee --- /dev/null +++ b/src/Spectre.Console.Json/JsonTokenType.cs @@ -0,0 +1,15 @@ +namespace Spectre.Console.Json; + +internal enum JsonTokenType +{ + LeftBrace, + RightBrace, + LeftBracket, + RightBracket, + Colon, + Comma, + String, + Number, + Boolean, + Null, +} diff --git a/src/Spectre.Console.Json/JsonTokenizer.cs b/src/Spectre.Console.Json/JsonTokenizer.cs new file mode 100644 index 000000000..8ba42ac7b --- /dev/null +++ b/src/Spectre.Console.Json/JsonTokenizer.cs @@ -0,0 +1,205 @@ +using System.Text; + +namespace Spectre.Console.Json; + +internal static class JsonTokenizer +{ + private static readonly Dictionary _typeLookup; + private static readonly Dictionary _keywords; + private static readonly HashSet _allowedEscapedChars; + + static JsonTokenizer() + { + _typeLookup = new Dictionary + { + { '{', JsonTokenType.LeftBrace }, + { '}', JsonTokenType.RightBrace }, + { '[', JsonTokenType.LeftBracket }, + { ']', JsonTokenType.RightBracket }, + { ':', JsonTokenType.Colon }, + { ',', JsonTokenType.Comma }, + }; + + _keywords = new Dictionary + { + { "true", JsonTokenType.Boolean }, + { "false", JsonTokenType.Boolean }, + { "null", JsonTokenType.Null }, + }; + + _allowedEscapedChars = new HashSet + { + '\"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u', + }; + } + + public static List Tokenize(string text) + { + var result = new List(); + var buffer = new StringBuffer(text); + + while (!buffer.Eof) + { + var current = buffer.Peek(); + + if (_typeLookup.TryGetValue(current, out var tokenType)) + { + buffer.Read(); // Consume + result.Add(new JsonToken(tokenType, current.ToString())); + continue; + } + else if (current == '\"') + { + result.Add(ReadString(buffer)); + } + else if (current == '-' || current.IsDigit()) + { + result.Add(ReadNumber(buffer)); + } + else if (current is ' ' or '\n' or '\r' or '\t') + { + buffer.Read(); // Consume + } + else if (char.IsLetter(current)) + { + var accumulator = new StringBuilder(); + while (!buffer.Eof) + { + current = buffer.Peek(); + if (!char.IsLetter(current)) + { + break; + } + + buffer.Read(); // Consume + accumulator.Append(current); + } + + if (!_keywords.TryGetValue(accumulator.ToString(), out var keyword)) + { + throw new InvalidOperationException($"Encountered invalid keyword '{keyword}'"); + } + + result.Add(new JsonToken(keyword, accumulator.ToString())); + } + else + { + throw new InvalidOperationException("Invalid token"); + } + } + + return result; + } + + private static JsonToken ReadString(StringBuffer buffer) + { + var accumulator = new StringBuilder(); + accumulator.Append(buffer.Expect('\"')); + + while (!buffer.Eof) + { + var current = buffer.Peek(); + if (current == '\"') + { + break; + } + else if (current == '\\') + { + buffer.Expect('\\'); + + if (buffer.Eof) + { + break; + } + + current = buffer.Read(); + if (!_allowedEscapedChars.Contains(current)) + { + throw new InvalidOperationException("Invalid escape encountered"); + } + + accumulator.Append('\\').Append(current); + } + else + { + accumulator.Append(current); + buffer.Read(); + } + } + + if (buffer.Eof) + { + throw new InvalidOperationException("Unterminated string literal"); + } + + accumulator.Append(buffer.Expect('\"')); + return new JsonToken(JsonTokenType.String, accumulator.ToString()); + } + + private static JsonToken ReadNumber(StringBuffer buffer) + { + var accumulator = new StringBuilder(); + + // Minus? + if (buffer.Peek() == '-') + { + buffer.Read(); + accumulator.Append("-"); + } + + // Digits + var current = buffer.Peek(); + if (current.IsDigit(min: 1)) + { + ReadDigits(buffer, accumulator, min: 1); + } + else if (current == '0') + { + accumulator.Append(buffer.Expect('0')); + } + else + { + throw new InvalidOperationException("Invalid number"); + } + + // Fractions + current = buffer.Peek(); + if (current == '.') + { + accumulator.Append(buffer.Expect('.')); + ReadDigits(buffer, accumulator); + } + + // Exponent + current = buffer.Peek(); + if (current is 'e' or 'E') + { + accumulator.Append(buffer.Read()); + + current = buffer.Peek(); + if (current is '+' or '-') + { + accumulator.Append(buffer.Read()); + } + + ReadDigits(buffer, accumulator); + } + + return new JsonToken(JsonTokenType.Number, accumulator.ToString()); + } + + private static void ReadDigits(StringBuffer buffer, StringBuilder accumulator, int min = 0) + { + while (!buffer.Eof) + { + var current = buffer.Peek(); + if (!current.IsDigit(min)) + { + break; + } + + buffer.Read(); // Consume + accumulator.Append(current); + } + } +} diff --git a/src/Spectre.Console.Json/Properties/Usings.cs b/src/Spectre.Console.Json/Properties/Usings.cs new file mode 100644 index 000000000..bd90d278a --- /dev/null +++ b/src/Spectre.Console.Json/Properties/Usings.cs @@ -0,0 +1,4 @@ +global using System.Text; +global using Spectre.Console.Internal; +global using Spectre.Console.Json.Syntax; +global using Spectre.Console.Rendering; \ No newline at end of file diff --git a/src/Spectre.Console.Json/Spectre.Console.Json.csproj b/src/Spectre.Console.Json/Spectre.Console.Json.csproj new file mode 100644 index 000000000..fab4d2309 --- /dev/null +++ b/src/Spectre.Console.Json/Spectre.Console.Json.csproj @@ -0,0 +1,26 @@ + + + + net7.0;net6.0;netstandard2.0 + enable + true + true + A library that extends Spectre.Console with JSON superpowers. + + + + + + + + + + + + + + + + + + diff --git a/src/Spectre.Console.Json/Syntax/JsonArray.cs b/src/Spectre.Console.Json/Syntax/JsonArray.cs new file mode 100644 index 000000000..566e7a9d6 --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonArray.cs @@ -0,0 +1,25 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents an array in the JSON abstract syntax tree. +/// +public sealed class JsonArray : JsonSyntax +{ + /// + /// Gets the array items. + /// + public List Items { get; } + + /// + /// Initializes a new instance of the class. + /// + public JsonArray() + { + Items = new List(); + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitArray(this, context); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Json/Syntax/JsonBoolean.cs b/src/Spectre.Console.Json/Syntax/JsonBoolean.cs new file mode 100644 index 000000000..0cfa00708 --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonBoolean.cs @@ -0,0 +1,26 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents a boolean literal in the JSON abstract syntax tree. +/// +public sealed class JsonBoolean : JsonSyntax +{ + /// + /// Gets the lexeme. + /// + public string Lexeme { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The lexeme. + public JsonBoolean(string lexeme) + { + Lexeme = lexeme; + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitBoolean(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonMember.cs b/src/Spectre.Console.Json/Syntax/JsonMember.cs new file mode 100644 index 000000000..14f61fd8a --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonMember.cs @@ -0,0 +1,33 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents a member in the JSON abstract syntax tree. +/// +public sealed class JsonMember : JsonSyntax +{ + /// + /// Gets the member name. + /// + public string Name { get; } + + /// + /// Gets the member value. + /// + public JsonSyntax Value { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The value. + public JsonMember(string name, JsonSyntax value) + { + Name = name; + Value = value; + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitMember(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonNull.cs b/src/Spectre.Console.Json/Syntax/JsonNull.cs new file mode 100644 index 000000000..2fe92698d --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonNull.cs @@ -0,0 +1,26 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents a null literal in the JSON abstract syntax tree. +/// +public sealed class JsonNull : JsonSyntax +{ + /// + /// Gets the lexeme. + /// + public string Lexeme { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The lexeme. + public JsonNull(string lexeme) + { + Lexeme = lexeme; + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitNull(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonNumber.cs b/src/Spectre.Console.Json/Syntax/JsonNumber.cs new file mode 100644 index 000000000..d06da4fec --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonNumber.cs @@ -0,0 +1,16 @@ +namespace Spectre.Console.Json.Syntax; + +internal sealed class JsonNumber : JsonSyntax +{ + public string Lexeme { get; } + + public JsonNumber(string lexeme) + { + Lexeme = lexeme; + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitNumber(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonObject.cs b/src/Spectre.Console.Json/Syntax/JsonObject.cs new file mode 100644 index 000000000..3b8b585fb --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonObject.cs @@ -0,0 +1,25 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents an object in the JSON abstract syntax tree. +/// +public sealed class JsonObject : JsonSyntax +{ + /// + /// Gets the object's members. + /// + public List Members { get; } + + /// + /// Initializes a new instance of the class. + /// + public JsonObject() + { + Members = new List(); + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitObject(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonString.cs b/src/Spectre.Console.Json/Syntax/JsonString.cs new file mode 100644 index 000000000..ed2dbf75f --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonString.cs @@ -0,0 +1,26 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents a string literal in the JSON abstract syntax tree. +/// +public sealed class JsonString : JsonSyntax +{ + /// + /// Gets the lexeme. + /// + public string Lexeme { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The lexeme. + public JsonString(string lexeme) + { + Lexeme = lexeme; + } + + internal override void Accept(JsonSyntaxVisitor visitor, T context) + { + visitor.VisitString(this, context); + } +} diff --git a/src/Spectre.Console.Json/Syntax/JsonSyntax.cs b/src/Spectre.Console.Json/Syntax/JsonSyntax.cs new file mode 100644 index 000000000..fbff17898 --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonSyntax.cs @@ -0,0 +1,9 @@ +namespace Spectre.Console.Json.Syntax; + +/// +/// Represents a syntax node in the JSON abstract syntax tree. +/// +public abstract class JsonSyntax +{ + internal abstract void Accept(JsonSyntaxVisitor visitor, T context); +} diff --git a/src/Spectre.Console.Json/Syntax/JsonSyntaxVisitor.cs b/src/Spectre.Console.Json/Syntax/JsonSyntaxVisitor.cs new file mode 100644 index 000000000..409bb4237 --- /dev/null +++ b/src/Spectre.Console.Json/Syntax/JsonSyntaxVisitor.cs @@ -0,0 +1,12 @@ +namespace Spectre.Console.Json.Syntax; + +internal abstract class JsonSyntaxVisitor +{ + public abstract void VisitObject(JsonObject syntax, T context); + public abstract void VisitArray(JsonArray syntax, T context); + public abstract void VisitMember(JsonMember syntax, T context); + public abstract void VisitNumber(JsonNumber syntax, T context); + public abstract void VisitString(JsonString syntax, T context); + public abstract void VisitBoolean(JsonBoolean syntax, T context); + public abstract void VisitNull(JsonNull syntax, T context); +} diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 882adb1ff..5b772532e 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -34,6 +34,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Cli", "Spec EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Cli.Tests", "..\test\Spectre.Console.Cli.Tests\Spectre.Console.Cli.Tests.csproj", "{E07C46D2-714F-4116-BADD-FEE09617A9C4}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Json", "Spectre.Console.Json\Spectre.Console.Json.csproj", "{579E6E31-1E2F-4FE1-8F8C-9770878993E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,6 +118,18 @@ Global {E07C46D2-714F-4116-BADD-FEE09617A9C4}.Release|x64.Build.0 = Release|Any CPU {E07C46D2-714F-4116-BADD-FEE09617A9C4}.Release|x86.ActiveCfg = Release|Any CPU {E07C46D2-714F-4116-BADD-FEE09617A9C4}.Release|x86.Build.0 = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|x64.Build.0 = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Debug|x86.Build.0 = Debug|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|Any CPU.Build.0 = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|x64.ActiveCfg = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|x64.Build.0 = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|x86.ActiveCfg = Release|Any CPU + {579E6E31-1E2F-4FE1-8F8C-9770878993E9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -123,6 +137,7 @@ Global GlobalSection(NestedProjects) = preSolution {C3E2CB5C-1517-4C75-B59A-93D4E22BEC8D} = {20595AD4-8D75-4AF8-B6BC-9C38C160423F} {0EFE694D-0770-4E71-BF4E-EC2B41362F79} = {E0E45070-123C-4A4D-AA98-2A780308876C} + {579E6E31-1E2F-4FE1-8F8C-9770878993E9} = {E0E45070-123C-4A4D-AA98-2A780308876C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/Extensions/CharExtensions.cs b/src/Spectre.Console/Extensions/CharExtensions.cs index 0b0b3fba9..268a2dfaa 100644 --- a/src/Spectre.Console/Extensions/CharExtensions.cs +++ b/src/Spectre.Console/Extensions/CharExtensions.cs @@ -3,7 +3,7 @@ namespace Spectre.Console; /// /// Contains extension methods for . /// -public static class CharExtensions +public static partial class CharExtensions { /// /// Gets the cell width of a character. diff --git a/src/Spectre.Console/Internal/Extensions/CharExtensions.cs b/src/Spectre.Console/Internal/Extensions/CharExtensions.cs new file mode 100644 index 000000000..ddc1fe4b7 --- /dev/null +++ b/src/Spectre.Console/Internal/Extensions/CharExtensions.cs @@ -0,0 +1,9 @@ +namespace Spectre.Console.Internal; + +internal static partial class CharExtensions +{ + public static bool IsDigit(this char character, int min = 0) + { + return char.IsDigit(character) && character >= (char)min; + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Extensions/EnumerableExtensions.cs b/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs similarity index 98% rename from src/Spectre.Console/Extensions/EnumerableExtensions.cs rename to src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs index b41d4c7bc..4616f8c98 100644 --- a/src/Spectre.Console/Extensions/EnumerableExtensions.cs +++ b/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs @@ -118,7 +118,7 @@ public static IEnumerable SelectIndex(this IEnumerable s } #endif - public static IEnumerable<(TFirst First, TSecond Second, TThird Third)> Zip( + public static IEnumerable<(TFirst First, TSecond Second, TThird Third)> ZipThree( this IEnumerable first, IEnumerable second, IEnumerable third) { return first.Zip(second, (a, b) => (a, b)) diff --git a/src/Spectre.Console/Internal/Ratio.cs b/src/Spectre.Console/Internal/Ratio.cs index 5865b7239..fb38bdd3b 100644 --- a/src/Spectre.Console/Internal/Ratio.cs +++ b/src/Spectre.Console/Internal/Ratio.cs @@ -89,7 +89,7 @@ public static List Reduce(int total, List ratios, List maximums, var totalRemaining = total; var result = new List(); - foreach (var (ratio, maximum, value) in ratios.Zip(maximums, values)) + foreach (var (ratio, maximum, value) in ratios.ZipThree(maximums, values)) { if (ratio != 0 && totalRatio > 0) { diff --git a/src/Spectre.Console/Internal/Text/StringBuffer.cs b/src/Spectre.Console/Internal/Text/StringBuffer.cs index 61a189f64..61fd03d33 100644 --- a/src/Spectre.Console/Internal/Text/StringBuffer.cs +++ b/src/Spectre.Console/Internal/Text/StringBuffer.cs @@ -23,11 +23,22 @@ public void Dispose() _reader.Dispose(); } + public char Expect(char character) + { + var read = Read(); + if (read != character) + { + throw new InvalidOperationException($"Expected '{character}', but found '{read}'"); + } + + return read; + } + public char Peek() { if (Eof) { - throw new InvalidOperationException("Tried to peek past the end of the text."); + return '\0'; } return (char)_reader.Peek(); @@ -37,7 +48,7 @@ public char Read() { if (Eof) { - throw new InvalidOperationException("Tried to read past the end of the text."); + return '\0'; } Position++; diff --git a/src/Spectre.Console/Spectre.Console.csproj b/src/Spectre.Console/Spectre.Console.csproj index 102ef074c..bf1630c76 100644 --- a/src/Spectre.Console/Spectre.Console.csproj +++ b/src/Spectre.Console/Spectre.Console.csproj @@ -36,6 +36,10 @@ + + + + $(DefineConstants)TRACE;WCWIDTH_VISIBILITY_INTERNAL diff --git a/test/Spectre.Console.Tests/Data/example.json b/test/Spectre.Console.Tests/Data/example.json new file mode 100644 index 000000000..66351bf21 --- /dev/null +++ b/test/Spectre.Console.Tests/Data/example.json @@ -0,0 +1,68 @@ +{ + "id": "0001", + "type": "donut", + "name": "Cake", + "ppu": 0.55, + "foo": true, + "bar": false, + "qux": 32, + "corgi": null, + "batters": { + "batter": [ + { + "id": "1001", + "type": "Regular", + "min": 0 + }, + { + "id": "1002", + "type": "Chocolate", + "min": 0.32 + }, + { + "id": "1003", + "min": 12.32, + "type": "Blueberry" + }, + { + "id": "1004", + "min": 0.32E-12, + "type": "Devil's Food" + } + ] + }, + "topping": [ + { + "id": "5001", + "min": 0.32e-12, + "type": "None" + }, + { + "id": "5002", + "min": 0.32E+12, + "type": "Glazed" + }, + { + "id": "5005", + "min": 0.32e+12, + "type": "Sugar" + }, + { + "id": "5007", + "min": 0.32e12, + "type": "Powdered Sugar" + }, + { + "id": "5006", + "type": "Chocolate with Sprinkles" + }, + { + "id": "5003", + "type": "Chocolate" + }, + { + "id": "5004", + "type": "Maple" + } + ] +} \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Expectations/Widgets/Json/Render_Json.Output.verified.txt b/test/Spectre.Console.Tests/Expectations/Widgets/Json/Render_Json.Output.verified.txt new file mode 100644 index 000000000..8d722baa2 --- /dev/null +++ b/test/Spectre.Console.Tests/Expectations/Widgets/Json/Render_Json.Output.verified.txt @@ -0,0 +1,70 @@ +┌─Some JSON───────────────────────────────────┐ +│ { │ +│ "id": "0001", │ +│ "type": "donut", │ +│ "name": "Cake", │ +│ "ppu": 0.55, │ +│ "foo": true, │ +│ "bar": false, │ +│ "qux": 32, │ +│ "corgi": null, │ +│ "batters": { │ +│ "batter": [ │ +│ { │ +│ "id": "1001", │ +│ "type": "Regular", │ +│ "min": 0 │ +│ }, │ +│ { │ +│ "id": "1002", │ +│ "type": "Chocolate", │ +│ "min": 0.32 │ +│ }, │ +│ { │ +│ "id": "1003", │ +│ "min": 12.32, │ +│ "type": "Blueberry" │ +│ }, │ +│ { │ +│ "id": "1004", │ +│ "min": 0.32E-12, │ +│ "type": "Devil's Food" │ +│ } │ +│ ] │ +│ }, │ +│ "topping": [ │ +│ { │ +│ "id": "5001", │ +│ "min": 0.32e-12, │ +│ "type": "None" │ +│ }, │ +│ { │ +│ "id": "5002", │ +│ "min": 0.32E+12, │ +│ "type": "Glazed" │ +│ }, │ +│ { │ +│ "id": "5005", │ +│ "min": 0.32e+12, │ +│ "type": "Sugar" │ +│ }, │ +│ { │ +│ "id": "5007", │ +│ "min": 0.32e12, │ +│ "type": "Powdered Sugar" │ +│ }, │ +│ { │ +│ "id": "5006", │ +│ "type": "Chocolate with Sprinkles" │ +│ }, │ +│ { │ +│ "id": "5003", │ +│ "type": "Chocolate" │ +│ }, │ +│ { │ +│ "id": "5004", │ +│ "type": "Maple" │ +│ } │ +│ ] │ +│ } │ +└─────────────────────────────────────────────┘ diff --git a/test/Spectre.Console.Tests/Extensions/StreamExtensions.cs b/test/Spectre.Console.Tests/Extensions/StreamExtensions.cs new file mode 100644 index 000000000..805b50350 --- /dev/null +++ b/test/Spectre.Console.Tests/Extensions/StreamExtensions.cs @@ -0,0 +1,17 @@ +namespace Spectre.Console.Tests; + +public static class StreamExtensions +{ + public static string ReadText(this Stream stream) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } +} diff --git a/test/Spectre.Console.Tests/Properties/Usings.cs b/test/Spectre.Console.Tests/Properties/Usings.cs index cd79736a7..761b4c5e0 100644 --- a/test/Spectre.Console.Tests/Properties/Usings.cs +++ b/test/Spectre.Console.Tests/Properties/Usings.cs @@ -10,6 +10,7 @@ global using System.Threading.Tasks; global using Shouldly; global using Spectre.Console.Advanced; +global using Spectre.Console.Json; global using Spectre.Console.Rendering; global using Spectre.Console.Testing; global using Spectre.Console.Tests.Data; diff --git a/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj b/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj index 4d7481040..e9e959e49 100644 --- a/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj +++ b/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj @@ -6,7 +6,9 @@ + + @@ -29,6 +31,7 @@ + diff --git a/test/Spectre.Console.Tests/Unit/Widgets/JsonTextTests.cs b/test/Spectre.Console.Tests/Unit/Widgets/JsonTextTests.cs new file mode 100644 index 000000000..09d457607 --- /dev/null +++ b/test/Spectre.Console.Tests/Unit/Widgets/JsonTextTests.cs @@ -0,0 +1,23 @@ +namespace Spectre.Console.Tests.Unit; + +[UsesVerify] +[ExpectationPath("Widgets/Json")] +public sealed class JsonTextTests +{ + [Fact] + [Expectation("Render_Json")] + public Task Should_Render_Json() + { + // Given + var console = new TestConsole().Size(new Size(80, 15)); + var json = EmbeddedResourceReader + .LoadResourceStream("Spectre.Console.Tests/Data/example.json") + .ReadText(); + + // When + console.Write(new Panel(new JsonText(json)).Header("Some JSON")); + + // Then + return Verifier.Verify(console.Output); + } +}