diff --git a/src/JsonPoke/JsonPoke.cs b/src/JsonPoke/JsonPoke.cs index cd40d87..e2279b2 100644 --- a/src/JsonPoke/JsonPoke.cs +++ b/src/JsonPoke/JsonPoke.cs @@ -1,283 +1,291 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -/// -/// Sets values as specified by a JSONPath query into a JSON file. -/// -public class JsonPoke : Task -{ - static readonly Regex JPathExpr = new(@"(?\['(?[^\]]+)'\])|(?\.(?[^\.\[]+))|(?\[(?\^)?(?\d+)\])", RegexOptions.Compiled); - - /// - /// Specifies the JSON input as a string. - /// - [Output] - public string? Content { get; set; } - - /// - /// Specifies the JSON input as a file path. - /// - public ITaskItem? ContentPath { get; set; } - - /// - /// Specifies the JSONPath expression. - /// - [Required] - public string Query { get; set; } = "$"; - - /// - /// Specifies the value to be inserted into the specified path. - /// - public ITaskItem[] Value { get; set; } = Array.Empty(); - - /// - /// Specifies the raw (JSON) value to be inserted into the specified path. - /// - public string? RawValue { get; set; } - - /// - /// Property names to set for a JSON object value from matching - /// item metadata values from item(s). - /// - public ITaskItem[] Properties { get; set; } = Array.Empty(); - - /// - /// Contains the updated JSON nodes. - /// - [Output] - public ITaskItem[] Result { get; private set; } = new ITaskItem[0]; - - /// - /// Locates nodes matching the and replaces their contents - /// with the provided . - /// - public override bool Execute() - { - if (Content == null && ContentPath == null) - return Log.Error("JPO01", $"Either {nameof(Content)} or {nameof(ContentPath)} must be provided."); - - if (ContentPath != null && !File.Exists(ContentPath.GetMetadata("FullPath"))) - return Log.Error("JPO02", $"Specified {nameof(ContentPath)} not found at {ContentPath.GetMetadata("FullPath")}."); - - if (Content != null && ContentPath != null) - return Log.Error("JPO03", $"Cannot specify both {nameof(Content)} and {nameof(ContentPath)}."); - - var content = ContentPath != null ? - File.ReadAllText(ContentPath.GetMetadata("FullPath")) : Content; - - if (string.IsNullOrEmpty(content)) - return Log.Error("JPO04", $"Empty JSON content."); - - if (Value.Length == 0 && RawValue == null) - return Log.Warn("JPO05", $"No value(s) specified.", true); - - if (Value.Length > 0 && RawValue != null) - return Log.Error("JPO06", $"Cannot specify both {nameof(Value)} and {nameof(RawValue)}."); - - var jvalue = new Lazy(GetValue); - - var json = JObject.Parse(content!); - var matches = JPathExpr.Matches(Query).Cast().ToList(); - // If we have any part of teh expression using our own "from end" syntax for array indexing, - // we know we'll be inserting a *new* - var nodes = matches.Any(m => m.Groups["end"].Success) ? new() : json.SelectTokens(Query).ToList(); - var result = new List(); - var owned = new HashSet(); - - // We couldn't match anything to Query, so attempt to - // create an object at the specified path. - if (nodes.Count == 0) - { - // If we can't match anything, we can't create any objects. - if (matches.Count == 0) - return true; - - // Split paths, try to match sequentially, can't do wildcards as final segment - for (var midx = 0; midx < matches.Count; midx++) - { - var match = matches[midx]; - var parent = json.SelectToken(Query[..match.Index]); - var token = match.Groups["end"].Success ? null : json.SelectToken(Query[..(match.Index + match.Length)]); - if (token != null) - continue; - - if (parent == null) - return Log.Error("JPO07", $"Could not find parent node for {Query[..match.Index]}."); - - // For arrays, if we don't find the element, create/add it if we can - if (match.Groups["array"].Success) - { - if (parent is not JArray array) - { - // We own the node (meaning we created it previously), so we can replace it with an array instead. - if (owned.Contains(Query[..match.Index])) - { - array = new JArray(); - parent.Replace(array); - } - else - { - return Log.Warn("JPO08", $"JSONPath segment '{Query[..match.Index]}' didn't match a JSON array."); - } - } - - if (match.Groups["index"].Success) - { - var index = int.Parse(match.Groups["index"].Value, CultureInfo.InvariantCulture); - if (match.Groups["end"].Success) - { - index = array.Count + 1 - index; - // In this case, we'll be changing the index value, so we'll need - // to replace the query and matches for subsequent segments. - Query = Query[..match.Index] + "[" + index + "]" + Query[(match.Index + match.Length)..]; - matches = JPathExpr.Matches(Query).Cast().ToList(); - match = matches[midx]; - } - - if (index >= 0 && index <= array.Count) - { - // We can simply add the new object at the given index. - token = new JObject(); - array.Insert(index, token); - owned.Add(Query[..(match.Index + match.Length)]); - } - else - { - return Log.Warn("JPO09", $"JSONPath index {index} out of range."); - } - } - else - { - return Log.Warn("JPO10", $"JSONPath array index not specified."); - } - } - else - { - // If parent is not an object, we can't set a property on it. - if (parent is not JObject obj) - return Log.Warn("JPO11", $"JSONPath segment '{Query[..match.Index]}' didn't match a JSON object."); - - token = new JProperty(match.Groups["name"].Value, new JObject()); - owned.Add(Query[..(match.Index + match.Length)]); - obj.Add(token); - } - - if (token == null) - return false; - } - } - - nodes = json.SelectTokens(Query).ToList(); - - void AddResult(JToken node, int index) - { - if (nodes.Count == 1) - { - result.Add(new TaskItem(Query, new Dictionary - { - { "Value", node.AsString() } - })); - } - else - { - result.Add(new TaskItem(Query + "[" + index + "]", new Dictionary - { - { "Value", node.AsString() } - })); - } - } - - for (var i = 0; i < nodes.Count; i++) - { - var node = nodes[i]; - - if (Value.Length > 1 || Properties.Length != 0 || RawValue != null) - { - // We'll be doing complex object replacement, - // so just replace the whole thing in one shot, - // no smarts for target-type selection. - node.Replace(jvalue.Value); - AddResult(jvalue.Value, i); - continue; - } - - // If the Value.Length == 1 OR Properties.Length == 0 (meaning - // we're not entering above condition for either arrays or complex - // objects), it's a single value, which we can target-type to the - // native node type being replaced. This allows us to preserve the - // native JSON type whenever possible. - - var value = node.Type switch - { - JTokenType.String => new JValue(Value[0].ItemSpec), - JTokenType.Array => new JArray(jvalue.Value), - JTokenType.Integer when long.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - JTokenType.Float when decimal.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - JTokenType.Boolean when bool.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - JTokenType.Date when DateTime.TryParseExact(Value[0].ItemSpec, "O", CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind, out var typed) => new JValue(typed), - JTokenType.Date when DateTime.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - JTokenType.Guid when Guid.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - JTokenType.Uri when Uri.TryCreate(Value[0].ItemSpec, UriKind.RelativeOrAbsolute, out var typed) => new JValue(typed), - JTokenType.TimeSpan when TimeSpan.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), - _ => jvalue.Value, - }; - - node.Replace(value); - AddResult(value, i); - } - - Content = json.ToString(Formatting.Indented); - if (ContentPath != null) - File.WriteAllText(ContentPath.GetMetadata("FullPath"), Content); - - Result = result.ToArray(); - - return true; - } - - JToken GetValue() - { - if (Value.Length == 1) - return GetValue(Value[0]); - else if (RawValue != null) - return JToken.Parse(RawValue); - - return new JArray(Value.Select(GetValue).ToArray()); - } - - JToken GetValue(ITaskItem item) - { - if (Properties.Length == 0) - return GetTypedValue(item.ItemSpec); - - var value = new JObject(); - foreach (var prop in Properties) - value[prop.ItemSpec] = GetTypedValue(item.GetMetadata(prop.ItemSpec)); - - return value; - } - - JToken GetTypedValue(string itemSpec) - { - if ((itemSpec.StartsWith("\"") && itemSpec.EndsWith("\"")) || - (itemSpec.StartsWith("'") && itemSpec.EndsWith("'"))) - return new JValue(itemSpec.Trim('\'', '"')); - - try - { - return JToken.Parse(itemSpec); - } - catch - { - return new JValue(itemSpec); - } - } -} +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +/// +/// Sets values as specified by a JSONPath query into a JSON file. +/// +public class JsonPoke : Task +{ + static readonly Regex JPathExpr = new(@"(?\['(?[^\]]+)'\])|(?\.(?[^\.\[]+))|(?\[(?\^)?(?\d+)\])", RegexOptions.Compiled); + + /// + /// Specifies the JSON input as a string. + /// + [Output] + public string? Content { get; set; } + + /// + /// Specifies the JSON input as a file path. + /// + public ITaskItem? ContentPath { get; set; } + + /// + /// Specifies the JSONPath expression. + /// + [Required] + public string Query { get; set; } = "$"; + + /// + /// Specifies the value to be inserted into the specified path. + /// + public ITaskItem[] Value { get; set; } = Array.Empty(); + + /// + /// Specifies the raw (JSON) value to be inserted into the specified path. + /// + public string? RawValue { get; set; } + + /// + /// Property names to set for a JSON object value from matching + /// item metadata values from item(s). + /// + public ITaskItem[] Properties { get; set; } = Array.Empty(); + + /// + /// Contains the updated JSON nodes. + /// + [Output] + public ITaskItem[] Result { get; private set; } = new ITaskItem[0]; + + /// + /// Locates nodes matching the and replaces their contents + /// with the provided . + /// + public override bool Execute() + { + if (Content == null && ContentPath == null) + return Log.Error("JPO01", $"Either {nameof(Content)} or {nameof(ContentPath)} must be provided."); + + if (ContentPath != null && !File.Exists(ContentPath.GetMetadata("FullPath"))) + return Log.Error("JPO02", $"Specified {nameof(ContentPath)} not found at {ContentPath.GetMetadata("FullPath")}."); + + if (Content != null && ContentPath != null) + return Log.Error("JPO03", $"Cannot specify both {nameof(Content)} and {nameof(ContentPath)}."); + + var content = ContentPath != null ? + File.ReadAllText(ContentPath.GetMetadata("FullPath")) : Content; + + if (string.IsNullOrEmpty(content)) + return Log.Error("JPO04", $"Empty JSON content."); + + if (Value.Length == 0 && RawValue == null) + return Log.Warn("JPO05", $"No value(s) specified.", true); + + if (Value.Length > 0 && RawValue != null) + return Log.Error("JPO06", $"Cannot specify both {nameof(Value)} and {nameof(RawValue)}."); + + var jvalue = new Lazy(GetValue); + + var json = JToken.Parse(content!); + var matches = JPathExpr.Matches(Query).Cast().ToList(); + + void SetToken(JToken node, JToken value) + { + if (node.Parent == null) + json = value; + else + node.Replace(value); + } + // If we have any part of teh expression using our own "from end" syntax for array indexing, + // we know we'll be inserting a *new* + var nodes = matches.Any(m => m.Groups["end"].Success) ? new() : json.SelectTokens(Query).ToList(); + var result = new List(); + var owned = new HashSet(); + + // We couldn't match anything to Query, so attempt to + // create an object at the specified path. + if (nodes.Count == 0) + { + // If we can't match anything, we can't create any objects. + if (matches.Count == 0) + return true; + + // Split paths, try to match sequentially, can't do wildcards as final segment + for (var midx = 0; midx < matches.Count; midx++) + { + var match = matches[midx]; + var parent = json.SelectToken(Query[..match.Index]); + var token = match.Groups["end"].Success ? null : json.SelectToken(Query[..(match.Index + match.Length)]); + if (token != null) + continue; + + if (parent == null) + return Log.Error("JPO07", $"Could not find parent node for {Query[..match.Index]}."); + + // For arrays, if we don't find the element, create/add it if we can + if (match.Groups["array"].Success) + { + if (parent is not JArray array) + { + // We own the node (meaning we created it previously), so we can replace it with an array instead. + if (owned.Contains(Query[..match.Index])) + { + array = new JArray(); + parent.Replace(array); + } + else + { + return Log.Warn("JPO08", $"JSONPath segment '{Query[..match.Index]}' didn't match a JSON array."); + } + } + + if (match.Groups["index"].Success) + { + var index = int.Parse(match.Groups["index"].Value, CultureInfo.InvariantCulture); + if (match.Groups["end"].Success) + { + index = array.Count + 1 - index; + // In this case, we'll be changing the index value, so we'll need + // to replace the query and matches for subsequent segments. + Query = Query[..match.Index] + "[" + index + "]" + Query[(match.Index + match.Length)..]; + matches = JPathExpr.Matches(Query).Cast().ToList(); + match = matches[midx]; + } + + if (index >= 0 && index <= array.Count) + { + // We can simply add the new object at the given index. + token = new JObject(); + array.Insert(index, token); + owned.Add(Query[..(match.Index + match.Length)]); + } + else + { + return Log.Warn("JPO09", $"JSONPath index {index} out of range."); + } + } + else + { + return Log.Warn("JPO10", $"JSONPath array index not specified."); + } + } + else + { + // If parent is not an object, we can't set a property on it. + if (parent is not JObject obj) + return Log.Warn("JPO11", $"JSONPath segment '{Query[..match.Index]}' didn't match a JSON object."); + + token = new JProperty(match.Groups["name"].Value, new JObject()); + owned.Add(Query[..(match.Index + match.Length)]); + obj.Add(token); + } + + if (token == null) + return false; + } + } + + nodes = json.SelectTokens(Query).ToList(); + + void AddResult(JToken node, int index) + { + if (nodes.Count == 1) + { + result.Add(new TaskItem(Query, new Dictionary + { + { "Value", node.AsString() } + })); + } + else + { + result.Add(new TaskItem(Query + "[" + index + "]", new Dictionary + { + { "Value", node.AsString() } + })); + } + } + + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + + if (Value.Length > 1 || Properties.Length != 0 || RawValue != null) + { + // We'll be doing complex object replacement, + // so just replace the whole thing in one shot, + // no smarts for target-type selection. + SetToken(node, jvalue.Value); + AddResult(jvalue.Value, i); + continue; + } + + // If the Value.Length == 1 OR Properties.Length == 0 (meaning + // we're not entering above condition for either arrays or complex + // objects), it's a single value, which we can target-type to the + // native node type being replaced. This allows us to preserve the + // native JSON type whenever possible. + + var value = node.Type switch + { + JTokenType.String => new JValue(Value[0].ItemSpec), + JTokenType.Array => new JArray(jvalue.Value), + JTokenType.Integer when long.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + JTokenType.Float when decimal.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + JTokenType.Boolean when bool.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + JTokenType.Date when DateTime.TryParseExact(Value[0].ItemSpec, "O", CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind, out var typed) => new JValue(typed), + JTokenType.Date when DateTime.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + JTokenType.Guid when Guid.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + JTokenType.Uri when Uri.TryCreate(Value[0].ItemSpec, UriKind.RelativeOrAbsolute, out var typed) => new JValue(typed), + JTokenType.TimeSpan when TimeSpan.TryParse(Value[0].ItemSpec, out var typed) => new JValue(typed), + _ => jvalue.Value, + }; + + SetToken(node, value); + AddResult(value, i); + } + + Content = json.ToString(Formatting.Indented); + if (ContentPath != null) + File.WriteAllText(ContentPath.GetMetadata("FullPath"), Content); + + Result = result.ToArray(); + + return true; + } + + JToken GetValue() + { + if (Value.Length == 1) + return GetValue(Value[0]); + else if (RawValue != null) + return JToken.Parse(RawValue); + + return new JArray(Value.Select(GetValue).ToArray()); + } + + JToken GetValue(ITaskItem item) + { + if (Properties.Length == 0) + return GetTypedValue(item.ItemSpec); + + var value = new JObject(); + foreach (var prop in Properties) + value[prop.ItemSpec] = GetTypedValue(item.GetMetadata(prop.ItemSpec)); + + return value; + } + + JToken GetTypedValue(string itemSpec) + { + if ((itemSpec.StartsWith("\"") && itemSpec.EndsWith("\"")) || + (itemSpec.StartsWith("'") && itemSpec.EndsWith("'"))) + return new JValue(itemSpec.Trim('\'', '"')); + + try + { + return JToken.Parse(itemSpec); + } + catch + { + return new JValue(itemSpec); + } + } +} diff --git a/src/Tests/Poke.targets b/src/Tests/Poke.targets index c61c7db..d38cd86 100644 --- a/src/Tests/Poke.targets +++ b/src/Tests/Poke.targets @@ -284,6 +284,30 @@ + + + $.a + Value of a + + + + + Value of a + + + + + + + + + + + + + + + diff --git a/src/Tests/Tests.cs b/src/Tests/Tests.cs index 2a6d992..a96b8fc 100644 --- a/src/Tests/Tests.cs +++ b/src/Tests/Tests.cs @@ -1,239 +1,275 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using Microsoft.Build.Execution; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Newtonsoft.Json.Linq; -using Xunit; -using Xunit.Abstractions; - -public record Tests(ITestOutputHelper Output) -{ - [Fact] - public void AddObjectPath() - { - var poke = new JsonPoke - { - Content = @"{ ""profiles"": { } }", - Query = "$.profiles['Foo.cs'].commandName", - RawValue = "'Project'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - string? value = obj?.profiles?["Foo.cs"]?.commandName; - - Assert.Equal("Project", value); - } - - [Fact] - public void AddArrayElement() - { - var poke = new JsonPoke - { - Content = @"{ ""values"": [] }", - Query = "$.values[0].commandName", - RawValue = "'Project'", - }; - - Assert.True(poke.Execute()); - - poke.Query = "$.values[1].commandName"; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("Project", (string?)obj?.values?[0]?.commandName); - Assert.Equal("Project", (string?)obj?.values?[1]?.commandName); - } - - [Fact] - public void AddLastArrayElement() - { - var poke = new JsonPoke - { - Content = @"{ ""values"": [ ""foo"", ""bar"" ] }", - Query = "$.values[^1]", - RawValue = "'baz'", - }; - - Assert.True(poke.Execute()); - - poke.Query = "$.values[^1]"; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("baz", (string?)obj?.values?[2]); - Assert.Equal("baz", (string?)obj?.values?[3]); - } - - [Fact] - public void AddMiddleArrayElement() - { - var poke = new JsonPoke - { - Content = @"{ ""values"": [ ""foo"", ""bar"" ] }", - Query = "$.values[1]", - RawValue = "'baz'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("baz", (string?)obj?.values?[1]); - } - - [Fact] - public void AddMiddleArrayElementFromEnd() - { - var poke = new JsonPoke - { - Content = @"{ ""values"": [ { }, { } ] }", - Query = "$.values[^2].data", - RawValue = "'baz'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("baz", (string?)obj?.values?[1]?.data); - } - - [Fact] - public void AddLastArrayElementWithEmptyArray() - { - var poke = new JsonPoke - { - Content = @"{ ""values"": [ ] }", - Query = "$.values[^1]", - RawValue = "'baz'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("baz", (string?)obj?.values?[0]); - } - - [Fact] - public void AddLastArrayElementWithNoArray() - { - var poke = new JsonPoke - { - Content = @"{ }", - Query = "$.values[^1]", - RawValue = "'baz'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - - Assert.Equal("baz", (string?)obj?.values?[0]); - } - - [Fact] - public void AddObjectArray() - { - var poke = new JsonPoke - { - Content = @"{ }", - Query = "$.foo[0].bar[0].baz", - RawValue = "'yay'", - }; - - Assert.True(poke.Execute()); - - dynamic obj = JObject.Parse(poke.Content); - string? value = obj?.foo?[0]?.bar?[0]?.baz; - - Assert.Equal("yay", value); - } - - [Theory] - [MemberData(nameof(GetTargets))] - public void Run(string file, string name, bool failure = false, string? code = null) - { - var logger = new TestLogger(); - var result = BuildManager.DefaultBuildManager.Build( - new BuildParameters - { - ResetCaches = true, - Loggers = new ILogger[] { logger } - }, - new BuildRequestData( - Path.Combine(Directory.GetCurrentDirectory(), file), - new Dictionary(), null, new[] { name }, null)); - - if (failure) - { - if (result.OverallResult != BuildResultCode.Failure) - Output.WriteLine(string.Join(Environment.NewLine, logger.Events - .Select(e => e.Message))); - - Assert.Equal(BuildResultCode.Failure, result.OverallResult); - Assert.Contains(code, logger.Errors); - } - else - { - if (result.OverallResult != BuildResultCode.Success) - Output.WriteLine(string.Join(Environment.NewLine, logger.Events - .Select(e => e.Message))); - - Assert.Equal(BuildResultCode.Success, result.OverallResult); - if (code != null) - Assert.Contains(code, logger.Warnings); - } - - Output.WriteLine(string.Join(Environment.NewLine, logger.Events.OfType() - .Where(e => e.Importance == MessageImportance.High) - .Select(e => e.Message))); - } - - public static IEnumerable GetTargets() - { - foreach (var file in Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.targets")) - { - foreach (var target in XDocument.Load(file).Root!.Elements("Target")) - { - var label = target.Attribute("Label")?.Value; - var name = target.Attribute("Name")!.Value; - if (label == null) - { - yield return new object?[] { Path.GetFileName(file), name, }; - continue; - } - - var parts = label.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) - continue; - - yield return new object[] { Path.GetFileName(file), name, parts[0] == "Error", parts[1] }; - } - } - } - - class TestLogger : Logger - { - public HashSet Warnings { get; } = new(); - public HashSet Errors { get; } = new(); - public List Events { get; } = new(); - - public override void Initialize(IEventSource eventSource) - { - eventSource.AnyEventRaised += (_, e) => Events.Add(e); - eventSource.ErrorRaised += (_, e) => Errors.Add(e.Code); - eventSource.WarningRaised += (_, e) => Warnings.Add(e.Code); - } - } -} \ No newline at end of file +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +public record Tests(ITestOutputHelper Output) +{ + [Fact] + public void AddObjectPath() + { + var poke = new JsonPoke + { + Content = @"{ ""profiles"": { } }", + Query = "$.profiles['Foo.cs'].commandName", + RawValue = "'Project'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + string? value = obj?.profiles?["Foo.cs"]?.commandName; + + Assert.Equal("Project", value); + } + + [Fact] + public void AddArrayElement() + { + var poke = new JsonPoke + { + Content = @"{ ""values"": [] }", + Query = "$.values[0].commandName", + RawValue = "'Project'", + }; + + Assert.True(poke.Execute()); + + poke.Query = "$.values[1].commandName"; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("Project", (string?)obj?.values?[0]?.commandName); + Assert.Equal("Project", (string?)obj?.values?[1]?.commandName); + } + + [Fact] + public void AddLastArrayElement() + { + var poke = new JsonPoke + { + Content = @"{ ""values"": [ ""foo"", ""bar"" ] }", + Query = "$.values[^1]", + RawValue = "'baz'", + }; + + Assert.True(poke.Execute()); + + poke.Query = "$.values[^1]"; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("baz", (string?)obj?.values?[2]); + Assert.Equal("baz", (string?)obj?.values?[3]); + } + + [Fact] + public void AddMiddleArrayElement() + { + var poke = new JsonPoke + { + Content = @"{ ""values"": [ ""foo"", ""bar"" ] }", + Query = "$.values[1]", + RawValue = "'baz'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("baz", (string?)obj?.values?[1]); + } + + [Fact] + public void AddMiddleArrayElementFromEnd() + { + var poke = new JsonPoke + { + Content = @"{ ""values"": [ { }, { } ] }", + Query = "$.values[^2].data", + RawValue = "'baz'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("baz", (string?)obj?.values?[1]?.data); + } + + [Fact] + public void AddLastArrayElementWithEmptyArray() + { + var poke = new JsonPoke + { + Content = @"{ ""values"": [ ] }", + Query = "$.values[^1]", + RawValue = "'baz'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("baz", (string?)obj?.values?[0]); + } + + [Fact] + public void AddLastArrayElementWithNoArray() + { + var poke = new JsonPoke + { + Content = @"{ }", + Query = "$.values[^1]", + RawValue = "'baz'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + + Assert.Equal("baz", (string?)obj?.values?[0]); + } + + [Fact] + public void PokeRootWithMetadata() + { + var value = new TaskItem("metadata"); + value.SetMetadata("a", "Value of a"); + + var poke = new JsonPoke + { + Content = "{}", + Query = "$", + Value = new[] { value }, + Properties = new[] { new TaskItem("a") }, + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content!); + Assert.Equal("Value of a", (string?)obj?.a); + } + + [Fact] + public void PokeRootWithRawValue() + { + var poke = new JsonPoke + { + Content = "{}", + Query = "$", + RawValue = @"{ ""a"": ""Value of a"" }", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content!); + Assert.Equal("Value of a", (string?)obj?.a); + } + + [Fact] + public void AddObjectArray() + { + var poke = new JsonPoke + { + Content = @"{ }", + Query = "$.foo[0].bar[0].baz", + RawValue = "'yay'", + }; + + Assert.True(poke.Execute()); + + dynamic obj = JObject.Parse(poke.Content); + string? value = obj?.foo?[0]?.bar?[0]?.baz; + + Assert.Equal("yay", value); + } + + [Theory] + [MemberData(nameof(GetTargets))] + public void Run(string file, string name, bool failure = false, string? code = null) + { + var logger = new TestLogger(); + var result = BuildManager.DefaultBuildManager.Build( + new BuildParameters + { + ResetCaches = true, + Loggers = new ILogger[] { logger } + }, + new BuildRequestData( + Path.Combine(Directory.GetCurrentDirectory(), file), + new Dictionary(), null, new[] { name }, null)); + + if (failure) + { + if (result.OverallResult != BuildResultCode.Failure) + Output.WriteLine(string.Join(Environment.NewLine, logger.Events + .Select(e => e.Message))); + + Assert.Equal(BuildResultCode.Failure, result.OverallResult); + Assert.Contains(code, logger.Errors); + } + else + { + if (result.OverallResult != BuildResultCode.Success) + Output.WriteLine(string.Join(Environment.NewLine, logger.Events + .Select(e => e.Message))); + + Assert.Equal(BuildResultCode.Success, result.OverallResult); + if (code != null) + Assert.Contains(code, logger.Warnings); + } + + Output.WriteLine(string.Join(Environment.NewLine, logger.Events.OfType() + .Where(e => e.Importance == MessageImportance.High) + .Select(e => e.Message))); + } + + public static IEnumerable GetTargets() + { + foreach (var file in Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.targets")) + { + foreach (var target in XDocument.Load(file).Root!.Elements("Target")) + { + var label = target.Attribute("Label")?.Value; + var name = target.Attribute("Name")!.Value; + if (label == null) + { + yield return new object?[] { Path.GetFileName(file), name, }; + continue; + } + + var parts = label.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + continue; + + yield return new object[] { Path.GetFileName(file), name, parts[0] == "Error", parts[1] }; + } + } + } + + class TestLogger : Logger + { + public HashSet Warnings { get; } = new(); + public HashSet Errors { get; } = new(); + public List Events { get; } = new(); + + public override void Initialize(IEventSource eventSource) + { + eventSource.AnyEventRaised += (_, e) => Events.Add(e); + eventSource.ErrorRaised += (_, e) => Errors.Add(e.Code); + eventSource.WarningRaised += (_, e) => Warnings.Add(e.Code); + } + } +}