From b43bfde584c7cc5464ec49db31f6fba5477afff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:25:59 +0000 Subject: [PATCH 1/3] Keep the enclosing indent on multi-line expression bodies PropertyTemplate.ExpressionBodyFactory and AccessorKind.Expression wrote the rendered expression as one string, and IndentedTextWriter only indents at the start of a write, so every line after the first lost the enclosing indent. A factory ending in WriteLine also left the semicolon alone on its own line. Both paths now go through TemplateRendering.WriteExpressionBody. It keeps the first line after the arrow, splices the rest at the current indent, and puts the semicolon on the last line that has content. Fixes #112 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019StKk4VdpfAZ5cdW5X685e --- CodeBlocker.Test/TemplateTests.cs | 99 ++++++++++++++++++++++ CodeBlocker/Templates/AccessorTemplate.cs | 4 +- CodeBlocker/Templates/PropertyTemplate.cs | 4 +- CodeBlocker/Templates/TemplateRendering.cs | 49 +++++++++++ 4 files changed, 150 insertions(+), 6 deletions(-) diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index 4b809ef..ecf6849 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -211,6 +211,105 @@ public int Count Render(property)); } + [TestMethod] + public void AMultiLineExpressionBodiedPropertyKeepsTheEnclosingIndent() + { + ClassTemplate type = new() + { + Name = "C", + Keywords = { "public" }, + Members = + { + new PropertyTemplate + { + Type = "int", + Name = "X", + Keywords = { "public" }, + ExpressionBodyFactory = codeBlocker => + { + codeBlocker.WriteLine("k switch"); + codeBlocker.WriteLine("{"); + codeBlocker.Indent(); + codeBlocker.WriteLine("_ => 0,"); + codeBlocker.Outdent(); + codeBlocker.Write("}"); + }, + }, + }, + }; + + Assert.AreEqual( + """ + public class C + { + public int X => k switch + { + _ => 0, + }; + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void AMultiLineExpressionBodiedAccessorKeepsTheEnclosingIndent() + { + ClassTemplate type = new() + { + Name = "C", + Keywords = { "public" }, + Members = + { + new PropertyTemplate + { + Type = "int[]", + Name = "Items", + Keywords = { "public" }, + Getter = AccessorTemplate.Expression(codeBlocker => + { + codeBlocker.WriteLine("["); + codeBlocker.Indent(); + codeBlocker.WriteLine("1,"); + codeBlocker.WriteLine("2,"); + codeBlocker.Outdent(); + codeBlocker.WriteLine("]"); + }), + }, + }, + }; + + Assert.AreEqual( + """ + public class C + { + public int[] Items + { + get => [ + 1, + 2, + ]; + } + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void AnExpressionBodyEndingInALineTerminatorKeepsItsSemicolonOnTheLastLine() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Doubled", + Keywords = { "public" }, + ExpressionBodyFactory = codeBlocker => codeBlocker.WriteLine("count * 2"), + }; + + Assert.AreEqual("public int Doubled => count * 2;\n", Render(property)); + } + [TestMethod] public void ABlockBodiedAccessorIsBracedAndIndented() { diff --git a/CodeBlocker/Templates/AccessorTemplate.cs b/CodeBlocker/Templates/AccessorTemplate.cs index 5e201d2..f941900 100644 --- a/CodeBlocker/Templates/AccessorTemplate.cs +++ b/CodeBlocker/Templates/AccessorTemplate.cs @@ -91,9 +91,7 @@ public void WriteTo(CodeBlocker codeBlocker, string keyword) case AccessorKind.Expression: codeBlocker.Write(prefix); - codeBlocker.Write(" => "); - codeBlocker.Write(TemplateRendering.RenderFragment(codeBlocker, BodyFactory)); - codeBlocker.WriteLine(";"); + TemplateRendering.WriteExpressionBody(codeBlocker, BodyFactory); break; case AccessorKind.Block: diff --git a/CodeBlocker/Templates/PropertyTemplate.cs b/CodeBlocker/Templates/PropertyTemplate.cs index feebaa5..87260ef 100644 --- a/CodeBlocker/Templates/PropertyTemplate.cs +++ b/CodeBlocker/Templates/PropertyTemplate.cs @@ -52,9 +52,7 @@ public override void WriteTo(CodeBlocker codeBlocker) if (ExpressionBodyFactory is not null) { - codeBlocker.Write(" => "); - codeBlocker.Write(TemplateRendering.RenderFragment(codeBlocker, ExpressionBodyFactory)); - codeBlocker.WriteLine(";"); + TemplateRendering.WriteExpressionBody(codeBlocker, ExpressionBodyFactory); return; } diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs index c54a2c4..cd54317 100644 --- a/CodeBlocker/Templates/TemplateRendering.cs +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -82,6 +82,55 @@ internal static void SpliceFragment(CodeBlocker parent, string fragment) } } + /// + /// Writes an expression body — the arrow, the expression and its terminating semicolon — and + /// ends the line. + /// + /// The to write to. + /// + /// The callback that writes the expression, without => or a semicolon. + /// + /// + /// The first line of the expression stays on the declaration line, after the arrow, and every + /// following line is spliced at the current indent, so a multi-line switch expression, collection + /// expression or initialiser keeps the nesting it was written with. The semicolon goes on the last + /// line that has content, so a factory that ends with a line terminator does not leave it on a + /// line of its own. + /// + internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action? expressionFactory) + { + string[] lines = SplitLines(codeBlocker, RenderFragment(codeBlocker, expressionFactory)); + + int last = lines.Length - 1; + while (last >= 0 && lines[last].Length == 0) + { + last--; + } + + codeBlocker.Write(" => "); + if (last < 0) + { + codeBlocker.WriteLine(";"); + return; + } + + for (int i = 0; i <= last; i++) + { + if (i == last) + { + codeBlocker.WriteLine(lines[i] + ";"); + } + else if (lines[i].Length == 0) + { + codeBlocker.NewLine(); + } + else + { + codeBlocker.WriteLine(lines[i]); + } + } + } + /// /// Writes a parenthesised, comma-separated parameter list. /// From 75e80b8e6eb8b683e84b87b2b62ea42c645102d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:26:26 +0000 Subject: [PATCH 2/3] Move WriteExpressionBody after WriteBody Groups the two body writers together, and keeps this change from touching the same lines as the string-literal escaping in #113. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019StKk4VdpfAZ5cdW5X685e --- CodeBlocker/Templates/TemplateRendering.cs | 98 +++++++++++----------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs index cd54317..2a6d51d 100644 --- a/CodeBlocker/Templates/TemplateRendering.cs +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -82,55 +82,6 @@ internal static void SpliceFragment(CodeBlocker parent, string fragment) } } - /// - /// Writes an expression body — the arrow, the expression and its terminating semicolon — and - /// ends the line. - /// - /// The to write to. - /// - /// The callback that writes the expression, without => or a semicolon. - /// - /// - /// The first line of the expression stays on the declaration line, after the arrow, and every - /// following line is spliced at the current indent, so a multi-line switch expression, collection - /// expression or initialiser keeps the nesting it was written with. The semicolon goes on the last - /// line that has content, so a factory that ends with a line terminator does not leave it on a - /// line of its own. - /// - internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action? expressionFactory) - { - string[] lines = SplitLines(codeBlocker, RenderFragment(codeBlocker, expressionFactory)); - - int last = lines.Length - 1; - while (last >= 0 && lines[last].Length == 0) - { - last--; - } - - codeBlocker.Write(" => "); - if (last < 0) - { - codeBlocker.WriteLine(";"); - return; - } - - for (int i = 0; i <= last; i++) - { - if (i == last) - { - codeBlocker.WriteLine(lines[i] + ";"); - } - else if (lines[i].Length == 0) - { - codeBlocker.NewLine(); - } - else - { - codeBlocker.WriteLine(lines[i]); - } - } - } - /// /// Writes a parenthesised, comma-separated parameter list. /// @@ -243,4 +194,53 @@ internal static void WriteBody(CodeBlocker codeBlocker, Action? bod codeBlocker.WriteLine(); SpliceFragment(codeBlocker, body); } + + /// + /// Writes an expression body — the arrow, the expression and its terminating semicolon — and + /// ends the line. + /// + /// The to write to. + /// + /// The callback that writes the expression, without => or a semicolon. + /// + /// + /// The first line of the expression stays on the declaration line, after the arrow, and every + /// following line is spliced at the current indent, so a multi-line switch expression, collection + /// expression or initialiser keeps the nesting it was written with. The semicolon goes on the last + /// line that has content, so a factory that ends with a line terminator does not leave it on a + /// line of its own. + /// + internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action? expressionFactory) + { + string[] lines = SplitLines(codeBlocker, RenderFragment(codeBlocker, expressionFactory)); + + int last = lines.Length - 1; + while (last >= 0 && lines[last].Length == 0) + { + last--; + } + + codeBlocker.Write(" => "); + if (last < 0) + { + codeBlocker.WriteLine(";"); + return; + } + + for (int i = 0; i <= last; i++) + { + if (i == last) + { + codeBlocker.WriteLine(lines[i] + ";"); + } + else if (lines[i].Length == 0) + { + codeBlocker.NewLine(); + } + else + { + codeBlocker.WriteLine(lines[i]); + } + } + } } From 0c41111fb9cd6bcb0eacc5218b77dee9fb6aaac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:36:13 +0000 Subject: [PATCH 3/3] Cover the blank-line and empty-expression paths of WriteExpressionBody SonarCloud's coverage gate (80% on new code) flagged these two branches as untested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019StKk4VdpfAZ5cdW5X685e --- CodeBlocker.Test/TemplateTests.cs | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index a9633ac..ce27649 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -327,6 +327,49 @@ public void AnExpressionBodyEndingInALineTerminatorKeepsItsSemicolonOnTheLastLin Assert.AreEqual("public int Doubled => count * 2;\n", Render(property)); } + [TestMethod] + public void AMultiLineExpressionBodyKeepsABlankLineWithoutIndentingIt() + { + ClassTemplate type = new() + { + Name = "C", + Keywords = { "public" }, + Members = + { + new PropertyTemplate + { + Type = "int", + Name = "X", + Keywords = { "public" }, + ExpressionBodyFactory = codeBlocker => + { + codeBlocker.WriteLine("a"); + codeBlocker.NewLine(); + codeBlocker.Write("+ b"); + }, + }, + }, + }; + + Assert.AreEqual( + "public class C\n{\n\tpublic int X => a\n\n\t+ b;\n}\n", + Render(type)); + } + + [TestMethod] + public void AnEmptyExpressionBodyStillTerminatesTheDeclaration() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "X", + Keywords = { "public" }, + ExpressionBodyFactory = _ => { }, + }; + + Assert.AreEqual("public int X => ;\n", Render(property)); + } + [TestMethod] public void ABlockBodiedAccessorIsBracedAndIndented() {