diff --git a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs new file mode 100644 index 000000000..37f408e9d --- /dev/null +++ b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs @@ -0,0 +1,148 @@ +using System; +using System.Globalization; +using System.IO; +using GitHub.DistributedTask.ObjectTemplating; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.Runner.Sdk; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Adapts a YamlDotNet as a DT + /// so a DOM + /// can be serialized back to YAML preserving its pre-evaluation form + /// (basic ${{ }} expressions are written through verbatim). + /// + /// Used by the DAP execution view to surface user-authored step + /// parameters (env:, with:, run:, ...) without + /// any expression substitution. + /// + internal sealed class TemplateTokenYamlAdapter : IObjectWriter + { + private readonly IEmitter _emitter; + + public TemplateTokenYamlAdapter(IEmitter emitter) + { + ArgUtil.NotNull(emitter, nameof(emitter)); + _emitter = emitter; + } + + public void WriteStart() + { + _emitter.Emit(new StreamStart()); + _emitter.Emit(new DocumentStart(null, null, true)); + } + + public void WriteEnd() + { + _emitter.Emit(new DocumentEnd(true)); + _emitter.Emit(new StreamEnd()); + } + + public void WriteNull() => + _emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false)); + + public void WriteBoolean(bool value) => + _emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false)); + + public void WriteNumber(double value) => + _emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false)); + + public void WriteString(string value) + { + if (value == null) + { + WriteNull(); + return; + } + // Multi-line strings render as block literal so embedded + // newlines survive the YAML round trip. + var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any; + _emitter.Emit(new Scalar(null, null, value, style, true, true)); + } + + public void WriteSequenceStart() => + _emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any)); + + public void WriteSequenceEnd() => + _emitter.Emit(new SequenceEnd()); + + public void WriteMappingStart() => + _emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any)); + + public void WriteMappingEnd() => + _emitter.Emit(new MappingEnd()); + + /// + /// Serialize a TemplateToken to a YAML fragment ready to embed + /// under a parent key. Each non-empty line is prefixed by + /// spaces. Trailing newlines and + /// the YAML stream start/document markers are stripped, so the + /// caller controls line breaks. + /// + /// + /// Empty mappings render as {} and empty sequences as + /// [] via YamlDotNet's flow style fallback for empty + /// collections. + /// + internal static string Serialize(TemplateToken token, int indentSpaces) + { + if (indentSpaces < 0) + { + throw new ArgumentOutOfRangeException(nameof(indentSpaces)); + } + + using var sw = new StringWriter(CultureInfo.InvariantCulture); + var emitter = new Emitter(sw); + var adapter = new TemplateTokenYamlAdapter(emitter); + TemplateWriter.Write(adapter, token); + + string raw = sw.ToString(); + // Strip YAML document markers ("--- " prefix and "\n..." suffix). + if (raw.StartsWith("--- ", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + const string DocEndMarker = "\n..."; + if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1); + } + else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length); + } + raw = raw.TrimEnd('\n'); + + if (indentSpaces == 0) + { + return raw; + } + + // Re-indent every non-empty line. Empty lines remain empty + // so YAML block-literal blank lines stay valid. + var pad = new string(' ', indentSpaces); + var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4); + int i = 0; + while (i < raw.Length) + { + int end = raw.IndexOf('\n', i); + int lineEnd = end < 0 ? raw.Length : end; + if (lineEnd > i) + { + sb.Append(pad); + sb.Append(raw, i, lineEnd - i); + } + if (end < 0) + { + break; + } + sb.Append('\n'); + i = end + 1; + } + return sb.ToString(); + } + } +} diff --git a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs new file mode 100644 index 000000000..b3e825323 --- /dev/null +++ b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs @@ -0,0 +1,155 @@ +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class TemplateTokenYamlAdapterL0 + { + private static StringToken Str(string s) => new(null, null, null, s); + private static BooleanToken Bool(bool b) => new(null, null, null, b); + private static NumberToken Num(double n) => new(null, null, null, n); + private static BasicExpressionToken Expr(string s) => new(null, null, null, s); + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_StringScalar() + { + Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_BooleanScalar() + { + Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0)); + Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NumberScalar() + { + Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NullToken_RendersAsNull() + { + Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_PreservesBasicExpression() + { + var token = Expr("runner.os"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Contains("${{ runner.os }}", yaml); + Assert.DoesNotContain("Linux", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_PreservesCompositeExpressionInStringToken() + { + // Composite strings like `${{ runner.os }}-primes` are parsed + // as a StringToken whose value is exactly that literal. The + // adapter must round-trip the literal unchanged. + var token = Str("${{ runner.os }}-primes"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Contains("${{ runner.os }}-primes", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NestedMapping() + { + var inner = new MappingToken(null, null, null); + inner.Add(Str("b"), Num(1)); + inner.Add(Str("c"), Expr("x")); + var outer = new MappingToken(null, null, null); + outer.Add(Str("a"), inner); + + string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0); + + Assert.Contains("a:", yaml); + Assert.Contains("b: 1", yaml); + Assert.Contains("c: ${{ x }}", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_EmptyMapping() + { + var token = new MappingToken(null, null, null); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Equal("{}", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_EmptySequence() + { + var token = new SequenceToken(null, null, null); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Equal("[]", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_MultilineString_UsesBlockScalar() + { + var token = Str("line1\nline2\nline3"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + // Block-literal indicator `|` appears for multi-line scalars. + Assert.Contains("|", yaml); + Assert.Contains("line1", yaml); + Assert.Contains("line2", yaml); + Assert.Contains("line3", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_IndentLevel_PrefixesNonEmptyLines() + { + var map = new MappingToken(null, null, null); + map.Add(Str("k1"), Str("v1")); + map.Add(Str("k2"), Str("v2")); + + string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4); + + foreach (var line in yaml.Split('\n')) + { + if (line.Length > 0) + { + Assert.StartsWith(" ", line); + } + } + Assert.Contains("k1: v1", yaml); + Assert.Contains("k2: v2", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NoTrailingNewline() + { + var token = Str("hello"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.False(yaml.EndsWith("\n")); + } + } +}