From ff2b75ad1a60d82d8ad9880c9968e142e4d6973b Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Wed, 17 Jul 2019 13:56:08 -0700 Subject: [PATCH 1/3] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 865b0d930..d4d860ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ _TeamCity* _NCrunch_* .*crunch*.local.xml nCrunchTemp_* +*.ncrunch* # MightyMoose *.mm.* From 5acdffbc763dabb45340b0604d0dc3d33c7669eb Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Wed, 17 Jul 2019 13:56:52 -0700 Subject: [PATCH 2/3] Microsoft.DotNet.Interactive.Rendering --- DotNetTry.sln | 14 + .../FormatterTests.cs | 649 ++++++++++++++++++ .../HtmlAttributesTests.cs | 337 +++++++++ ....DotNet.Interactive.Rendering.Tests.csproj | 26 + .../PocketViewTests.cs | 287 ++++++++ .../PocketViewWithFormatterTests.cs | 85 +++ .../StringExtensions.cs | 32 + .../TestClasses.cs | 92 +++ .../DictionaryExtensions.cs | 26 + .../Formatter.cs | 358 ++++++++++ .../Formatter{T}.cs | 260 +++++++ .../Html.cs | 31 + .../HtmlAttributes.cs | 397 +++++++++++ .../HtmlAttributesExtensions.cs | 208 ++++++ .../IDisplayTextFormatter.cs | 22 + .../ITag.cs | 22 + .../JsonString.cs | 18 + .../MemberAccessor{T}.cs | 33 + ...rosoft.DotNet.Interactive.Rendering.csproj | 17 + .../PocketView.cs | 350 ++++++++++ .../PocketViewTags.cs | 83 +++ .../RecursionCounter.cs | 25 + .../SingleLineTextFormatter.cs | 46 ++ Microsoft.DotNet.Interactive.Rendering/Tag.cs | 147 ++++ .../TagExtensions.cs | 219 ++++++ .../TypeExtensions.cs | 92 +++ 26 files changed, 3876 insertions(+) create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/FormatterTests.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/Microsoft.DotNet.Interactive.Rendering.Tests.csproj create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewTests.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/StringExtensions.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering.Tests/TestClasses.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/DictionaryExtensions.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/Formatter.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/Formatter{T}.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/Html.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/HtmlAttributes.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/HtmlAttributesExtensions.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/IDisplayTextFormatter.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/ITag.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/JsonString.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/MemberAccessor{T}.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj create mode 100644 Microsoft.DotNet.Interactive.Rendering/PocketView.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/PocketViewTags.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/RecursionCounter.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/SingleLineTextFormatter.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/Tag.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/TagExtensions.cs create mode 100644 Microsoft.DotNet.Interactive.Rendering/TypeExtensions.cs diff --git a/DotNetTry.sln b/DotNetTry.sln index ce79c7ad8..da58bc3c7 100644 --- a/DotNetTry.sln +++ b/DotNetTry.sln @@ -55,6 +55,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.ProjectTem EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharpWorkspaceShim", "FSharpWorkspaceShim\FSharpWorkspaceShim.fsproj", "{9128FCED-2A19-4502-BCEE-BE1BAB6882EB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Interactive.Rendering", "Microsoft.DotNet.Interactive.Rendering\Microsoft.DotNet.Interactive.Rendering.csproj", "{FBEA5F71-23F5-4412-A936-9B8E6E228968}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Interactive.Rendering.Tests", "Microsoft.DotNet.Interactive.Rendering.Tests\Microsoft.DotNet.Interactive.Rendering.Tests.csproj", "{B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +161,14 @@ Global {9128FCED-2A19-4502-BCEE-BE1BAB6882EB}.Debug|Any CPU.Build.0 = Debug|Any CPU {9128FCED-2A19-4502-BCEE-BE1BAB6882EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {9128FCED-2A19-4502-BCEE-BE1BAB6882EB}.Release|Any CPU.Build.0 = Release|Any CPU + {FBEA5F71-23F5-4412-A936-9B8E6E228968}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBEA5F71-23F5-4412-A936-9B8E6E228968}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBEA5F71-23F5-4412-A936-9B8E6E228968}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBEA5F71-23F5-4412-A936-9B8E6E228968}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -186,6 +198,8 @@ Global {1F1A7554-1E88-4514-8602-EC00899E0C49} = {8192FEAD-BCE6-4E62-97E5-2E9EA884BD71} {E047D81A-7A18-4A1A-98E8-EDBB51EBB7DB} = {6EE8F484-DFA2-4F0F-939F-400CE78DFAC2} {9128FCED-2A19-4502-BCEE-BE1BAB6882EB} = {6EE8F484-DFA2-4F0F-939F-400CE78DFAC2} + {FBEA5F71-23F5-4412-A936-9B8E6E228968} = {6EE8F484-DFA2-4F0F-939F-400CE78DFAC2} + {B4B9DC70-6BA2-4BC1-A780-7FCEBDB1D218} = {8192FEAD-BCE6-4E62-97E5-2E9EA884BD71} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D6CD99BA-B16B-4570-8910-225CBDFFA3AD} diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/FormatterTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/FormatterTests.cs new file mode 100644 index 000000000..8c216ca06 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/FormatterTests.cs @@ -0,0 +1,649 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Data; +using System.Dynamic; +using System.IO; +using FluentAssertions; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public class FormatterTests + { + public FormatterTests() + { + Formatter.ResetToDefault(); + } + + [Fact] + public virtual void Generate_creates_a_function_that_emits_the_property_names_and_values_for_a_specific_type() + { + var write = Formatter.GenerateForAllMembers(); + + var writer = new StringWriter(); + write(new Widget { Name = "Bob" }, writer); + + var s = writer.ToString(); + s.Should().Contain("Name: Bob"); + } + + [Fact] + public virtual void GenerateFor_creates_a_function_that_emits_the_specified_property_names_and_values_for_a_specific_type() + { + Formatter.RegisterForMembers( + o => o.DateProperty, + o => o.StringProperty); + + var s = new SomethingWithLotsOfProperties + { + DateProperty = DateTime.MinValue, + StringProperty = "howdy" + }.ToDisplayString(); + + s.Should().Contain("DateProperty: 0001-01-01 00:00:00Z"); + s.Should().Contain("StringProperty: howdy"); + s.Should().NotContain("IntProperty"); + s.Should().NotContain("BoolProperty"); + s.Should().NotContain("UriProperty"); + } + + [Fact] + public void GenerateForMembers_throws_when_an_expression_is_not_a_MemberExpression() + { + var ex = Assert.Throws(() => Formatter.GenerateForMembers( + o => o.DateProperty.ToShortDateString(), + o => o.StringProperty)); + + ex.Message.Should().Contain("o => o.DateProperty.ToShortDateString()"); + } + + [Fact] + public void Recursive_formatter_calls_do_not_cause_exceptions() + { + var widget = new Widget(); + widget.Parts = new List { new Part { Widget = widget } }; + + Formatter.RegisterForMembers(); + Formatter.RegisterForMembers(); + + // this should not throw + var _ = widget.ToDisplayString(); + } + + [Fact] + public void Formatter_expands_IEnumerable() + { + var list = new List { "this", "that", "the other thing" }; + + var formatted = list.ToDisplayString(); + + formatted.Should().Be("[ this, that, the other thing ]"); + } + + [Fact] + public void Formatter_expands_properties_of_ExpandoObjects() + { + dynamic expando = new ExpandoObject(); + expando.Name = "socks"; + expando.Parts = null; + Formatter.RegisterForMembers(); + + var expandoString = ((object) expando).ToDisplayString(); + + expandoString.Should().Be("{ Name: socks, Parts: }"); + } + + [Fact] + public void Formatter_does_not_expand_string() + { + // set up a recursive call, so that the custom formatter will not be used once we go far enough in + var widget = new Widget(); + widget.Parts = new List { new Part { Widget = widget } }; + + Formatter.RegisterForMembers(); + Formatter.RegisterForMembers(); + + // this should not throw + var s = widget.ToDisplayString(); + + s.Should().NotContain("{ D },{ e }"); + } + + [Fact] + public void Default_formatter_for_Type_displays_only_the_name() + { + GetType().ToDisplayString() + .Should().Be(GetType().Name); + typeof(FormatterTests).ToDisplayString() + .Should().Be(typeof(FormatterTests).Name); + } + + [Fact] + public void Default_formatter_for_Type_displays_generic_parameter_name_for_single_parameter_generic_type() + { + typeof(List).ToDisplayString() + .Should().Be("List"); + new List().GetType().ToDisplayString() + .Should().Be("List"); + } + + [Fact] + public void Default_formatter_for_Type_displays_generic_parameter_name_for_multiple_parameter_generic_type() + { + typeof(Dictionary>).ToDisplayString() + .Should().Be("Dictionary>"); + } + + [Fact] + public void Default_formatter_for_Type_displays_generic_parameter_names_for_open_generic_types() + { + typeof(IList<>).ToDisplayString() + .Should().Be("IList"); + typeof(IDictionary<,>).ToDisplayString() + .Should().Be("IDictionary"); + } + + [Fact] + public void Custom_formatter_for_Type_can_be_registered() + { + Formatter.Register(t => t.GUID.ToString()); + + GetType().ToDisplayString() + .Should().Be(GetType().GUID.ToString()); + } + + [Fact] + public void Default_formatter_for_null_Nullable_indicates_null() + { + int? nullable = null; + + var output = nullable.ToDisplayString(); + + output.Should().Be(((object) null).ToDisplayString()); + } + + [Fact] + public virtual void Formatter_recursively_formats_types_within_IEnumerable() + { + var list = new List + { + new Widget { Name = "widget x" }, + new Widget { Name = "widget y" }, + new Widget { Name = "widget z" } + }; + + Formatter.Register( + w => w.Name + ", Parts: " + + (w.Parts == null ? "0" : w.Parts.Count.ToString())); + var formatted = list.ToDisplayString(); + Console.WriteLine(formatted); + + formatted.Should().Be("[ widget x, Parts: 0, widget y, Parts: 0, widget z, Parts: 0 ]"); + } + + [Fact] + public virtual void Formatter_truncates_expansion_of_long_IEnumerable() + { + var list = new List(); + for (var i = 1; i < 11; i++) + { + list.Add("number " + i); + } + + Formatter.ListExpansionLimit = 4; + + var formatted = list.ToDisplayString(); + + formatted.Contains("number 1").Should().BeTrue(); + formatted.Contains("number 4").Should().BeTrue(); + formatted.Should().NotContain("number 5"); + formatted.Contains("6 more").Should().BeTrue(); + } + + [Fact] + public virtual void Formatter_iterates_IEnumerable_property_when_its_actual_type_is_an_array_of_structs() + { + new[] { 1, 2, 3, 4, 5 }.ToDisplayString() + .Should().Be("[ 1, 2, 3, 4, 5 ]"); + } + + [Fact] + public virtual void Formatter_iterates_IEnumerable_property_when_its_actual_type_is_an_array_of_objects() + { + Formatter.RegisterForMembers(); + + var node = new Node + { + Id = "1", + Nodes = + new[] + { + new Node { Id = "1.1" }, + new Node { Id = "1.2" }, + new Node { Id = "1.3" }, + } + }; + + var output = node.ToDisplayString(); + + output.Should().Contain("1.1"); + output.Should().Contain("1.2"); + output.Should().Contain("1.3"); + } + + [Fact] + public virtual void Formatter_iterates_IEnumerable_property_when_its_reflected_type_is_array() + { + Formatter.RegisterForMembers(); + + var node = new Node + { + Id = "1", + NodesArray = + new[] + { + new Node { Id = "1.1" }, + new Node { Id = "1.2" }, + new Node { Id = "1.3" }, + } + }; + + var output = node.ToDisplayString(); + + output.Should().Contain("1.1"); + output.Should().Contain("1.2"); + output.Should().Contain("1.3"); + } + + [Fact] + public virtual void GenerateForAllMembers_expands_properties_of_structs() + { + var write = Formatter.GenerateForAllMembers(); + var id = new EntityId("the typename", "the id"); + var writer = new StringWriter(); + + write(id, writer); + + string value = writer.ToString(); + value.Should().Contain("TypeName: the typename"); + value.Should().Contain("Id: the id"); + } + + [Fact] + public void Static_fields_are_not_written() + { + Formatter.RegisterForMembers(); + + new Widget().ToDisplayString() + .Should().NotContain(nameof(SomethingAWithStaticProperty.StaticField)); + } + + [Fact] + public void Static_properties_are_not_written() + { + Formatter.RegisterForMembers(); + + new Widget().ToDisplayString() + .Should().NotContain(nameof(SomethingAWithStaticProperty.StaticProperty)); + } + + [Fact] + public virtual void GenerateForAllMembers_expands_fields_of_objects() + { + var write = Formatter.GenerateForAllMembers(); + var today = DateTime.Today; + var tomorrow = DateTime.Today.AddDays(1); + var id = new SomeStruct + { + DateField = today, + DateProperty = tomorrow + }; + var writer = new StringWriter(); + + write(id, writer); + var value = writer.ToString(); + + value.Should().Contain("DateField: "); + value.Should().Contain("DateProperty: "); + } + + [Fact] + public void Exceptions_always_get_properties_formatters() + { + var exception = new ReflectionTypeLoadException( + new[] + { + typeof(FileStyleUriParser), + typeof(AssemblyKeyFileAttribute) + }, + new Exception[] + { + new DataMisalignedException() + }); + + var message = exception.ToDisplayString(); + + message.Should().Contain(nameof(DataMisalignedException.Data)); + message.Should().Contain(nameof(DataMisalignedException.HResult)); + message.Should().Contain(nameof(DataMisalignedException.StackTrace)); + } + + [Fact] + public void Exception_Data_is_included_by_default() + { + var ex = new InvalidOperationException("oh noes!", new NullReferenceException()); + var key = "a very important int"; + ex.Data[key] = 123456; + + var msg = ex.ToDisplayString(); + + msg.Should().Contain(key); + msg.Should().Contain("123456"); + } + + [Fact] + public void Exception_StackTrace_is_included_by_default() + { + string msg; + var ex = new InvalidOperationException("oh noes!", new NullReferenceException()); + + try + { + throw ex; + } + catch (Exception thrownException) + { + msg = thrownException.ToDisplayString(); + } + + msg.Should() + .Contain($"StackTrace: at {GetType().FullName}.{MethodInfo.GetCurrentMethod().Name}"); + } + + [Fact] + public void Exception_Type_is_included_by_default() + { + var ex = new InvalidOperationException("oh noes!", new NullReferenceException()); + + var msg = ex.ToDisplayString(); + + msg.Should().Contain("InvalidOperationException"); + } + + [Fact] + public void Exception_Message_is_included_by_default() + { + var ex = new InvalidOperationException("oh noes!", new NullReferenceException()); + + var msg = ex.ToDisplayString(); + + msg.Should().Contain("oh noes!"); + } + + [Fact] + public void Exception_InnerExceptions_are_included_by_default() + { + var ex = new InvalidOperationException("oh noes!", new NullReferenceException("oh my.", new DataException("oops!"))); + + ex.ToDisplayString() + .Should() + .Contain("NullReferenceException"); + ex.ToDisplayString() + .Should() + .Contain("DataException"); + } + + [Fact] + public void When_a_property_throws_it_does_not_prevent_other_properties_from_being_written() + { + Formatter.RegisterForMembers(); + var log = new SomePropertyThrows().ToDisplayString(); + + log.Should().Contain("Ok:"); + log.Should().Contain("Fine:"); + log.Should().Contain("PerfectlyFine:"); + } + + [Fact] + public void GenerateForAllMembers_can_include_internal_fields() + { + var write = Formatter.GenerateForAllMembers(true); + var writer = new StringWriter(); + + write(new Node { Id = "5" }, writer); + + writer.ToString().Should().Contain("_id: 5"); + } + + [Fact] + public void GenerateForAllMembers_does_not_include_autoproperty_backing_fields() + { + var formatter = Formatter.GenerateForAllMembers(true); + var writer = new StringWriter(); + + formatter(new Node(), writer); + + var output = writer.ToString(); + output.Should().NotContain("k__BackingField"); + output.Should().NotContain("k__BackingField"); + } + + [Fact] + public void GenerateForAllMembers_can_include_internal_properties() + { + var formatter = Formatter.GenerateForAllMembers(true); + var writer = new StringWriter(); + + formatter(new Node { Id = "6" }, writer); + + writer.ToString().Should().Contain("InternalId: 6"); + } + + [Fact] + public void When_ResetToDefault_is_called_then_default_formatters_are_immediately_reregistered() + { + var widget = new Widget { Name = "hola!" }; + + var defaultValue = widget.ToDisplayString(); + + Formatter.Register(e => "hello!"); + + widget.ToDisplayString().Should().NotBe(defaultValue); + + Formatter.ResetToDefault(); + + widget.ToDisplayString().Should().Be(defaultValue); + } + + [Fact] + public void Anonymous_types_are_automatically_fully_formatted() + { + var ints = new[] { 3, 2, 1 }; + + var output = new { ints, count = ints.Length }.ToDisplayString(); + + output.Should().Be("{ ints: [ 3, 2, 1 ], count: 3 }"); + } + + [Fact] + public void ToDisplayString_uses_actual_type_formatter_and_not_compiled_type() + { + Widget widget = new InheritedWidget(); + bool widgetFormatterCalled = false; + bool inheritedWidgetFormatterCalled = false; + + Formatter.Register(w => + { + widgetFormatterCalled = true; + return ""; + }); + Formatter.Register(w => + { + inheritedWidgetFormatterCalled = true; + return ""; + }); + + widget.ToDisplayString(); + + widgetFormatterCalled.Should().BeFalse(); + inheritedWidgetFormatterCalled.Should().BeTrue(); + } + + [Fact] + public async Task RecursionCounter_does_not_share_state_across_threads() + { + var participantCount = 3; + var barrier = new Barrier(participantCount); + var counter = new RecursionCounter(); + + var tasks = Enumerable.Range(1, participantCount) + .Select(i => + { + return Task.Run(() => + { + barrier.SignalAndWait(); + + counter.Depth.Should().Be(0); + + using (counter.Enter()) + { + barrier.SignalAndWait(); + + counter.Depth.Should().Be(1); + + using (counter.Enter()) + { + counter.Depth.Should().Be(2); + } + } + + counter.Depth.Should().Be(0); + }); + }); + + await Task.WhenAll(tasks); + } + + [Fact] + public void Custom_formatters_can_be_registered_for_types_not_known_until_runtime() + { + Formatter.Register( + type: typeof(FileInfo), + formatter: (filInfo, writer) => writer.Write("hello")); + + new FileInfo(@"c:\temp\foo.txt").ToDisplayString() + .Should().Be("hello"); + } + + [Fact] + public void Generated_formatters_can_be_registered_for_types_not_known_until_runtime() + { + var obj = new SomethingWithLotsOfProperties + { + BoolProperty = true, + DateProperty = DateTime.Now, + IntProperty = 42, + StringProperty = "oh hai", + UriProperty = new Uri("http://blammo.com") + }; + var reference = Formatter.GenerateForAllMembers(); + var writer = new StringWriter(); + reference(obj, writer); + + Formatter.RegisterForAllMembers(typeof(SomethingWithLotsOfProperties)); + + obj.ToDisplayString().Should().Be(writer.ToString()); + } + + [Fact] + public void When_JObject_is_formatted_it_outputs_its_string_representation() + { + JObject jObject = JObject.Parse(JsonConvert.SerializeObject(new + { + SomeString = "hello", + SomeInt = 123 + })); + + var output = jObject.ToDisplayString(); + + output.Should().Be(jObject.ToString()); + } + + [Fact] + public void When_JArray_is_formatted_it_outputs_its_string_representation() + { + JArray jArray = JArray.Parse(JsonConvert.SerializeObject(Enumerable.Range(1, 10).Select( + i => new + { + SomeString = "hello", + SomeInt = 123 + }).ToArray())); + + jArray.ToDisplayString() + .Should() + .Be(jArray.ToString()); + } + + [Fact] + public void ListExpansionLimit_can_be_specified_per_type() + { + Formatter>.ListExpansionLimit = 1000; + Formatter.ListExpansionLimit = 4; + var dictionary = new Dictionary + { + { "zero", 0 }, + { "two", 2 }, + { "three", 3 }, + { "four", 4 }, + { "five", 5 }, + { "six", 6 }, + { "seven", 7 }, + { "eight", 8 }, + { "nine", 9 }, + { "ninety-nine", 99 } + }; + + var output = dictionary.ToDisplayString(); + + output.Should().Contain("zero"); + output.Should().Contain("0"); + output.Should().Contain("ninety-nine"); + output.Should().Contain("99"); + } + + [Fact] + public void FormatAllTypes_allows_formatters_to_be_registered_on_fly_for_all_types() + { + Formatter.AutoGenerateForType = t => true; + + new FileInfo(@"c:\temp\foo.txt").ToDisplayString() + .Should().Contain(@"DirectoryName: c:\temp"); + new FileInfo(@"c:\temp\foo.txt").ToDisplayString() + .Should().Contain("Parent: "); + new FileInfo(@"c:\temp\foo.txt").ToDisplayString() + .Should().Contain("Root: "); + new FileInfo(@"c:\temp\foo.txt").ToDisplayString() + .Should().Contain("Exists: "); + } + + [Fact] + public void FormatAllTypes_does_not_reregister_formatters_for_types_having_special_default_formatters() + { + Formatter.AutoGenerateForType = t => true; + + var log = "hello".ToDisplayString(); + + log.Should().Contain("hello"); + log.Should().NotContain("Length"); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs new file mode 100644 index 000000000..ffdc4c922 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using System.Linq; +using Microsoft.AspNetCore.Html; +using Xunit; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public class HtmlAttributesTests + { + [Fact] + public void When_object_constructor_overload_is_passed_a_dictionary_it_initializes_correctly() + { + var attributes = + new HtmlAttributes( + new Dictionary + { + { "class", "required" }, + { "style", "display:none" } + }); + + var html = attributes.ToString(); + + html.Should() + .Be("class=\"required\" style=\"display:none\"", html); + } + + [Fact] + public void When_object_constructor_overload_is_passed_a_HtmlAttributes_it_initializes_correctly() + { + var attributes = + new HtmlAttributes( + new HtmlAttributes + { + { "class", "required" }, + { "style", "display:none" } + }); + + var html = attributes.ToString(); + + html.Should() + .Be("class=\"required\" style=\"display:none\""); + } + + [Fact] + public virtual void Dictionary_Add_adds_items_to_dictionary() + { + var attributes = new HtmlAttributes(); + + attributes.Add("foo", "bar"); + + var value = attributes["foo"].ToString(); + + value.Should().Be("bar"); + } + + [Fact] + public virtual void Collection_initializer_adds_items_to_dictionary() + { + var attributes = new HtmlAttributes { { "foo", "bar" } }; + + var value = attributes["foo"].ToString(); + + value.Should().Be("bar"); + } + + [Fact] + public virtual void When_HtmlAttributes_is_empty_ToString_returns_empty_string() + { + var attributes = new HtmlAttributes(); + + var value = attributes.ToString(); + + value.Should().BeEmpty(); + } + + [Fact] + public virtual void ToString_returns_correct_value_for_single_dictionary_entry() + { + var attributes = new HtmlAttributes { { "foo", "bar" } }; + + attributes.ToString().Should().Be("foo=\"bar\""); + } + + [Fact] + public virtual void ToString_returns_correct_value_for_multiple_dictionary_entries() + { + var attributes = new HtmlAttributes + { + { "one", "1" }, + { "two", "2" }, + }; + + attributes.ToString().Should().Be("one=\"1\" two=\"2\""); + } + + [Fact] + public virtual void Dynamic_assignment_adds_items_to_dictionary() + { + dynamic attributes = new HtmlAttributes(); + + attributes.foo = "bar"; + + string value = attributes["foo"]; + + value.Should().Be("bar"); + } + + [Fact] + public virtual void Dictionary_items_can_be_dynamically_retrieved() + { + dynamic attributes = new HtmlAttributes { { "foo", "bar" } }; + + string foo = attributes.foo; + + foo.Should().Be("bar"); + } + + [Fact] + public virtual void Get_keys_returns_dynamically_assigned_keys() + { + dynamic attributes = new HtmlAttributes(); + attributes.one = 1; + attributes.two = 2; + attributes.three = 3; + attributes.four = 4; + attributes.five = 5; + + ((IDictionary) attributes).Keys.Count.Should().Be(5); + } + + [Fact] + public virtual void Classes_are_aggregated_by_AddCssClass() + { + var attributes = new HtmlAttributes(); + + attributes.AddCssClass("bar"); + attributes.AddCssClass("foo"); + + attributes["class"].Should().Be("foo bar"); + } + + [Fact] + public virtual void When_multiple_Add_calls_use_same_key_it_throws() + { + var attributes = new HtmlAttributes(); + + attributes.Add("class", "one"); + + Assert.Throws(() => attributes.Add("class", "two")); + } + + [Fact] + public virtual void Classes_are_overwritten_during_set_operations() + { + var attributes = new HtmlAttributes(); + + attributes.Add("class", "one"); + attributes["class"] = "two"; + + attributes["class"].Should().Be("two"); + } + + [Fact] + public virtual void Classes_are_overwritten_during_dynamic_set_operations() + { + dynamic attributes = new HtmlAttributes(); + + attributes.Add("class", "one"); + attributes.@class = "two"; + + string @class = attributes.@class; + + @class.Should().Be("two"); + } + + [Fact] + public virtual void Can_remove_dynamically_assigned_value() + { + var attributes = new HtmlAttributes(); + + ((dynamic) attributes).foo = "bar"; + + attributes.Remove("foo"); + + attributes.Count.Should().Be(0); + } + + [Fact] + public virtual void ContainsKey_is_true_for_dynamically_assigned_property() + { + var attributes = new HtmlAttributes(); + + ((dynamic) attributes).foo = "bar"; + + attributes.ContainsKey("foo").Should().BeTrue(); + } + + [Fact] + public virtual void Can_convert_arbitrary_named_parameters_into_attributes() + { + dynamic attributes = new HtmlAttributes(); + + attributes.MergeWith(@class: "required", style: "display:block"); + + string output = attributes.ToString(); + + output.Should() + .Contain("class=\"required\" style=\"display:block\""); + } + + [Fact] + public void Attribute_values_containing_double_quotes_are_attribute_encoded() + { + dynamic attributes = new HtmlAttributes(); + var quote = "\"Reality\" is the only word in the English language that should always be used in quotes."; + + attributes.quote = quote; + + string value = attributes.ToString(); + + value + .Should() + .Be($"quote=\"{quote.HtmlAttributeEncode()}\""); + } + + [Fact] + public void Data_attributes_containing_primitive_values_are_properly_quoted() + { + var attr = new HtmlAttributes(); + + attr.Data("string", "name"); + attr.Data("int", 123); + attr.Data("double", 1.23); + + attr.ToString().Should().Contain("data-int='123'"); + attr.ToString().Should().Contain("data-double='1.23'"); + attr.ToString().Should().Contain("data-string=\"name\""); + } + + [Fact] + public void Data_attributes_containing_IHtmlContent_are_not_reencoded() + { + var attr = new HtmlAttributes(); + + attr.Data("bind", new { name = "Felix" }.SerializeToJson()); + + attr.ToString() + .Should() + .Be("data-bind='{\"name\":\"Felix\"}'"); + } + + [Fact] + public void Data_attributes_containing_json_can_be_added() + { + var attributes = new HtmlAttributes(); + + attributes.Data("data-widget", new { Id = 789, Name = "The Stanley", Price = 24.99m }); + + attributes.ToString() + .Should() + .Be("data-widget='{\"Id\":789,\"Name\":\"The Stanley\",\"Price\":24.99}'"); + } + + [Fact] + public void Data_prepends_the_word_data_if_specified_attribute_name_does_not_start_with_the_word_data() + { + var attributes = new HtmlAttributes(); + + attributes.Data("widget", new { Id = 789, Name = "The Stanley", Price = 24.99m }); + + attributes.ToString() + .Should() + .Be("data-widget='{\"Id\":789,\"Name\":\"The Stanley\",\"Price\":24.99}'"); + } + + [Fact] + public void Empty_id_attributes_are_not_rendered() + { + "div".Tag().WithAttributes(new HtmlAttributes { { "id", null } }).ToString() + .Should() + .NotContain("id"); + + "div".Tag().WithAttributes(new HtmlAttributes { { "id", new HtmlString("") } }).ToString() + .Should() + .NotContain("id"); + + "div".Tag().WithAttributes(new HtmlAttributes { { "id", string.Empty } }).ToString() + .Should() + .NotContain("id"); + } + + [Fact] + public void Attributes_containing_JSON_values_are_not_reencoded() + { + const string expected = @""; + + var part = + new + { + Name = "X-1 Widget", + SerialNumber = "X-1" + }; + + var attr = new HtmlAttributes + { + { "data-url", ("/widgets/" + part.SerialNumber).JsonEncode() } + }; + + var opt = "option" + .Tag() + .Containing(part.Name) + .WithAttributes(attr); + + opt.Crunch() + .Should() + .Be(expected.Crunch()); + } + + [Fact] + public void IsReadOnly_defaults_to_false() + { + new HtmlAttributes().IsReadOnly.Should().BeFalse(); + } + + [Fact] + public void Can_be_cleared() + { + var attributes = new HtmlAttributes { { "one", 1 } }; + + attributes.Count.Should().Be(1); + attributes.Clear(); + attributes.Count.Should().Be(0); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/Microsoft.DotNet.Interactive.Rendering.Tests.csproj b/Microsoft.DotNet.Interactive.Rendering.Tests/Microsoft.DotNet.Interactive.Rendering.Tests.csproj new file mode 100644 index 000000000..09c70983e --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/Microsoft.DotNet.Interactive.Rendering.Tests.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + + + + + + + + + + + + diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewTests.cs new file mode 100644 index 000000000..2d1946320 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewTests.cs @@ -0,0 +1,287 @@ +using System; +using FluentAssertions; +using System.Linq; +using Xunit; +using static Microsoft.DotNet.Interactive.Rendering.PocketViewTags; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public class PocketViewTests + { + [Fact] + public void Outputs_nested_html_elements_with_attributes() + { + string output = html( + body( + ul[@class: "sold-out", id: "cat-products"]( + li(a("Scratching post")), + li(a[@class: "selected"]("Cat Fountain"))))) + .ToString(); + + output + .Should() + .Be( + @""); + } + + [Fact] + public void Comma_delimited_arguments_output_sibling_elements() + { + string output = ul( + li("one"), + li("two"), + li("three")).ToString(); + + output + .Should() + .Be("
  • one
  • two
  • three
"); + } + + [Fact] + public void When_a_sibling_element_is_IEnumerable_it_is_enumerated() + { + string output = ul(Enumerable.Range(1, 3).Select(i => li(i))).ToString(); + + output.Should().Be("
  • 1
  • 2
  • 3
"); + } + + [Fact] + public void Comma_delimited_arguments_of_different_types_are_encoded_properly() + { + var joe = new { foo = 1, bar = "two" }; + + string output = script[@type: "text/javascript", id: "the-script"]( + "var x = ", joe.SerializeToJson()).ToString(); + + output + .Should() + .Be(""); + } + + [Fact] + public void A_simple_tag() + { + string output = div("foo").ToString(); + + output.Should().Be("
foo
"); + } + + [Fact] + public void A_tag_with_one_attribute_passed_as_a_hash() + { + string output = div[@class: "bar"]("foo").ToString(); + + output.Should().Be("
foo
"); + } + + [Fact] + public void A_tag_with_two_attributes_passed_as_a_hash() + { + string output = div[@class: "bar", width: "100px"]("foo").ToString(); + + output + .Should().Be("
foo
"); + } + + [Fact] + public void A_nested_tag() + { + string output = div(a("foo")).ToString(); + + output.Should().Be(""); + } + + [Fact] + public void If_a_string_is_passed_as_tag_contents_it_is_encoded() + { + string output = div("this & that").ToString(); + + output.Should().Be("
this & that
"); + } + + [Fact] + public void If_an_IHtmlContent_is_passed_as_tag_contents_it_is_not_reencoded() + { + string output = div("this & that".ToHtmlContent()).ToString(); + + output.Should().Be("
this & that
"); + } + + [Fact] + public void If_a_string_is_passed_as_attribute_contents_it_is_encoded() + { + string output = div[data: "this & that"]("hi").ToString(); + + output.Should().Be("
hi
"); + } + + [Fact] + public void If_an_IHtmlContent_is_passed_as_attribute_contents_it_is_not_reencoded() + { + var htmlContent = "this & that".ToHtmlContent(); + + string output = div[data: htmlContent].ToString(); + + output.Should().Be("
"); + } + + [Fact] + public void A_transform_can_be_used_to_create_an_alias() + { + _.foolink = PocketView.Transform( + (tag, model) => + { + tag.TagName = "a"; + tag.HtmlAttributes.Add("href", "http://foo.biz"); + }); + + string output = _.foolink("click here!").ToString(); + + output + .Should() + .Be("click here!"); + } + + [Fact] + public void A_transform_can_be_used_to_create_an_expandable_tag() + { + _.textbox = PocketView.Transform( + (tag, model) => + { + tag.TagName = "div"; + tag.Content = w => + { + w.Write(label[@for: model.name](model.name)); + w.Write(input[value: model.value, type: "text", name: model.name]); + }; + }); + + string output = _.textbox(name: "FirstName", value: "Bob").ToString(); + + output + .Should().Be("
"); + } + + [Fact] + public void A_transform_can_be_used_to_add_attributes() + { + dynamic _ = new PocketView(); + + _.div = PocketView.Transform( + (tag, model) => tag.HtmlAttributes.Class("foo")); + + string output = _.div("hi").ToString(); + + output.Should().Be("
hi
"); + } + + [Fact] + public void A_transform_merges_differently_named_attributes() + { + dynamic _ = new PocketView(); + + _.div = PocketView.Transform( + (tag, model) => tag.HtmlAttributes.Class("foo")); + + string output = _.div[style: "color:red"]("hi").ToString(); + + output + .Should().Be("
hi
"); + } + + [Fact] + public void A_transform_overwrites_like_named_attributes() + { + dynamic _ = new PocketView(); + + _.div = PocketView.Transform( + (tag, model) => tag.HtmlAttributes["style"] = "color:yellow"); + + string output = _.div[style: "color:red"]("hi").ToString(); + + output + .Should() + .Be("
hi
"); + } + + [Fact] + public void HtmlAttributes_can_be_used_for_attributes() + { + var attr = new HtmlAttributes(); + + string output = section[attr.Class("info")]("some content").ToString(); + + output.Should() + .Be("
some content
"); + } + + [Fact] + public void Input_elements_are_rendered_as_self_closing_when_no_content_is_passed_to_method_invocation() + { + string output = input[type: "button", value: "go"]().ToString(); + + output.Should().Be(""); + } + + [Fact] + public void Input_elements_are_rendered_as_self_closing_when_calling_using_property_accessor() + { + string output = input[type: "button", value: "go"].ToString(); + + output.Should().Be(""); + } + + [Fact] + public void HtmlAttributes_mixed_with_indexer_args_can_be_used_for_attributes() + { + // TODO-JOSEQU: (HtmlAttributes_mixed_with_indexer_args_can_be_used_for_attributes) + var attr = new HtmlAttributes(); + + string output = section[attr.Class("info"), style: "color:red"]("some content").ToString(); + + output.Should().Be("
some content
"); + } + + [Fact] + public void Underscores_in_attribute_names_are_replaced_with_hyphens() + { + string output = input[data_bind: "some_element"]().ToString(); + + output.Should().Be(""); + } + + [Fact] + public void Method_and_property_calls_return_the_same_result_when_no_attributes_are_present() + { + string property = _.foo.ToString(); + var method = _.foo().ToString(); + + property.Should().Be(method); + } + + [Fact] + public void Method_and_property_calls_return_the_same_result_when_attributes_are_present() + { + string property = _.foo[bar: "baz"].ToString(); + string method = _.foo[bar: "baz"]().ToString(); + + property.Should().Be(method); + } + + [Fact] + public void br_method_invocation_writes_a_self_closing_tag() + { + string output = br().ToString(); + + output.Should().Be("
"); + } + + [Fact] + public void br_property_invocation_writes_a_self_closing_tag() + { + string output = br.ToString(); + + output.Should().Be("
"); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs new file mode 100644 index 000000000..ec9fe1a37 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using FluentAssertions; +using Xunit; + +using static Microsoft.DotNet.Interactive.Rendering.PocketViewTags; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public class PocketViewWithFormatterTests + { + public PocketViewWithFormatterTests() + { + Formatter.ResetToDefault(); + } + + [Fact] + public void Embedded_objects_are_formatted_using_custom_formatter() + { + var date = DateTime.Parse("1/1/2019 12:30pm"); + + Formatter.Register(_ => "hello"); + + string output = div(date).ToString(); + + output.Should().Be("
hello
"); + } + + [Fact] + public void Nested_registered_views_are_not_reencoded() + { + var widget = new Widget + { + Name = "Thingy", + Parts = new List + { + new Part { PartNumber = "ONE" }, + new Part { PartNumber = "TWO" } + } + }; + + Formatter.RegisterView(part => span(part.PartNumber)); + + Formatter.RegisterView(w => + table( + tr( + th(nameof(Widget.Name))), + tr( + td(w.Name), + td(w.Parts) + ))); + + string output = div(widget).ToString(); + + output.Should() + .Be("
Name
ThingyONETWO
"); + } + + [Fact] + public void Nested_registered_string_formatters_are_HTML_encoded() + { + var widget = new Widget + { + Name = "Thingy", + Parts = new List + { + new Part { PartNumber = "ONE" }, + new Part { PartNumber = "TWO" } + } + }; + + Formatter.Register(part => $"<{part.PartNumber}>"); + + Formatter.RegisterView(w => div(w.Parts)); + + string output = div(widget).ToString(); + + output.Should().Be("
<ONE><TWO>
"); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/StringExtensions.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/StringExtensions.cs new file mode 100644 index 000000000..d1319b84b --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/StringExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public static class StringExtensions + { + /// + /// Attempts to homogenize an HTML string by reducing whitespace for easier comparison. + /// + /// The string to be crunched. + public static string Crunch(this string s) + { + var result = Regex.Replace(s, "[\n\r]*", ""); // remove newlines + result = Regex.Replace(result, "\\s*<", "<"); // remove whitespace preceding a tag + result = Regex.Replace(result, ">\\s*", ">"); // remove whitespace following a tag + return result; + } + + /// + /// Attempts to homogenize an HTML string by reducing whitespace for easier comparison. + /// + /// The string to be crunched. + public static string Crunch(this IHtmlContent s) + { + return s.ToString().Crunch(); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/TestClasses.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/TestClasses.cs new file mode 100644 index 000000000..5ee03310e --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/TestClasses.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Interactive.Rendering.Tests +{ + public class Widget + { + public Widget() + { + Name = "Default"; + } + + public string Name { get; set; } + + public List Parts { get; set; } + } + + public class InheritedWidget : Widget + { + } + + public class Part + { + public string PartNumber { get; set; } + public Widget Widget { get; set; } + } + + public struct SomeStruct + { + public DateTime DateField; + public DateTime DateProperty { get; set; } + } + + public class SomePropertyThrows + { + public string Fine => "Fine"; + + public string NotOk => throw new Exception("not ok"); + + public string Ok => "ok"; + + public string PerfectlyFine => "PerfectlyFine"; + } + + public struct EntityId + { + public EntityId(string typeName, string id) : this() + { + TypeName = typeName; + Id = id; + } + + public string TypeName { get; } + public string Id { get; } + } + + public class Node + { + private string _id; + + public string Id + { + get => _id; + set => _id = value; + } + + public IEnumerable Nodes { get; set; } + + public Node[] NodesArray { get; set; } + + internal string InternalId => Id; + } + + public class SomethingWithLotsOfProperties + { + public DateTime DateProperty { get; set; } + public string StringProperty { get; set; } + public int IntProperty { get; set; } + public bool BoolProperty { get; set; } + public Uri UriProperty { get; set; } + } + + public class SomethingAWithStaticProperty + { + public static string StaticProperty { get; set; } + + public static string StaticField; + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/DictionaryExtensions.cs b/Microsoft.DotNet.Interactive.Rendering/DictionaryExtensions.cs new file mode 100644 index 000000000..6b5124094 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/DictionaryExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + internal static class DictionaryExtensions + { + public static void MergeWith( + this IDictionary target, + IDictionary source, + bool replace = false) + { + foreach (var pair in source) + { + if (replace || !target.ContainsKey(pair.Key)) + { + target[pair.Key] = pair.Value; + } + } + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Formatter.cs b/Microsoft.DotNet.Interactive.Rendering/Formatter.cs new file mode 100644 index 000000000..770911344 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/Formatter.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Dynamic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Linq.Expressions; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Provides methods for formatting objects into log strings. + /// + public static class Formatter + { + private static Func _autoGenerateForType = t => false; + private static int _defaultListExpansionLimit; + private static int _recursionLimit; + internal static readonly RecursionCounter RecursionCounter = new RecursionCounter(); + + private static readonly ConcurrentDictionary> genericFormatters = new ConcurrentDictionary>(); + + /// + /// Initializes the class. + /// + static Formatter() + { + ResetToDefault(); + } + + /// + /// A factory function called to get a TextWriter for writing out log-formatted objects. + /// + public static Func CreateWriter = () => new StringWriter(CultureInfo.InvariantCulture); + + internal static IDisplayTextFormatter TextFormatter = new SingleLineTextFormatter(); + + /// + /// Gets or sets the limit to the number of items that will be written out in detail from an IEnumerable sequence. + /// + /// + /// The list expansion limit. + /// + public static int ListExpansionLimit + { + get => _defaultListExpansionLimit; + set + { + if (value < 0) + { + throw new ArgumentException($"{nameof(ListExpansionLimit)} must be at least 0."); + } + + _defaultListExpansionLimit = value; + } + } + + /// + /// Gets or sets the string that will be written out for null items. + /// + /// + /// The null string. + /// + public static string NullString; + + /// + /// Gets or sets the limit to how many levels the formatter will recurse into an object graph. + /// + /// + /// The recursion limit. + /// + public static int RecursionLimit + { + get => _recursionLimit; + set + { + if (value < 0) + { + throw new ArgumentException($"{nameof(RecursionLimit)} must be at least 0."); + } + + _recursionLimit = value; + } + } + + internal static event EventHandler Clearing; + + /// + /// Resets all formatters and formatter settings to their default values. + /// + public static void ResetToDefault() + { + Clearing?.Invoke(null, EventArgs.Empty); + + AutoGenerateForType = t => false; + ListExpansionLimit = 10; + RecursionLimit = 6; + NullString = ""; + + RegisterDefaults(); + TypesWithHtmlViewsRegistered.Clear(); + } + + /// + /// Gets or sets a delegate that is checked when a type is being formatted that not previously been formatted and has no custom formatting rules set. If this delegate returns true, then is called for that type. + /// + /// + /// The type being formatted. + /// + /// value + public static Func AutoGenerateForType + { + get => _autoGenerateForType; + set => _autoGenerateForType = value ?? throw new ArgumentNullException(nameof(value)); + } + + public static string ToDisplayString(this object obj) + { + var writer = CreateWriter(); + FormatTo(obj, writer); + return writer.ToString(); + } + + /// + /// Writes a formatted representation of the object to the specified writer. + /// + /// The type of the object being written. + /// The object to write. + /// The writer. + public static void FormatTo(this T obj, TextWriter writer) + { + if (obj != null) + { + var actualType = obj.GetType(); + + if (typeof(T) != actualType) + { + // in some cases the generic parameter is Object but the object is of a more specific type, in which case get or add a cached accessor to the more specific Formatter.Format method + var genericFormatter = + genericFormatters.GetOrAdd(actualType, + GetGenericFormatterMethod); + genericFormatter(obj, writer); + return; + } + } + + Formatter.FormatTo(obj, writer); + } + + internal static Action GetGenericFormatterMethod(this Type type) + { + var methodInfo = typeof(Formatter<>) + .MakeGenericType(type) + .GetMethod(nameof(Formatter.FormatTo), new[] { type, typeof(TextWriter) }); + + var targetParam = Expression.Parameter(typeof(object), "target"); + var writerParam = Expression.Parameter(typeof(TextWriter), "target"); + + var methodCallExpr = Expression.Call(null, methodInfo, + Expression.Convert(targetParam, type), + writerParam); + + return Expression.Lambda>(methodCallExpr, targetParam, writerParam).Compile(); + } + + // TODO: (Formatter) make Join methods public and expose an override for iteration limit + + internal static void Join( + IEnumerable list, + TextWriter writer, + int? listExpansionLimit = null) => + Join(list.Cast(), writer, listExpansionLimit); + + internal static void Join( + IEnumerable list, + TextWriter writer, + int? listExpansionLimit = null) + { + if (list == null) + { + writer.Write(NullString); + return; + } + + var i = 0; + + TextFormatter.WriteStartSequence(writer); + + listExpansionLimit ??= Formatter.ListExpansionLimit; + + using (var enumerator = list.GetEnumerator()) + { + while (enumerator.MoveNext()) + { + if (i < listExpansionLimit) + { + // write out another item in the list + if (i > 0) + { + TextFormatter.WriteSequenceDelimiter(writer); + } + + i++; + + TextFormatter.WriteStartSequenceItem(writer); + + enumerator.Current.FormatTo(writer); + } + else + { + // write out just a count of the remaining items in the list + var difference = list.Count() - i; + if (difference > 0) + { + writer.Write(" ... ("); + writer.Write(difference); + writer.Write(" more)"); + } + + break; + } + } + } + + TextFormatter.WriteEndSequence(writer); + } + + /// + /// Registers a formatter to be used when formatting instances of a specified type. + /// + public static void Register(Type type, Action formatter) + { + var genericRegisterMethod = typeof(Formatter<>) + .MakeGenericType(type) + .GetMethod("Register", new[] { typeof(Action<,>).MakeGenericType(type, typeof(TextWriter)) }); + + genericRegisterMethod.Invoke(null, new object[] { formatter }); + } + + /// + /// Registers a formatter to be used when formatting instances of a specified type. + /// + public static void RegisterForAllMembers(Type type, bool includeInternals = false) + { + var genericRegisterMethod = typeof(Formatter<>) + .MakeGenericType(type) + .GetMethod(nameof(RegisterForAllMembers)); + + genericRegisterMethod.Invoke(null, new object[] { includeInternals }); + } + + private static void RegisterDefaults() + { + // common primitive types + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + Formatter.Default = (value, writer) => writer.Write(value); + + Formatter.Default = (value, writer) => writer.Write(value.ToString("u")); + Formatter.Default = (value, writer) => writer.Write(value.ToString("u")); + + // common complex types + Formatter>.Default = (pair, writer) => + { + writer.Write(pair.Key); + TextFormatter.WriteNameValueDelimiter(writer); + pair.Value.FormatTo(writer); + }; + + Formatter.Default = (pair, writer) => + { + writer.Write(pair.Key); + TextFormatter.WriteNameValueDelimiter(writer); + pair.Value.FormatTo(writer); + }; + + Formatter.Default = (expando, writer) => + { + TextFormatter.WriteStartObject(writer); + var pairs = expando.ToArray(); + var length = pairs.Length; + for (var i = 0; i < length; i++) + { + var pair = pairs[i]; + writer.Write(pair.Key); + TextFormatter.WriteNameValueDelimiter(writer); + pair.Value.FormatTo(writer); + if (i < length - 1) + { + TextFormatter.WritePropertyDelimiter(writer); + } + } + + TextFormatter.WriteEndObject(writer); + }; + + Formatter.Default = (type, writer) => + { + var typeName = type.Name; + if (typeName.Contains("`") && !type.IsAnonymous()) + { + writer.Write(typeName.Remove(typeName.IndexOf('`'))); + writer.Write("<"); + var genericArguments = type.GetGenericArguments(); + + for (var i = 0; i < genericArguments.Length; i++) + { + Formatter.Default(genericArguments[i], writer); + if (i < genericArguments.Length - 1) + { + writer.Write(","); + } + } + + writer.Write(">"); + } + else + { + writer.Write(typeName); + } + }; + + // an additional formatter is needed since typeof(Type) == System.RuntimeType, which is not public + // ReSharper disable once PossibleMistakenCallToGetType.2 + Register(typeof(Type).GetType(), + (obj, writer) => Formatter.Default((Type) obj, writer)); + + // supply a formatter for String so that it will not be iterated + Formatter.Default = (s, writer) => writer.Write(s); + + // Newtonsoft.Json types -- these implement IEnumerable and their default output is not useful, so use their default ToString + TryRegisterDefault("Newtonsoft.Json.Linq.JArray, Newtonsoft.Json", (obj, writer) => writer.Write(obj)); + TryRegisterDefault("Newtonsoft.Json.Linq.JObject, Newtonsoft.Json", (obj, writer) => writer.Write(obj)); + } + + internal static HashSet TypesWithHtmlViewsRegistered { get; } = new HashSet(); + + private static void TryRegisterDefault(string typeName, Action write) + { + var type = Type.GetType(typeName); + if (type != null) + { + Register(type, write); + } + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Formatter{T}.cs b/Microsoft.DotNet.Interactive.Rendering/Formatter{T}.cs new file mode 100644 index 000000000..918e73983 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/Formatter{T}.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Provides formatting functionality for a specific type. + /// + /// The type for which formatting is provided. + public static class Formatter + { + private static readonly bool isAnonymous = typeof(T).IsAnonymous(); + private static Action Custom; + private static readonly bool isException = typeof(Exception).IsAssignableFrom(typeof(T)); + private static readonly bool writeHeader = !isAnonymous; + private static int? listExpansionLimit; + + /// + /// Initializes the class. + /// + static Formatter() + { + Formatter.Clearing += (o, e) => Custom = null; + } + + /// + /// Gets or sets the default formatter for type . + /// + public static Action Default { get; set; } = WriteDefault; + + /// + /// Generates a formatter action that will write out all properties and fields from instances of type . + /// + /// if set to true include internal and private members. + public static Action GenerateForAllMembers(bool includeInternals = false) => + CreateCustom(typeof(T).GetAllMembers(includeInternals).ToArray()); + + /// + /// Generates a formatter action that will write out all properties and fields from instances of type . + /// + /// Expressions specifying the members to include in formatting. + /// + public static Action GenerateForMembers(params Expression>[] members) => + CreateCustom(typeof(T).GetMembers(members).ToArray()); + + /// + /// Registers a formatter to be used when formatting instances of type . + /// + public static void Register(Action formatter) + { + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + + if (typeof(T) == typeof(Type)) + { + // special treatment is needed since typeof(Type) == System.RuntimeType, which is not public + // ReSharper disable once PossibleMistakenCallToGetType.2 + Formatter.Register(typeof(Type).GetType(), (o, writer) => formatter((T)o, writer)); + } + + Custom = formatter; + } + + /// + /// Registers a formatter to be used when formatting instances of type . + /// + public static void Register(Func formatter) => + Register((obj, writer) => writer.Write(formatter(obj))); + + public static void RegisterView(Func formatter) + { + Register((obj, writer) => + { + var htmlContent = formatter(obj); + + htmlContent.WriteTo(writer, HtmlEncoder.Default); + }); + + Formatter.TypesWithHtmlViewsRegistered.Add(typeof(T)); + } + + /// + /// Registers a formatter to be used when formatting instances of type . + /// + public static void RegisterForAllMembers(bool includeInternals = false) => + Register(GenerateForAllMembers(includeInternals)); + + /// + /// Registers a formatter to be used when formatting instances of type . + /// + public static void RegisterForMembers(params Expression>[] members) + { + if (members == null || !members.Any()) + { + Register(GenerateForAllMembers()); + } + else + { + Register(GenerateForMembers(members)); + } + } + + /// + /// Formats an object and writes it to a writer. + /// + /// The obj. + /// The writer. + public static void FormatTo(T obj, TextWriter writer) + { + if (obj == null) + { + writer.Write(Formatter.NullString); + return; + } + + // find a formatter for the object type, and possibly register one on the fly + using (Formatter.RecursionCounter.Enter()) + { + if (Formatter.RecursionCounter.Depth <= Formatter.RecursionLimit) + { + if (Custom == null) + { + if (isAnonymous || isException) + { + Custom = GenerateForAllMembers(); + } + else if (Default == WriteDefault) + { + Custom = Formatter.AutoGenerateForType(typeof(T)) + ? GenerateForAllMembers() + : (o, w) => Default(o, w); + } + } + (Custom ?? Default)(obj, writer); + } + else + { + Default(obj, writer); + } + } + } + + /// + /// Creates a custom formatter for the specified members. + /// + private static Action CreateCustom(MemberInfo[] forMembers) + { + var accessors = forMembers.GetMemberAccessors(); + + if (isException) + { + // filter out internal values from the Data dictionary, since they're intended to be surfaced in other ways + var dataAccessor = accessors.SingleOrDefault(a => a.MemberName == "Data"); + if (dataAccessor != null) + { + var originalGetData = dataAccessor.GetValue; + dataAccessor.GetValue = e => ((IDictionary)originalGetData(e)) + .Cast() + .ToDictionary(de => de.Key, de => de.Value); + } + + // replace the default stack trace with the full stack trace when present + var stackTraceAccessor = accessors.SingleOrDefault(a => a.MemberName == "StackTrace"); + if (stackTraceAccessor != null) + { + stackTraceAccessor.GetValue = e => + { + var ex = e as Exception; + + return ex.StackTrace; + }; + } + } + + return (target, writer) => + { + Formatter.TextFormatter.WriteStartObject(writer); + + if (writeHeader) + { + Formatter.FormatTo(typeof(T), writer); + Formatter.TextFormatter.WriteEndHeader(writer); + } + + for (var i = 0; i < accessors.Length; i++) + { + try + { + var accessor = accessors[i]; + + if (accessor.Ignore) + { + continue; + } + + var value = accessor.GetValue(target); + + Formatter.TextFormatter.WriteStartProperty(writer); + writer.Write(accessor.MemberName); + Formatter.TextFormatter.WriteNameValueDelimiter(writer); + value.FormatTo(writer); + Formatter.TextFormatter.WriteEndProperty(writer); + + if (i < accessors.Length - 1) + { + Formatter.TextFormatter.WritePropertyDelimiter(writer); + } + } + catch (Exception) + { + } + } + + Formatter.TextFormatter.WriteEndObject(writer); + }; + } + + /// + /// Gets or sets the limit to the number of items that will be written out in detail from an IEnumerable sequence of . + /// + /// The list expansion limit. + public static int ListExpansionLimit + { + get => listExpansionLimit ?? Formatter.ListExpansionLimit; + set => listExpansionLimit = value; + } + + internal static bool IsCustom => + Custom != null || Default != WriteDefault; + + private static void WriteDefault(T obj, TextWriter writer) + { + if (obj is string) + { + writer.Write(obj); + return; + } + + if (obj is IEnumerable enumerable) + { + Formatter.Join(enumerable, writer, listExpansionLimit); + } + else + { + writer.Write(obj.ToString()); + } + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Html.cs b/Microsoft.DotNet.Interactive.Rendering/Html.cs new file mode 100644 index 000000000..12fb1c2dc --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/Html.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using System.Web; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + public static class Html + { + internal static IHtmlContent EnsureHtmlAttributeEncoded(this object source) => + source == null + ? HtmlString.Empty + : source as IHtmlContent ?? source.ToString().HtmlAttributeEncode(); + + public static IHtmlContent HtmlEncode(this string content) => + new HtmlString(HttpUtility.HtmlEncode(content)); + + public static IHtmlContent HtmlAttributeEncode(this string content) => new HtmlString(HttpUtility.HtmlAttributeEncode(content)); + + public static IHtmlContent ToHtmlContent(this string value) => + new HtmlString(value); + + public static JsonString SerializeToJson(this T source) => + new JsonString(JsonSerializer.Serialize(source)); + + public static IHtmlContent JsonEncode(this string source) => + new JsonString(JsonEncodedText.Encode(source).ToString()); + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/HtmlAttributes.cs b/Microsoft.DotNet.Interactive.Rendering/HtmlAttributes.cs new file mode 100644 index 000000000..2bf6a8dc0 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/HtmlAttributes.cs @@ -0,0 +1,397 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// A dynamic object representing HTML attributes. + /// + public class HtmlAttributes : DynamicObject, IDictionary, IHtmlContent + { + private readonly SortedDictionary _attributes = new SortedDictionary(StringComparer.Ordinal); + + /// + /// Initializes a new instance of the class. + /// + public HtmlAttributes() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Key/value pairs to be added to the HtmlAttributes instance. + public HtmlAttributes(IEnumerable> attributes) + { + InitializeFrom(attributes); + } + + private void InitializeFrom(IEnumerable> attributes) + { + foreach (var item in attributes) + { + _attributes.Add(item.Key, item.Value); + } + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// + /// A that can be used to iterate through the collection. + /// + public IEnumerator> GetEnumerator() + { + return _attributes.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An object that can be used to iterate through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + /// + /// The is read-only. + /// + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + /// + /// Adds CSS classes to the attributes. + /// + /// A space-delimited list of CSS classes to be added. + public void AddCssClass(string value) + { + if (TryGetValue("class", out var values)) + { + _attributes["class"] = value + " " + values; + } + else + { + _attributes["class"] = value; + } + } + + /// + /// Removes classes from the attributes. + /// + /// A space-delimited list of CSS classes to be removed. + public void RemoveCssClass(string value) + { + if (TryGetValue("class", out var currentObj)) + { + var currentClasses = currentObj.ToString(); + _attributes["class"] = string.Join(" ", currentClasses.Split(' ').Except(value.Split(' '))); + } + } + + /// + /// Removes all items from the . + /// + /// + /// The is read-only. + /// + public void Clear() + { + _attributes.Clear(); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// + /// true if is found in the ; otherwise, false. + /// + public bool Contains(KeyValuePair item) + { + return _attributes.Contains(item); + } + + /// + /// Copies the elements of the to an System.Array, starting at a particular System.Array index. + /// + /// The one-dimensional System.Array that is the destination of the elements copied from . The System.Array must have zero-based indexing. + /// The zero-based index in array at which copying begins. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((ICollection>) _attributes).CopyTo(array, arrayIndex); + } + + /// + /// Removes the first occurrence of a specific object from the . + /// + /// The object to remove from the . + /// + /// true if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . + /// + /// + /// The is read-only. + /// + public bool Remove(KeyValuePair item) + { + return _attributes.Remove(item.Key); + } + + /// + /// Gets the number of elements contained in the . + /// + /// + /// The number of elements contained in the . + /// + public int Count => _attributes.Count; + + /// + /// Gets a value indicating whether the is read-only. + /// + /// true if the is read-only; otherwise, false. + /// + public bool IsReadOnly => false; + + /// + /// Determines whether the contains an element with the specified key. + /// + /// The key to locate in the . + /// + /// true if the contains an element with the key; otherwise, false. + /// + /// is null. + /// + public bool ContainsKey(string key) => _attributes.ContainsKey(key); + + /// + /// Adds an element with the provided key and value to the . + /// + /// The object to use as the key of the element to add. + /// The object to use as the value of the element to add. + /// is null. + /// + /// + /// An element with the same key already exists in the . + /// + /// + /// The is read-only. + /// + public void Add(string key, object value) => _attributes.Add(key, value); + + /// + /// Removes the element with the specified key from the . + /// + /// The key of the element to remove. + /// + /// true if the element is successfully removed; otherwise, false. This method also returns false if was not found in the original . + /// + /// is null. + /// + /// + /// The is read-only. + /// + public bool Remove(string key) => _attributes.Remove(key); + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get. + /// When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// + /// true if the object that implements contains an element with the specified key; otherwise, false. + /// + /// is null. + /// + public bool TryGetValue(string key, out object value) => + _attributes.TryGetValue(key, out value); + + /// + /// Gets or sets the element with the specified key. + /// + /// + /// The element with the specified key. + /// + /// is null. + /// + /// + /// The property is retrieved and is not found. + /// + /// + /// The property is set and the is read-only. + /// + public object this[string key] + { + get => _attributes[key]; + set => _attributes[key] = value; + } + + /// + /// Gets an containing the keys of the . + /// + /// + /// An containing the keys of the object that implements . + /// + public ICollection Keys => _attributes.Keys; + + /// + /// Gets an containing the values in the . + /// + /// + /// An containing the values in the object that implements . + /// + public ICollection Values => _attributes.Values; + + /// + /// Provides the implementation for operations that get member values. Classes derived from the class can override this method to specify dynamic behavior for operations such as getting a value for a property. + /// + /// Provides information about the object that called the dynamic operation. The binder.Name property provides the name of the member on which the dynamic operation is performed. For example, for the Console.WriteLine(sampleObject.SampleProperty) statement, where sampleObject is an instance of the class derived from the class, binder.Name returns "SampleProperty". The binder.IgnoreCase property specifies whether the member name is case-sensitive. + /// The result of the get operation. For example, if the method is called for a property, you can assign the property value to . + /// + /// true if the operation is successful; otherwise, false. If this method returns false, the run-time binder of the language determines the behavior. (In most cases, a run-time exception is thrown.) + /// + public override bool TryGetMember(GetMemberBinder binder, out object result) => _attributes.TryGetValue(binder.Name, out result); + + /// + /// Returns the enumeration of all dynamic member names. + /// + /// + /// A sequence that contains dynamic member names. + /// + public override IEnumerable GetDynamicMemberNames() => _attributes.Keys; + + /// + /// Provides the implementation for operations that set member values. Classes derived from the class can override this method to specify dynamic behavior for operations such as setting a value for a property. + /// + /// Provides information about the object that called the dynamic operation. The binder.Name property provides the name of the member to which the value is being assigned. For example, for the statement sampleObject.SampleProperty = "Test", where sampleObject is an instance of the class derived from the class, binder.Name returns "SampleProperty". The binder.IgnoreCase property specifies whether the member name is case-sensitive. + /// The value to set to the member. For example, for sampleObject.SampleProperty = "Test", where sampleObject is an instance of the class derived from the class, the is "Test". + /// + /// true if the operation is successful; otherwise, false. If this method returns false, the run-time binder of the language determines the behavior. (In most cases, a language-specific run-time exception is thrown.) + /// + public override bool TrySetMember(SetMemberBinder binder, object value) + { + _attributes[binder.Name] = value; + return true; + } + + public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) + { + switch (binder.Name) + { + case "MergeWith": + var dictionary = + binder + .CallInfo + .ArgumentNames + .Select( + (name, index) => new KeyValuePair(name, args[index])) + .ToDictionary(kvp => kvp.Key, + kvp => kvp.Value); + MergeWith(dictionary); + result = this; + return true; + default: + result = false; + return false; + } + } + + /// + /// Merges specified attributes the with the current instance. + /// + /// The attributes to be merged. + /// if set to true [replace]. + public void MergeWith(IDictionary htmlAttributes, bool replace = false) => + _attributes.MergeWith(htmlAttributes, replace); + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + { + var sb = new StringBuilder(); + var first = true; + foreach (var pair in _attributes) + { + // don't write out empty id attributes + if (string.Equals(pair.Key, "id", StringComparison.Ordinal) && + (pair.Value == null || pair.Value.ToString() == string.Empty)) + { + continue; + } + + if (!first) + { + // spaces between attributes + sb.Append(" "); + } + else + { + first = false; + } + + if (pair.Value is JsonString) + { + // don't re-encode, use ' as delimiter + sb.Append(pair.Key) + .Append("='") + .Append(pair.Value) + .Append("'"); + } + else + { + sb.Append(pair.Key) + .Append("=\"") + .Append(pair.Value.EnsureHtmlAttributeEncoded()) + .Append("\""); + } + } + + return sb.ToString(); + } + + public void WriteTo(TextWriter writer, HtmlEncoder encoder) + { + if (Count > 0) + { + writer.Write(" "); + writer.Write(ToString()); + } + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/HtmlAttributesExtensions.cs b/Microsoft.DotNet.Interactive.Rendering/HtmlAttributesExtensions.cs new file mode 100644 index 000000000..d622ed9d8 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/HtmlAttributesExtensions.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Provides additional functionality for rendering HTML attributes. + /// + public static class HtmlAttributesExtensions + { + /// + /// Sets or removes the "checked" attribute. + /// + /// The attributes to modify. + /// Specifies whether to set the "checked" attribute. If set to false, this will remove the "checked" attribute. + /// + /// The modified instance. + /// + public static HtmlAttributes Checked( + this HtmlAttributes attributes, + bool value = true) + { + if (value) + { + attributes["checked"] = "checked"; + } + else + { + attributes.Remove("checked"); + } + + return attributes; + } + + /// + /// Adds the specified class or classes to the attributes. + /// + /// The attributes to which to add classes. + /// The class or classes to be added. + /// The modified instance. + /// + /// The classes are merged with the existing classes on the instance. + /// + public static HtmlAttributes Class( + this HtmlAttributes attributes, + string classes) + { + attributes.AddCssClass(classes); + return attributes; + } + + /// + /// Adds the specified class or classes to the attributes. + /// + /// The attributes to which to add classes. + /// The class or classes to be added. + /// If set to true, add the class; otherwise, remove it. + /// + /// The modified instance. + /// + /// + /// The classes are merged with the existing classes on the instance. + /// + public static HtmlAttributes Class( + this HtmlAttributes attributes, + string classes, + bool include) + { + if (include) + { + attributes.AddCssClass(classes); + } + else + { + attributes.RemoveCssClass(classes); + } + + return attributes; + } + + public static HtmlAttributes Data(this HtmlAttributes attributes, string key, object value) + { + var actualKey = key.StartsWith("data-") ? key : "data-" + key; + attributes.Add(actualKey, value.SerializeToJson()); + return attributes; + } + + /// + /// Adds a data-* attribute to the tag with the value serialized to JSON. + /// + /// The attributes to which to add the data. + /// The name of the attribute. + /// The value to be serialized to JSON and inserted into the attribute's value. + /// The modified instance. + public static HtmlAttributes Data( + this HtmlAttributes attributes, + string key, + string value) + { + var actualKey = key.StartsWith("data-") ? key : "data-" + key; + attributes.Add(actualKey, value); + return attributes; + } + + /// + /// Adds a data-* attribute to the tag with the value serialized to JSON. + /// + /// The attributes to which to add the data. + /// The name of the attribute. + /// The value to be serialized to JSON and inserted into the attribute's value. + /// The modified instance. + public static HtmlAttributes Data( + this HtmlAttributes attributes, + string key, + IHtmlContent value) + { + var actualKey = key.StartsWith("data-") ? key : "data-" + key; + attributes.Add(actualKey, value); + return attributes; + } + + /// + /// Sets or removes the "disabled" attribute. + /// + /// The attributes to modify. + /// Specifies whether to set the "disabled" attribute. If set to false, this will remove the "disabled" attribute. + /// + /// The modified instance. + /// + public static HtmlAttributes Disabled( + this HtmlAttributes attributes, + bool value = true) + { + if (value) + { + attributes["disabled"] = "disabled"; + } + else + { + attributes.Remove("disabled"); + } + + return attributes; + } + + /// + /// Sets or removes the "selected" attribute. + /// + /// The attributes to modify. + /// Specifies whether to set the "selected" attribute. If set to false, this will remove the "selected" attribute. + /// + /// The modified instance. + /// + public static HtmlAttributes Selected( + this HtmlAttributes attributes, + bool value = true) + { + if (value) + { + attributes["selected"] = "selected"; + } + else + { + attributes.Remove("selected"); + } + + return attributes; + } + + /// + /// Determines whether attributes contain the specified class. + /// + /// The attributes. + /// The class to check for. + /// + /// true if the specified attributes has the specified class; otherwise, false. + /// + public static bool HasClass( + this HtmlAttributes attributes, + string @class) + { + return (attributes["class"] ?? string.Empty) + .ToString() + .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Any(c => c == @class); + } + + /// + /// Adds the specified attribute. + /// + /// The HTML attributes to which to add an attribute. + /// The name. + /// The value. + /// + internal static HtmlAttributes Attr( + this HtmlAttributes htmlAttributes, + string name, + object value) + { + htmlAttributes[name] = value; + return htmlAttributes; + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/IDisplayTextFormatter.cs b/Microsoft.DotNet.Interactive.Rendering/IDisplayTextFormatter.cs new file mode 100644 index 000000000..2f9444b35 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/IDisplayTextFormatter.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.IO; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + internal interface IDisplayTextFormatter + { + void WriteStartProperty(TextWriter writer); + void WriteEndProperty(TextWriter writer); + void WriteStartObject(TextWriter writer); + void WriteEndObject(TextWriter writer); + void WriteStartSequence(TextWriter writer); + void WriteEndSequence(TextWriter writer); + void WriteNameValueDelimiter(TextWriter writer); + void WritePropertyDelimiter(TextWriter writer); + void WriteSequenceDelimiter(TextWriter writer); + void WriteEndHeader(TextWriter writer); + void WriteStartSequenceItem(TextWriter writer); + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/ITag.cs b/Microsoft.DotNet.Interactive.Rendering/ITag.cs new file mode 100644 index 000000000..cb29ca9bb --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/ITag.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + public interface ITag : IHtmlContent + { + /// + /// Gets HTML tag type. + /// + /// The type of the tag. + string TagName { get; } + + /// + /// Gets the HTML attributes to be rendered into the tag. + /// + /// The HTML attributes. + HtmlAttributes HtmlAttributes { get; } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/JsonString.cs b/Microsoft.DotNet.Interactive.Rendering/JsonString.cs new file mode 100644 index 000000000..d73ad3e91 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/JsonString.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + public class JsonString : HtmlString + { + /// + /// Initializes a new instance of the class. + /// + /// The json. + public JsonString(string json) : base(json) + { + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/MemberAccessor{T}.cs b/Microsoft.DotNet.Interactive.Rendering/MemberAccessor{T}.cs new file mode 100644 index 000000000..122d39726 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/MemberAccessor{T}.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + internal class MemberAccessor + { + public MemberAccessor(MemberInfo member) + { + var targetParam = Expression.Parameter(typeof(T), "target"); + + MemberName = member.Name; + + GetValue = (Func)Expression.Lambda( + typeof(Func), + Expression.TypeAs( + Expression.PropertyOrField(targetParam, MemberName), + typeof(object)), + targetParam).Compile(); + } + + public bool Ignore { get; set; } + + public string MemberName { get; } + + public Func GetValue { get; set; } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj b/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj new file mode 100644 index 000000000..c8c73d8e3 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + preview + + + + + + + + + + + + diff --git a/Microsoft.DotNet.Interactive.Rendering/PocketView.cs b/Microsoft.DotNet.Interactive.Rendering/PocketView.cs new file mode 100644 index 000000000..827a6aa22 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/PocketView.cs @@ -0,0 +1,350 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Writes HTML using a C# DSL, bypasing the need for specialized parser and compiler infrastructure such as Razor or WebForms require. + /// + public class PocketView : DynamicObject, ITag + { + private readonly Dictionary _transforms = new Dictionary(); + private readonly Tag _tag; + private TagTransform _transform; + + /// + /// Initializes a new instance of the class. + /// + /// A nested instance. + public PocketView(PocketView nested = null) + { + if (nested != null) + { + _transforms = nested._transforms; + } + else + { + AddDefaultTransforms(); + } + } + + private void AddDefaultTransforms() + { + ((dynamic) this).br = Transform((t, u) => { t.SelfClosing(); }); + ((dynamic) this).input = Transform((t, u) => { t.SelfClosing(); }); + } + + /// + /// Initializes a new instance of the class. + /// + /// Name of the tag. + /// A nested instance. + protected internal PocketView(string tagName, PocketView nested = null) : this(nested) + { + _tag = tagName.Tag(); + } + + /// + /// Writes an element. + /// + public override bool TryGetMember( + GetMemberBinder binder, + out object result) + { + var returnValue = new PocketView(tagName: binder.Name, nested: this); + + if (_transforms.TryGetValue(binder.Name, out var transform)) + { + returnValue._transform = transform; + } + + result = returnValue; + return true; + } + + /// + /// Writes an element. + /// + public override bool TryInvokeMember( + InvokeMemberBinder binder, + object[] args, + out object result) + { + var pocketView = new PocketView(tagName: binder.Name, nested: this); + + pocketView.SetContent(args); + + if (_transforms.TryGetValue(binder.Name, out var transform)) + { + var content = ComposeContent(binder.CallInfo.ArgumentNames, args); + + transform(pocketView._tag, content); + } + + result = pocketView; + return true; + } + + /// + /// Writes tag content + /// + public override bool TryInvoke( + InvokeBinder binder, + object[] args, + out object result) + { + SetContent(args); + + ApplyTransform(binder, args); + + result = this; + return true; + } + + private void ApplyTransform( + InvokeBinder binder, + object[] args) + { + if (_transform != null) + { + var content = ComposeContent( + binder?.CallInfo?.ArgumentNames, + args); + + _transform(_tag, content); + + // null out _transform so that it will only be applied once + _transform = null; + } + } + + public override bool TrySetMember( + SetMemberBinder binder, + object value) + { + if (value is TagTransform alias) + { + _transforms.Add(binder.Name, alias); + + return true; + } + + return false; + } + + /// + /// Writes attributes. + /// + public override bool TryGetIndex( + GetIndexBinder binder, + object[] values, + out object result) + { + var argumentNameIndex = 0; + + for (var i = 0; i < values.Length; i++) + { + var att = values[i]; + + if (att is IDictionary dict) + { + HtmlAttributes.MergeWith(dict); + } + else + { + var key = binder.CallInfo + .ArgumentNames + .ElementAt(argumentNameIndex++) + .Replace("_", "-"); + HtmlAttributes[key] = values[i]; + } + } + + result = this; + return true; + } + + private void SetContent(object[] args) + { + if (args?.Length == 0) + { + return; + } + + _tag.Content = + writer => + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg) + { + case string s: + writer.Write(s.HtmlEncode()); + break; + + case IEnumerable seq: + foreach (var item in seq) + { + switch (item) + { + case string s: + writer.Write(s.HtmlEncode()); + break; + + case IHtmlContent html: + writer.Write(html.ToString()); + break; + + default: + if (Formatter.TypesWithHtmlViewsRegistered.Contains(item.GetType())) + { + item.FormatTo(writer); + } + else + { + var formatted = item.ToDisplayString() + .HtmlEncode(); + + formatted.FormatTo(writer); + } + + break; + } + } + + break; + + default: + arg.FormatTo(writer); + break; + } + } + }; + } + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + { + if (_tag == null) + { + return ""; + } + else + { + ApplyTransform(null, null); + return _tag.ToString(); + } + } + + /// + /// Gets HTML tag type. + /// + /// The type of the tag. + public string TagName + { + get + { + if (_tag == null) + { + return ""; + } + + return _tag.TagName; + } + } + + /// + /// Gets the HTML attributes to be rendered into the tag. + /// + /// The HTML attributes. + public HtmlAttributes HtmlAttributes => _tag.HtmlAttributes; + + /// + /// Renders the tag to the specified . + /// + /// The writer. + public void WriteTo(TextWriter writer, HtmlEncoder encoder) + { + _tag?.WriteTo(writer, encoder); + } + + /// + /// Creates a tag transform. + /// + /// The transform. + /// + /// _.textbox = Underscore.Transform( + /// (tag, model) => + /// { + /// tag.TagName = "div"; + /// tag.Content = w => + /// { + /// w.Write(_.label[@for: model.name](model.name)); + /// w.Write(_.input[value: model.value, type: "text", name: model.name]); + /// }; + /// }); + /// + /// When called like this: + /// + /// _.textbox(name: "FirstName", value: "Bob") + /// + /// This outputs: + /// + /// + ///
+ /// + /// + ///
+ ///
+ ///
+ public static object Transform(Action transform) + { + return new TagTransform(transform); + } + + private delegate void TagTransform(Tag tag, object contents); + + private dynamic ComposeContent( + IReadOnlyCollection argumentNames, + object[] args) + { + if (argumentNames?.Count == 0) + { + if (args?.Length > 0) + { + return args; + } + + return null; + } + + var expando = new ExpandoObject(); + + if (argumentNames != null) + { + expando + .MergeWith( + argumentNames.Zip(args, (name, value) => new { name, value }) + .ToDictionary(p => p.name, p => p.value)); + } + + return expando; + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/PocketViewTags.cs b/Microsoft.DotNet.Interactive.Rendering/PocketViewTags.cs new file mode 100644 index 000000000..3bc1e043c --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/PocketViewTags.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.DotNet.Interactive.Rendering +{ + public static class PocketViewTags + { + public static dynamic _ { get; } = new PocketView(); + public static dynamic a => _.a; + public static dynamic area => _.area; + public static dynamic aside => _.aside; + public static dynamic b => _.b; + public static dynamic body => _.body; + public static dynamic br => _.br; + public static dynamic button => _.button; + public static dynamic caption => _.caption; + public static dynamic center => _.center; + public static dynamic code => _.code; + public static dynamic colgroup => _.colgroup; + public static dynamic dd => _.dd; + public static dynamic details => _.details; + public static dynamic div => _.div; + public static dynamic dl => _.dl; + public static dynamic dt => _.dt; + public static dynamic em => _.em; + public static dynamic figure => _.figure; + public static dynamic font => _.font; + public static dynamic form => _.form; + public static dynamic h1 => _.h1; + public static dynamic h2 => _.h2; + public static dynamic h3 => _.h3; + public static dynamic h4 => _.h4; + public static dynamic h5 => _.h5; + public static dynamic h6 => _.h6; + public static dynamic head => _.head; + public static dynamic header => _.header; + public static dynamic hgroup => _.hgroup; + public static dynamic hr => _.hr; + public static dynamic html => _.html; + public static dynamic i => _.i; + public static dynamic iframe => _.iframe; + public static dynamic img => _.img; + public static dynamic input => _.input; + public static dynamic label => _.label; + public static dynamic li => _.li; + public static dynamic link => _.link; + public static dynamic main => _.main; + public static dynamic menu => _.menu; + public static dynamic menuitem => _.menuitem; + public static dynamic meta => _.meta; + public static dynamic meter => _.meter; + public static dynamic nav => _.nav; + public static dynamic ol => _.ol; + public static dynamic optgroup => _.optgroup; + public static dynamic option => _.option; + public static dynamic p => _.p; + public static dynamic pre => _.pre; + public static dynamic progress => _.progress; + public static dynamic q => _.q; + public static dynamic script => _.script; + public static dynamic section => _.section; + public static dynamic select => _.select; + public static dynamic small => _.small; + public static dynamic source => _.source; + public static dynamic span => _.span; + public static dynamic strike => _.strike; + public static dynamic strong => _.strong; + public static dynamic sub => _.sub; + public static dynamic sup => _.sup; + public static dynamic table => _.table; + public static dynamic tbody => _.tbody; + public static dynamic td => _.td; + public static dynamic textarea => _.textarea; + public static dynamic tfoot => _.tfoot; + public static dynamic th => _.th; + public static dynamic thead => _.thead; + public static dynamic title => _.title; + public static dynamic tr => _.tr; + public static dynamic u => _.u; + public static dynamic ul => _.ul; + public static dynamic video => _.video; + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/RecursionCounter.cs b/Microsoft.DotNet.Interactive.Rendering/RecursionCounter.cs new file mode 100644 index 000000000..301c6294f --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/RecursionCounter.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + [DebuggerStepThrough] + public class RecursionCounter : IDisposable + { + [ThreadStatic] + private static int depth = 0; + + public int Depth => depth; + + public IDisposable Enter() + { + depth += 1; + return this; + } + + public void Dispose() => depth -= 1; + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/SingleLineTextFormatter.cs b/Microsoft.DotNet.Interactive.Rendering/SingleLineTextFormatter.cs new file mode 100644 index 000000000..70e318eeb --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/SingleLineTextFormatter.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.IO; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + internal class SingleLineTextFormatter : IDisplayTextFormatter + { + private const string EndObject = " }"; + private const string EndSequence = " ]"; + private const string ItemSeparator = ", "; + private const string NameValueDelimiter = ": "; + private const string PropertySeparator = ", "; + private const string StartObject = "{ "; + private const string StartSequence = "[ "; + + public void WriteStartProperty(TextWriter writer) + { + } + + public void WriteEndProperty(TextWriter writer) + { + } + + public void WriteStartObject(TextWriter writer) => writer.Write(StartObject); + + public void WriteEndObject(TextWriter writer) => writer.Write(EndObject); + + public void WriteStartSequence(TextWriter writer) => writer.Write(StartSequence); + + public void WriteEndSequence(TextWriter writer) => writer.Write(EndSequence); + + public void WriteNameValueDelimiter(TextWriter writer) => writer.Write(NameValueDelimiter); + + public void WritePropertyDelimiter(TextWriter writer) => writer.Write(PropertySeparator); + + public void WriteSequenceDelimiter(TextWriter writer) => writer.Write(ItemSeparator); + + public void WriteEndHeader(TextWriter writer) => writer.Write(": "); + + public void WriteStartSequenceItem(TextWriter writer) + { + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Tag.cs b/Microsoft.DotNet.Interactive.Rendering/Tag.cs new file mode 100644 index 000000000..4c7ac82c2 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/Tag.cs @@ -0,0 +1,147 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Represents an HTML tag. + /// + public class Tag : ITag + { + private HtmlAttributes _htmlAttributes; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the tag. + public Tag(string tagName) + { + TagName = tagName; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the tag. + /// The text contained by the tag. + public Tag(string tagName, string text) : this(tagName) + { + Content = writer => writer.Write(text); + } + + /// + /// Initializes a new instance of the class. + /// + /// Name of the tag. + /// The content. + public Tag(string tagName, Action content) : this(tagName) + { + Content = content; + } + + public Action Content { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is self closing. + /// + /// + /// true if this instance is self closing; otherwise, false. + /// + public bool IsSelfClosing { get; set; } + + /// + /// Gets HTML tag type. + /// + /// + /// The type of the tag. + /// + public string TagName { get; set; } + + /// + /// Gets the HTML attributes to be rendered into the tag. + /// + /// + /// The HTML attributes. + /// + public HtmlAttributes HtmlAttributes + { + get => _htmlAttributes ??= new HtmlAttributes(); + set => _htmlAttributes = value; + } + + /// + /// Renders the tag to the specified . + /// + /// The writer. + public virtual void WriteTo(TextWriter writer, HtmlEncoder encoder) + { + if (Content == null && IsSelfClosing) + { + WriteSelfClosingTag(writer); + return; + } + + WriteStartTag(writer); + WriteContentsTo(writer); + WriteEndTag(writer); + } + + protected void WriteSelfClosingTag(TextWriter writer) + { + writer.Write('<'); + writer.Write(TagName); + HtmlAttributes.WriteTo(writer, HtmlEncoder.Default); + writer.Write(" />"); + } + + protected void WriteEndTag(TextWriter writer) + { + writer.Write("'); + } + + protected void WriteStartTag(TextWriter writer) + { + writer.Write('<'); + writer.Write(TagName); + HtmlAttributes.WriteTo(writer, HtmlEncoder.Default); + writer.Write('>'); + } + + /// + /// Writes the tag contents (without outer HTML elements) to the specified writer. + /// + /// The writer. + protected virtual void WriteContentsTo(TextWriter writer) + { + Content?.Invoke(writer); + } + + /// + /// Merges the specified attributes into the tag's existing attributes. + /// + /// The HTML attributes to be merged. + /// if set to true replace existing attributes when attributes with the same name have been previously defined; otherwise, ignore. + public void MergeAttributes(IDictionary htmlAttributes, bool replace = false) => + HtmlAttributes.MergeWith(htmlAttributes, replace); + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + { + var writer = new StringWriter(); + WriteTo(writer, HtmlEncoder.Default); + return writer.ToString(); + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/TagExtensions.cs b/Microsoft.DotNet.Interactive.Rendering/TagExtensions.cs new file mode 100644 index 000000000..ba838b80b --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/TagExtensions.cs @@ -0,0 +1,219 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + /// + /// Provides functionality for writing HTML tags. + /// + public static class TagExtensions + { + /// + /// Designates that the tag, when rendered, will be self-closing. + /// + public static TTag SelfClosing(this TTag tag) + where TTag : Tag + { + tag.IsSelfClosing = true; + return tag; + } + + /// + /// Merges the specified attributes into the tag's existing attributes. + /// + public static TTag WithAttributes(this TTag tag, IDictionary htmlAttributes) where TTag : ITag + { + tag.HtmlAttributes.MergeWith(htmlAttributes, true); + return tag; + } + + /// + /// Merges the specified attributes into the tag's existing attributes. + /// + public static TTag WithAttributes(this TTag tag, Action attributes) where TTag : ITag + { + attributes(tag.HtmlAttributes); + return tag; + } + + /// + /// Adds the specified class or classes to the tag. + /// + /// The type of the tag. + /// The tag to which classes are to be added. + /// The class or classes to be added. + /// + /// The modified instance. + /// + /// + /// The classes are merged with the existing classes on the instance. + /// + public static TTag Class(this TTag tag, string classes) where TTag : ITag + { + tag.HtmlAttributes.Class(classes); + return tag; + } + + /// + /// Creates a tag of the type specified by . + /// + public static Tag Tag(this string tagName) + { + return new Tag(tagName); + } + + /// + /// Appends the specified tag to the source tag. + /// + /// The type of the tag. + /// The tag to which to append. + /// The tag to be appended. + /// . + public static TTag Append(this TTag toTag, IHtmlContent content) where TTag : Tag + { + Action writeOriginalContent = toTag.Content; + toTag.Content = writer => + { + writeOriginalContent?.Invoke(writer); + writer.Write(content); + }; + return toTag; + } + + /// + /// Appends the specified tags to the source tag. + /// + /// The type of . + /// To tag to which other tags will be appended. + /// The tags to append. + /// . + public static TTag Append(this TTag toTag, params IHtmlContent[] contents) where TTag : Tag + { + Action writeOriginalContent = toTag.Content; + toTag.Content = writer => + { + writeOriginalContent?.Invoke(writer); + + for (int i = 0; i < contents.Length; i++) + { + writer.Write(contents[i]); + } + }; + return toTag; + } + + /// + /// Appends a tag to the source tag. + /// + /// The type of the source tag. + /// The tag to be appended. + /// The tag to which to append . + /// . + public static TTag AppendTo(this TTag appendTag, Tag toTag) where TTag : ITag + { + toTag.Append(appendTag); + return appendTag; + } + + + /// + /// Specifies the contents of a tag. + /// + /// The type of the tag. + /// The tag name. + /// The text which the tag should contain. + /// The same tag instance, with the contents set to the specified text. + public static TTag Containing(this TTag tag, string text) where TTag : Tag + { + return tag.Containing(text.HtmlEncode()); + } + + /// + /// Specifies the contents of a tag. + /// + /// The type of the tag. + /// The tag name. + /// The content of the tag. + /// The same tag instance, with the contents set to the specified text. + public static TTag Containing(this TTag tag, IHtmlContent content) where TTag : Tag + { + tag.Content = w => w.Write(content.ToString()); + return tag; + } + + internal static TTag Containing(this TTag tag, params ITag[] tags) where TTag : Tag + { + return tag.Containing((IEnumerable) tags); + } + + internal static TTag Containing(this TTag tag, IEnumerable tags) where TTag : Tag + { + tag.Content = w => + { + foreach (var childTag in tags) + { + childTag.WriteTo(w, HtmlEncoder.Default); + } + }; + return tag; + } + + internal static TTag Containing(this TTag tag, Action content) where TTag : Tag + { + tag.Content = content; + return tag; + } + + + /// + /// Prepends the specified tags to the source tag. + /// + /// The type of . + /// To tag to which other tags will be prepended. + /// The tags to prepend. + /// . + public static TTag Prepend(this TTag toTag, IHtmlContent content) where TTag : Tag + { + Action writeOriginalContent = toTag.Content; + toTag.Content = writer => + { + writer.Write(content); + writeOriginalContent?.Invoke(writer); + }; + return toTag; + } + + /// + /// Prepends a tag to the source tag. + /// + /// The type of the source tag. + /// The tag to be prepended. + /// The tag to which to prepend . + /// . + public static TTag PrependTo(this TTag prependTag, Tag toTag) where TTag : ITag + { + toTag.Prepend(prependTag); + return prependTag; + } + + /// + /// Wraps a tag's content in the specified tag. + /// + /// The type of the tag. + /// The tag. + /// The wrapping tag. + /// + public static TTag WrapInner(this TTag tag, Tag wrappingTag) where TTag : Tag + { + wrappingTag.Content = tag.Content; + tag.Content = writer => wrappingTag.WriteTo(writer, HtmlEncoder.Default); + return tag; + } + } +} \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/TypeExtensions.cs b/Microsoft.DotNet.Interactive.Rendering/TypeExtensions.cs new file mode 100644 index 000000000..d09c077e2 --- /dev/null +++ b/Microsoft.DotNet.Interactive.Rendering/TypeExtensions.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.Interactive.Rendering +{ + internal static class TypeExtensions + { + public static string MemberName(this Expression> expression) + { + if (expression == null) + { + throw new ArgumentNullException(nameof(expression)); + } + + if (expression.Body is MemberExpression memberExpression) + { + return memberExpression.Member.Name; + } + + // when the return type of the expression is a value type, it contains a call to Convert, resulting in boxing, so we get a UnaryExpression instead + if (expression.Body is UnaryExpression unaryExpression) + { + memberExpression = unaryExpression.Operand as MemberExpression; + if (memberExpression != null) + { + return memberExpression.Member.Name; + } + } + + throw new ArgumentException($"Expression {expression} does not specify a member."); + } + + public static IEnumerable GetMembers( + this Type type, + params Expression>[] forProperties) + { + var allMembers = typeof(T).GetAllMembers(true).ToArray(); + + if (forProperties == null || !forProperties.Any()) + { + return allMembers; + } + + return + forProperties + .Select(p => + { + var memberName = p.MemberName(); + return allMembers.Single(m => m.Name == memberName); + }); + } + + public static MemberAccessor[] GetMemberAccessors(this MemberInfo[] forMembers) => + forMembers + .Select(m => new MemberAccessor(m)) + .ToArray(); + + public static IEnumerable GetAllMembers(this Type type, bool includeInternals = false) + { + var bindingFlags = includeInternals + ? BindingFlags.Instance | BindingFlags.GetProperty | BindingFlags.GetField | BindingFlags.Public | BindingFlags.NonPublic + : BindingFlags.Instance | BindingFlags.GetProperty | BindingFlags.GetField | BindingFlags.Public; + + return type.GetMembers(bindingFlags) + .Where(m => !m.Name.Contains("<") && !m.Name.Contains("k__BackingField")) + .Where(m => m.MemberType == MemberTypes.Property || m.MemberType == MemberTypes.Field) + .Where(m => m.MemberType != MemberTypes.Property || + ((PropertyInfo) m).CanRead && !((PropertyInfo) m).GetIndexParameters().Any()) + .ToArray(); + } + + public static bool IsAnonymous(this Type type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + return Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute), false) && + type.IsGenericType && type.Name.Contains("AnonymousType") && + (type.Name.StartsWith("<>") || type.Name.StartsWith("VB$")) && + (type.Attributes & TypeAttributes.NotPublic) == TypeAttributes.NotPublic; + } + } +} \ No newline at end of file From 04081ca942516705657ac84e204726bf38dacc42 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Thu, 18 Jul 2019 08:44:32 -0700 Subject: [PATCH 3/3] mime types and Jupyter integration of new rendering API --- .../HtmlAttributesTests.cs | 2 +- .../PocketViewWithFormatterTests.cs | 12 +- .../Formatter.cs | 22 ++- .../Formatter{T}.cs | 2 +- .../Html.cs | 6 +- ...rosoft.DotNet.Interactive.Rendering.csproj | 2 +- .../PocketView.cs | 2 +- .../ExecuteRequestHandlerTests.cs | 1 - .../RenderingTests.cs | 183 ----------------- .../ExecuteRequestHandler.cs | 24 +-- .../Rendering/DefaultRenderer.cs | 58 ------ .../Rendering/DictionaryRenderer.cs | 35 ---- .../Rendering/HtmlRendering.cs | 18 -- .../Rendering/ListRenderer.cs | 35 ---- .../Rendering/PlainTextRendering.cs | 18 -- .../Rendering/RendererUtilities.cs | 184 ------------------ .../Rendering/RenderingEngineExtensions.cs | 18 -- .../Rendering/SequenceRenderer.cs | 37 ---- WorkspaceServer/Kernel/CSharpRepl.cs | 28 ++- WorkspaceServer/Kernel/FormattedValue.cs | 18 ++ WorkspaceServer/Kernel/IRenderer.cs | 10 - WorkspaceServer/Kernel/RenderingEngine.cs | 95 --------- WorkspaceServer/Kernel/ValueProduced.cs | 12 +- WorkspaceServer/WorkspaceServer.csproj | 1 + 24 files changed, 92 insertions(+), 731 deletions(-) delete mode 100644 Microsoft.DotNet.Try.Jupyter.Tests/RenderingTests.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/DefaultRenderer.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/DictionaryRenderer.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/HtmlRendering.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/ListRenderer.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/PlainTextRendering.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/RendererUtilities.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/RenderingEngineExtensions.cs delete mode 100644 Microsoft.DotNet.Try.Jupyter/Rendering/SequenceRenderer.cs create mode 100644 WorkspaceServer/Kernel/FormattedValue.cs delete mode 100644 WorkspaceServer/Kernel/IRenderer.cs delete mode 100644 WorkspaceServer/Kernel/RenderingEngine.cs diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs index ffdc4c922..7ee9df093 100644 --- a/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/HtmlAttributesTests.cs @@ -294,7 +294,7 @@ public void Empty_id_attributes_are_not_rendered() [Fact] public void Attributes_containing_JSON_values_are_not_reencoded() { - const string expected = @""; + const string expected = @""; var part = new diff --git a/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs index ec9fe1a37..768b72c61 100644 --- a/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs +++ b/Microsoft.DotNet.Interactive.Rendering.Tests/PocketViewWithFormatterTests.cs @@ -3,10 +3,8 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using FluentAssertions; using Xunit; - using static Microsoft.DotNet.Interactive.Rendering.PocketViewTags; namespace Microsoft.DotNet.Interactive.Rendering.Tests @@ -81,5 +79,15 @@ public void Nested_registered_string_formatters_are_HTML_encoded() output.Should().Be("
<ONE><TWO>
"); } + + [Fact] + public void When_a_view_is_registered_then_ToDisplayString_returns_the_HTML() + { + Formatter.RegisterView(w => div("hello")); + + DateTime.Now.ToDisplayString() + .Should() + .Be("
hello
"); + } } } \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Formatter.cs b/Microsoft.DotNet.Interactive.Rendering/Formatter.cs index 770911344..b26f6e5ff 100644 --- a/Microsoft.DotNet.Interactive.Rendering/Formatter.cs +++ b/Microsoft.DotNet.Interactive.Rendering/Formatter.cs @@ -23,7 +23,8 @@ public static class Formatter private static int _recursionLimit; internal static readonly RecursionCounter RecursionCounter = new RecursionCounter(); - private static readonly ConcurrentDictionary> genericFormatters = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> _genericFormatters = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary _mimeTypesByType = new ConcurrentDictionary(); /// /// Initializes the class. @@ -103,7 +104,7 @@ public static void ResetToDefault() NullString = ""; RegisterDefaults(); - TypesWithHtmlViewsRegistered.Clear(); + _mimeTypesByType.Clear(); } /// @@ -118,7 +119,7 @@ public static Func AutoGenerateForType get => _autoGenerateForType; set => _autoGenerateForType = value ?? throw new ArgumentNullException(nameof(value)); } - + public static string ToDisplayString(this object obj) { var writer = CreateWriter(); @@ -142,7 +143,7 @@ public static void FormatTo(this T obj, TextWriter writer) { // in some cases the generic parameter is Object but the object is of a more specific type, in which case get or add a cached accessor to the more specific Formatter.Format method var genericFormatter = - genericFormatters.GetOrAdd(actualType, + _genericFormatters.GetOrAdd(actualType, GetGenericFormatterMethod); genericFormatter(obj, writer); return; @@ -344,8 +345,6 @@ private static void RegisterDefaults() TryRegisterDefault("Newtonsoft.Json.Linq.JObject, Newtonsoft.Json", (obj, writer) => writer.Write(obj)); } - internal static HashSet TypesWithHtmlViewsRegistered { get; } = new HashSet(); - private static void TryRegisterDefault(string typeName, Action write) { var type = Type.GetType(typeName); @@ -354,5 +353,16 @@ private static void TryRegisterDefault(string typeName, Action formatter) htmlContent.WriteTo(writer, HtmlEncoder.Default); }); - Formatter.TypesWithHtmlViewsRegistered.Add(typeof(T)); + Formatter.SetMimeType(typeof(T), "text/html"); } /// diff --git a/Microsoft.DotNet.Interactive.Rendering/Html.cs b/Microsoft.DotNet.Interactive.Rendering/Html.cs index 12fb1c2dc..14fbddbc3 100644 --- a/Microsoft.DotNet.Interactive.Rendering/Html.cs +++ b/Microsoft.DotNet.Interactive.Rendering/Html.cs @@ -1,9 +1,9 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Text.Json; using System.Web; using Microsoft.AspNetCore.Html; +using Newtonsoft.Json; namespace Microsoft.DotNet.Interactive.Rendering { @@ -23,9 +23,9 @@ public static IHtmlContent ToHtmlContent(this string value) => new HtmlString(value); public static JsonString SerializeToJson(this T source) => - new JsonString(JsonSerializer.Serialize(source)); + new JsonString(JsonConvert.SerializeObject(source)); public static IHtmlContent JsonEncode(this string source) => - new JsonString(JsonEncodedText.Encode(source).ToString()); + new JsonString(JsonConvert.ToString(source)); } } \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj b/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj index c8c73d8e3..41e03b444 100644 --- a/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj +++ b/Microsoft.DotNet.Interactive.Rendering/Microsoft.DotNet.Interactive.Rendering.csproj @@ -11,7 +11,7 @@ - + diff --git a/Microsoft.DotNet.Interactive.Rendering/PocketView.cs b/Microsoft.DotNet.Interactive.Rendering/PocketView.cs index 827a6aa22..46240008e 100644 --- a/Microsoft.DotNet.Interactive.Rendering/PocketView.cs +++ b/Microsoft.DotNet.Interactive.Rendering/PocketView.cs @@ -207,7 +207,7 @@ private void SetContent(object[] args) break; default: - if (Formatter.TypesWithHtmlViewsRegistered.Contains(item.GetType())) + if (Formatter.MimeTypeFor(item.GetType()) != null) { item.FormatTo(writer); } diff --git a/Microsoft.DotNet.Try.Jupyter.Tests/ExecuteRequestHandlerTests.cs b/Microsoft.DotNet.Try.Jupyter.Tests/ExecuteRequestHandlerTests.cs index 5151a163b..c124a252b 100644 --- a/Microsoft.DotNet.Try.Jupyter.Tests/ExecuteRequestHandlerTests.cs +++ b/Microsoft.DotNet.Try.Jupyter.Tests/ExecuteRequestHandlerTests.cs @@ -79,7 +79,6 @@ public async Task sends_executeReply_with_error_message_on_codeSubmissionEvaluat _ioRecordingSocket.DecodedMessages .Should().Contain(message => message.Contains(MessageTypeValues.Stream)); - } [Fact] diff --git a/Microsoft.DotNet.Try.Jupyter.Tests/RenderingTests.cs b/Microsoft.DotNet.Try.Jupyter.Tests/RenderingTests.cs deleted file mode 100644 index 98a6cac2b..000000000 --- a/Microsoft.DotNet.Try.Jupyter.Tests/RenderingTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using HtmlAgilityPack; -using Microsoft.DotNet.Try.Jupyter.Rendering; -using WorkspaceServer.Kernel; -using Xunit; - -namespace Microsoft.DotNet.Try.Jupyter.Tests -{ - public class RenderingTests - { - private struct PriorityElement - { - public string Url; - public int Priority; - } - private readonly RenderingEngine _engine; - - public RenderingTests() - { - _engine = new RenderingEngine(new DefaultRenderer(), new PlainTextRendering("")); - _engine.RegisterRenderer(typeof(string), new DefaultRenderer()); - _engine.RegisterRenderer(typeof(IDictionary), new DictionaryRenderer()); - _engine.RegisterRenderer(typeof(IList), new ListRenderer()); - _engine.RegisterRenderer(typeof(IEnumerable), new SequenceRenderer()); - } - - [Fact] - public void renders_null() - { - var rendering = _engine.Render(null); - rendering.MimeType.Should().Be("text/plain"); - rendering.Content.Should().Be(""); - } - - [Fact] - public void objects_are_rendered_as_table() - { - var source = new - { - Name = "Test object", - Counter = 10 - }; - - var rendering = _engine.Render(source); - rendering.MimeType.Should().Be("text/html"); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - table.Should().NotBeNull(); - table.SelectNodes("//td") - .Select(n => n.InnerText) - .Should() - .BeEquivalentTo("Name", source.Name, "Counter", "10"); - - } - - [Fact] - public void structs_are_rendered_as_table() - { - var source = new PriorityElement { Url = "Test struct", Priority = 112 }; - - var rendering = _engine.Render(source); - rendering.MimeType.Should().Be("text/html"); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - table.Should().NotBeNull(); - table.SelectNodes("//td") - .Select(n => n.InnerText) - .Should() - .BeEquivalentTo("Url", source.Url, "Priority", "112"); - } - - [Fact] - public void collections_are_rendered_as_lists() - { - var source = Enumerable.Range(1, 3).Select(i => i + 2); - - var rendering = _engine.Render(source); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - - table.Should().NotBeNull(); - - table.SelectNodes("//th").Should().BeNullOrEmpty(); - - table.SelectNodes("//td") - .Select(td => td.InnerText) - .Should() - .BeEquivalentTo("3", "4", "5"); - } - - [Fact] - public void collections_of_objects_are_rendered_as_table() - { - var source = Enumerable.Range(1, 2).Select(i => new { Url = $"http://site{i}.microsoft.com", Priority = i }); - - var rendering = _engine.Render(source); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - - table.Should().NotBeNull(); - - table.SelectNodes("//th") - .Select(th => th.InnerText) - .Should() - .BeEquivalentTo("Url", "Priority"); - - table.SelectNodes("//td") - .Select(td => td.InnerText) - .Should() - .BeEquivalentTo("http://site1.microsoft.com", "1", "http://site2.microsoft.com", "2"); - } - - [Fact] - public void lists_of_objects_are_rendered_as_table() - { - var source = new[] - { - new { Url = "http://siteA.microsoft.com", Priority = 9}, - new { Url = "http://siteB.microsoft.com", Priority = 12} - }; - - var rendering = _engine.Render(source); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - table.Should().NotBeNull(); - - - table.SelectNodes("//th") - .Select(th => th.InnerText) - .Should() - .BeEquivalentTo("", "Url", "Priority"); - - table.SelectNodes("//td") - .Select(td => td.InnerText) - .Should() - .BeEquivalentTo("0", "http://siteA.microsoft.com", "9", "1", "http://siteB.microsoft.com", "12"); - } - - [Fact] - public void dictionaries_of_objects_are_rendered_as_table() - { - var source = new Dictionary - { - {"low", new PriorityElement{ Url = "http://siteA.microsoft.com", Priority = 9}}, - {"high", new PriorityElement{ Url = "http://siteB.microsoft.com", Priority = 12}} - }; - - var rendering = _engine.Render(source); - - var html = new HtmlDocument(); - html.LoadHtml(rendering.Content.ToString()); - var table = html.DocumentNode.SelectSingleNode("table"); - table.Should().NotBeNull(); - - - table.SelectNodes("//th") - .Select(th => th.InnerText) - .Should() - .BeEquivalentTo("", "Url", "Priority"); - - table.SelectNodes("//td") - .Select(td => td.InnerText) - .Should() - .BeEquivalentTo("low", "http://siteA.microsoft.com", "9", "high", "http://siteB.microsoft.com", "12"); - } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/ExecuteRequestHandler.cs b/Microsoft.DotNet.Try.Jupyter/ExecuteRequestHandler.cs index e79109e37..d9dae88a0 100644 --- a/Microsoft.DotNet.Try.Jupyter/ExecuteRequestHandler.cs +++ b/Microsoft.DotNet.Try.Jupyter/ExecuteRequestHandler.cs @@ -2,29 +2,22 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; -using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.Try.Jupyter.Protocol; -using Microsoft.DotNet.Try.Jupyter.Rendering; using WorkspaceServer.Kernel; namespace Microsoft.DotNet.Try.Jupyter { public class ExecuteRequestHandler : RequestHandlerBase { - private readonly RenderingEngine _renderingEngine; private int _executionCount; public ExecuteRequestHandler(IKernel kernel) :base(kernel) { - _renderingEngine = new RenderingEngine(new DefaultRenderer(), new PlainTextRendering("")); - _renderingEngine.RegisterRenderer(typeof(string), new DefaultRenderer()); - _renderingEngine.RegisterRenderer(typeof(IDictionary), new DictionaryRenderer()); - _renderingEngine.RegisterRenderer(typeof(IList), new ListRenderer()); - _renderingEngine.RegisterRenderer(typeof(IEnumerable), new SequenceRenderer()); } public async Task Handle(JupyterRequestContext context) @@ -89,7 +82,7 @@ void OnKernelResultEvent(IKernelEvent value) switch (value) { case ValueProduced valueProduced: - OnValueProduced(valueProduced, OpenRequests, _renderingEngine); + OnValueProduced(valueProduced, OpenRequests); break; case CodeSubmissionEvaluated codeSubmissionEvaluated: OnCodeSubmissionEvaluated(codeSubmissionEvaluated, OpenRequests); @@ -145,8 +138,9 @@ private static void OnCodeSubmissionEvaluatedFailed(CodeSubmissionEvaluationFail openRequest.Dispose(); } - private static void OnValueProduced(ValueProduced valueProduced, - ConcurrentDictionary openRequests, RenderingEngine renderingEngine) + private static void OnValueProduced( + ValueProduced valueProduced, + ConcurrentDictionary openRequests) { openRequests.TryGetValue(valueProduced.Command, out var openRequest); if (openRequest == null) @@ -156,16 +150,12 @@ private static void OnValueProduced(ValueProduced valueProduced, try { - var rendering = renderingEngine.Render(valueProduced.Value); - // executeResult data var executeResultData = new ExecuteResult( openRequest.ExecutionCount, transient: openRequest.Transient, - data: new Dictionary - { - { rendering.MimeType, rendering.Content } - }); + data: valueProduced?.FormattedValues + ?.ToDictionary(k => k.MimeType ?? "text/plain", v => v.Value)); if (!openRequest.Request.Silent) { diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/DefaultRenderer.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/DefaultRenderer.cs deleted file mode 100644 index e0072b7d1..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/DefaultRenderer.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Reflection; -using System.Text; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class DefaultRenderer : IRenderer - { - public IRendering Render(object source, RenderingEngine engine = null) - { - var sourceType = source.GetType(); - - var isStructured = RendererUtilities.IsStructured(sourceType); - - return isStructured ? RenderObject(source, engine) : new PlainTextRendering(source?.ToString()); - } - - public IRendering RenderObject(object source, RenderingEngine engine = null) - { - var rows = CreateRows(source, engine); - var table = $@" - {rows} -
"; - - return new HtmlRendering(table); - } - - private string CreateRows(object source, RenderingEngine engine) - { - var props = source.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); - var rows = new StringBuilder(); - foreach (var propertyInfo in props) - { - var childRenderer = engine.GetRendererForType(propertyInfo.PropertyType); - var childValue = propertyInfo.GetValue(source); - var childRendering = childRenderer.Render(childValue, engine); - var row = $@"{propertyInfo.Name}{childRendering.Content}"; - rows.AppendLine(row); - } - - var fields = source.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance); - - foreach (var fieldInfo in fields) - { - var childRenderer = engine.GetRendererForType(fieldInfo.FieldType); - var childValue = fieldInfo.GetValue(source); - var childRendering = childRenderer.Render(childValue, engine); - var row = $@"{fieldInfo.Name}{childRendering.Content}"; - rows.AppendLine(row); - } - - return rows.ToString(); - } - } -} diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/DictionaryRenderer.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/DictionaryRenderer.cs deleted file mode 100644 index 84cb9bbf2..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/DictionaryRenderer.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class DictionaryRenderer : IRenderer - { - public IRendering Render(object source, RenderingEngine engine = null) - { - switch (source) - { - case IDictionary dictionary: - var sourceType = RendererUtilities.GetSequenceElementTypeOrKeyValuePairValueType(dictionary); - var accessors = RendererUtilities.GetAccessors(sourceType).ToList(); - var keyValueList = dictionary.Keys.OfType() - .Select(k => new KeyValuePair(k, dictionary[k])); - var headers = RendererUtilities.CreateTableHeaders(accessors, true); - var rows = RendererUtilities.CreateTableRowsFromValues(accessors, keyValueList, engine); - var table = $@" -{headers} -{rows} -
"; - return new HtmlRendering(table); - default: - throw new ArgumentOutOfRangeException($"Sequence type {source.GetType()} not supported "); - } - } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/HtmlRendering.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/HtmlRendering.cs deleted file mode 100644 index 0eb8ceb31..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/HtmlRendering.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class HtmlRendering : IRendering - { - public HtmlRendering(string html) - { - Content = html; - } - - public string MimeType { get; } = "text/html"; - public object Content { get; } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/ListRenderer.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/ListRenderer.cs deleted file mode 100644 index 3eaca6756..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/ListRenderer.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class ListRenderer : IRenderer - { - public IRendering Render(object source, RenderingEngine engine = null) - { - switch (source) - { - case IList sequence: - var sourceType = RendererUtilities.GetSequenceElementTypeOrKeyValuePairValueType(sequence); - var accessors = RendererUtilities.GetAccessors(sourceType).ToList(); - var keyValueList = sequence.OfType() - .Select((v, i) => new KeyValuePair(i, v)); - var headers = RendererUtilities.CreateTableHeaders(accessors, true); - var rows = RendererUtilities.CreateTableRowsFromValues(accessors, keyValueList, engine); - var table = $@" -{headers} -{rows} -
"; - return new HtmlRendering(table); - default: - throw new ArgumentOutOfRangeException($"Sequence type {source.GetType()} not supported "); - } - } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/PlainTextRendering.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/PlainTextRendering.cs deleted file mode 100644 index f0826a193..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/PlainTextRendering.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class PlainTextRendering : IRendering - { - public PlainTextRendering(string text) - { - Content = text; - } - - public string MimeType { get; } = "text/plain"; - public object Content { get; } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/RendererUtilities.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/RendererUtilities.cs deleted file mode 100644 index 8bf8bec38..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/RendererUtilities.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - internal static class RendererUtilities - { - public static bool IsStructured(Type sourceType) - { - var isStructured = !sourceType.IsPrimitive - && ((sourceType.IsClass && sourceType != typeof(string)) - || sourceType.IsValueType && !sourceType.IsEnum); - return isStructured; - } - - public static string CreateTableHeaders(IEnumerable memberInfos, bool emptyFirstHeader) - { - var headersBuffer = new StringBuilder(); - if (memberInfos?.Any() != false) - { - headersBuffer.AppendLine("\t"); - if (emptyFirstHeader) - { - headersBuffer.AppendLine("\t\t"); - } - - foreach (var memberInfo in memberInfos) - { - headersBuffer.AppendLine($"\t\t{memberInfo.Name}"); - } - headersBuffer.AppendLine("\t"); - } - - return headersBuffer.ToString(); - } - - public static IEnumerable GetAccessors(Type sourceType) - { - if (IsStructured(sourceType)) - { - var props = sourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance).OfType(); - var fields = sourceType.GetFields(BindingFlags.Public | BindingFlags.Instance).OfType(); - - return props.Concat(fields); - } - - return Enumerable.Empty(); - } - - public static string CreateTableRowsFromValues(IEnumerable memberInfos, IEnumerable> source, RenderingEngine engine, - bool emptyFirstCell = false) - { - var nullRendering = new PlainTextRendering("null"); - var rowsBuffer = new StringBuilder(); - - - foreach (var (key,element) in source.Select(e => (e.Key,e.Value))) - { - - rowsBuffer.AppendLine("\t"); - if (emptyFirstCell) - { - rowsBuffer.AppendLine("\t\t"); - } - - var keyRenderer = engine.GetRendererForType(key.GetType()); - var keyRendering = keyRenderer.Render(key, engine); - rowsBuffer.AppendLine($"\t\t{keyRendering?.Content ?? string.Empty}"); - CreateTableRow(memberInfos, engine, element, nullRendering, rowsBuffer); - rowsBuffer.AppendLine("\t"); - } - - return rowsBuffer.ToString(); - } - - private static void CreateTableRow(IEnumerable memberInfos, RenderingEngine engine, object element, - IRendering defaultRendering, StringBuilder rowsBuffer) - { - if (memberInfos?.Any() != false) - { - foreach (var memberInfo in memberInfos) - { - IRendering childRendering = null; - switch (memberInfo) - { - case PropertyInfo propertyInfo: - { - var childRenderer = engine.GetRendererForType(propertyInfo.PropertyType); - var childValue = propertyInfo.GetValue(element); - childRendering = childValue == null ? defaultRendering : childRenderer.Render(childValue, engine); - } - break; - case FieldInfo fieldInfo: - { - var childRenderer = engine.GetRendererForType(fieldInfo.FieldType); - var childValue = fieldInfo.GetValue(element); - childRendering = childValue == null ? defaultRendering : childRenderer.Render(childValue, engine); - } - break; - } - - var row = $"\t\t{childRendering?.Content ?? string.Empty}"; - rowsBuffer.AppendLine(row); - } - } - else - { - var childRenderer = engine.GetRendererForType(element.GetType()); - var childRendering = childRenderer.Render(element, engine); - rowsBuffer.AppendLine($"\t\t{childRendering.Content}"); - } - } - - public static string CreateTableRowsFromValues(IEnumerable memberInfos, IEnumerable source, RenderingEngine engine, - bool emptyFirstCell = false) - { - var nullRendering = new PlainTextRendering("null"); - var rowsBuffer = new StringBuilder(); - - - foreach (var element in source) - { - rowsBuffer.AppendLine("\t"); - if (emptyFirstCell) - { - rowsBuffer.AppendLine("\t\t"); - } - CreateTableRow(memberInfos, engine, element, nullRendering, rowsBuffer); - rowsBuffer.AppendLine("\t"); - } - - return rowsBuffer.ToString(); - } - - public static Type GetSequenceElementTypeOrKeyValuePairValueType(IEnumerable sequence) - { - var elementType = GetSequenceElementTypeOrKeyValuePairValueType(sequence.GetType()); - if (elementType == null) - { - elementType = sequence.Cast().FirstOrDefault()?.GetType() ?? typeof(object); - elementType = GetElementOrValuePropertyType(elementType); - } - - return elementType; - } - - private static Type GetElementOrValuePropertyType(Type elementType) - { - if (elementType.IsGenericType && elementType.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) - { - elementType = elementType.GetGenericArguments()[1]; - } - - return elementType; - } - - private static Type GetSequenceElementTypeOrKeyValuePairValueType(Type sequenceType) - { - var dictionaryInterface = sequenceType.GetInterfaces().FirstOrDefault(i => - i.IsGenericType && typeof(IDictionary<,>).IsAssignableFrom(i.GetGenericTypeDefinition())); - if (dictionaryInterface != null) - { - return dictionaryInterface.GetGenericArguments()[1]; - } - - var enumerableInterface =sequenceType.GetInterfaces().FirstOrDefault(i => - i.IsGenericType && typeof(IEnumerable<>).IsAssignableFrom(i.GetGenericTypeDefinition())); - if (enumerableInterface != null) - { - return GetElementOrValuePropertyType(enumerableInterface.GetGenericArguments()[0]); - } - - return null; - } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/RenderingEngineExtensions.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/RenderingEngineExtensions.cs deleted file mode 100644 index 8d4bd476f..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/RenderingEngineExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - internal static class RenderingEngineExtensions - { - public static IRenderer GetRendererForType( - this RenderingEngine engine, - Type sourceType) - { - return engine?.FindRenderer(sourceType) ?? new DefaultRenderer(); - } - } -} \ No newline at end of file diff --git a/Microsoft.DotNet.Try.Jupyter/Rendering/SequenceRenderer.cs b/Microsoft.DotNet.Try.Jupyter/Rendering/SequenceRenderer.cs deleted file mode 100644 index 0028cfaad..000000000 --- a/Microsoft.DotNet.Try.Jupyter/Rendering/SequenceRenderer.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Collections; -using System.Linq; -using WorkspaceServer.Kernel; - -namespace Microsoft.DotNet.Try.Jupyter.Rendering -{ - public class SequenceRenderer : IRenderer - { - public IRendering Render(object source, RenderingEngine engine = null) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - switch (source) - { - case IEnumerable sequence: - var sourceType = RendererUtilities.GetSequenceElementTypeOrKeyValuePairValueType(sequence); - var accessors = RendererUtilities.GetAccessors(sourceType).ToList(); - var headers = RendererUtilities.CreateTableHeaders(accessors, false); - var rows = RendererUtilities.CreateTableRowsFromValues(accessors, sequence, engine); - var table = $@" -{headers} -{rows} -
"; - return new HtmlRendering(table); - default: - throw new ArgumentOutOfRangeException($"Sequence type {source.GetType()} not supported "); - } - } - } -} \ No newline at end of file diff --git a/WorkspaceServer/Kernel/CSharpRepl.cs b/WorkspaceServer/Kernel/CSharpRepl.cs index 85b188d4e..33b3f45fd 100644 --- a/WorkspaceServer/Kernel/CSharpRepl.cs +++ b/WorkspaceServer/Kernel/CSharpRepl.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Text; @@ -11,6 +12,8 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; +using Microsoft.DotNet.Interactive.Rendering; +using Task = System.Threading.Tasks.Task; namespace WorkspaceServer.Kernel { @@ -44,11 +47,13 @@ private void SetupScriptOptions() "System.Collections", "System.Collections.Generic", "System.Threading.Tasks", - "System.Linq") + "System.Linq", + "Microsoft.DotNet.Interactive.Rendering") .AddReferences( - typeof(Enumerable).GetTypeInfo().Assembly, - typeof(IEnumerable<>).GetTypeInfo().Assembly, - typeof(Task<>).GetTypeInfo().Assembly); + typeof(Enumerable).Assembly, + typeof(IEnumerable<>).Assembly, + typeof(Task<>).Assembly, + typeof(PocketView).Assembly); } private (bool shouldExecute, string completeSubmission) IsBufferACompleteSubmission(string input) @@ -135,7 +140,20 @@ private async Task HandleSubmitCode( { if (HasReturnValue) { - context.OnNext(new ValueProduced(_scriptState.ReturnValue, codeSubmission)); + var writer = new StringWriter(); + _scriptState.ReturnValue.FormatTo(writer); + + var formattedValues = new List + { + new FormattedValue( + Formatter.MimeTypeFor(_scriptState.ReturnValue?.GetType() ?? typeof(object)), writer.ToString()) + }; + + context.OnNext( + new ValueProduced( + _scriptState.ReturnValue, + codeSubmission, + formattedValues)); } context.OnNext(new CodeSubmissionEvaluated(codeSubmission)); diff --git a/WorkspaceServer/Kernel/FormattedValue.cs b/WorkspaceServer/Kernel/FormattedValue.cs new file mode 100644 index 000000000..a4dc2e0e4 --- /dev/null +++ b/WorkspaceServer/Kernel/FormattedValue.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace WorkspaceServer.Kernel +{ + public class FormattedValue + { + public FormattedValue(string mimeType, object value) + { + MimeType = mimeType; + Value = value; + } + + public string MimeType { get; } + + public object Value { get; } + } +} \ No newline at end of file diff --git a/WorkspaceServer/Kernel/IRenderer.cs b/WorkspaceServer/Kernel/IRenderer.cs deleted file mode 100644 index f298ccc56..000000000 --- a/WorkspaceServer/Kernel/IRenderer.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -namespace WorkspaceServer.Kernel -{ - public interface IRenderer - { - IRendering Render(object source, RenderingEngine engine = null); - } -} \ No newline at end of file diff --git a/WorkspaceServer/Kernel/RenderingEngine.cs b/WorkspaceServer/Kernel/RenderingEngine.cs deleted file mode 100644 index 95cf3da49..000000000 --- a/WorkspaceServer/Kernel/RenderingEngine.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace WorkspaceServer.Kernel -{ - public class RenderingEngine - { - private readonly IRenderer _defaultRenderer; - private readonly IRendering _nullRendering; - private readonly Dictionary _rendererRegistry = new Dictionary(); - - public RenderingEngine(IRenderer defaultRenderer, IRendering nullRendering) - { - _defaultRenderer = defaultRenderer; - _nullRendering = nullRendering; - } - - public IRendering Render(object source) - { - if (source == null) - { - return _nullRendering; - } - - var renderer = FindRenderer(source.GetType()); - return renderer.Render(source, this); - } - - public IRenderer FindRenderer(Type sourceType) - { - if (sourceType == null) - { - throw new ArgumentNullException(nameof(sourceType)); - } - return _rendererRegistry.TryGetValue(sourceType, out var renderer) ? renderer : FindMatchingRenderer(sourceType); - } - - private IRenderer FindMatchingRenderer(Type sourceType) - { - var keyCandidates = new List(); - - var candidates = _rendererRegistry.Keys.Where(key => key.IsAssignableFrom(sourceType)).ToList(); - - if (candidates.Count == 0) - { - return _defaultRenderer; - } - - foreach (var candidate in candidates) - { - var found = false; - - for (var i = 0; i < keyCandidates.Count; i++) - { - var current = keyCandidates[i]; - if (current.IsAssignableFrom(candidate)) - { - keyCandidates[i] = candidate; - found = true; - } - else if (candidate.IsAssignableFrom(current)) - { - found = true; - } - - if (found) - { - break; - } - } - - if (!found) - { - keyCandidates.Add(candidate); - } - } - - return _rendererRegistry[keyCandidates[0]]; - } - - public void RegisterRenderer(Type sourceType, IRenderer renderer) - { - if (sourceType == null) - { - throw new ArgumentNullException(nameof(sourceType)); - } - - _rendererRegistry[sourceType] = renderer ?? throw new ArgumentNullException(nameof(renderer)); - } - } -} \ No newline at end of file diff --git a/WorkspaceServer/Kernel/ValueProduced.cs b/WorkspaceServer/Kernel/ValueProduced.cs index aeff6ae05..4ccd136c0 100644 --- a/WorkspaceServer/Kernel/ValueProduced.cs +++ b/WorkspaceServer/Kernel/ValueProduced.cs @@ -1,15 +1,23 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Collections.Generic; + namespace WorkspaceServer.Kernel { - public class ValueProduced: KernelEventBase + public class ValueProduced : KernelEventBase { - public ValueProduced(object value, SubmitCode submitCode) : base(submitCode) + public ValueProduced( + object value, + SubmitCode submitCode, + IReadOnlyCollection formattedValues = null) : base(submitCode) { Value = value; + FormattedValues = formattedValues; } public object Value { get; } + + public IReadOnlyCollection FormattedValues { get; } } } \ No newline at end of file diff --git a/WorkspaceServer/WorkspaceServer.csproj b/WorkspaceServer/WorkspaceServer.csproj index a531c33bd..8d1c57187 100644 --- a/WorkspaceServer/WorkspaceServer.csproj +++ b/WorkspaceServer/WorkspaceServer.csproj @@ -100,6 +100,7 @@ +