Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/Nullean.Curb.Core/Documents/Doc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ internal enum LineType : byte

/// <summary>
/// Always a newline, emitted at column 0 with no indent. Used inside verbatim and raw string
/// literals, whose content must not be re-indented.
/// literals, whose content must not be re-indented. <c>B</c> picks the ending — see
/// <see cref="Doc.ConfiguredEnding"/>.
/// </summary>
Literal = 3,
}
Expand Down Expand Up @@ -169,6 +170,23 @@ internal readonly struct Doc
/// <summary>Sentinel for <see cref="DocKind.Indent"/> meaning "dedent to column 0".</summary>
public const int IndentToRoot = int.MinValue;

/// <summary>
/// <c>B</c> on a <see cref="LineType.Literal"/> line: write the configured <c>end_of_line</c>.
/// </summary>
/// <remarks>
/// The default, and what a break between two lines of a comment, a doc comment or a disabled
/// <c>#if</c> branch wants: those newlines are layout, so normalising them is the whole point.
/// A newline that is part of a string literal's value is not layout, and asks for one of the two
/// below instead — rewriting it changes what the program computes.
/// </remarks>
public const int ConfiguredEnding = 0;

/// <summary><c>B</c> on a <see cref="LineType.Literal"/> line: write <c>\n</c> whatever the configuration says.</summary>
public const int LfEnding = 1;

/// <summary><c>B</c> on a <see cref="LineType.Literal"/> line: write <c>\r\n</c> whatever the configuration says.</summary>
public const int CrLfEnding = 2;

public readonly DocKind Kind;
public readonly DocFlags Flags;

Expand Down
11 changes: 11 additions & 0 deletions src/Nullean.Curb.Core/Documents/DocArena.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ public void ExternalText(string text)
/// <summary>Always a newline emitted at column 0, for content that must not be re-indented.</summary>
public void LiteralLine(DocFlags flags = DocFlags.None) => AddLine(LineType.Literal, flags);

/// <summary>
/// A literal line that reproduces the ending the source had here rather than the configured one.
/// </summary>
/// <remarks>
/// For newlines that are part of a string literal's value. A raw or verbatim string's line
/// endings are content: rewriting them changes the string the program builds, and changes the
/// token's own text, which is why the re-parse comparer reports it as a changed token.
/// </remarks>
public void SourceLine(bool crLf) =>
Add(new Doc(DocKind.Line, a: (int)LineType.Literal, b: crLf ? Doc.CrLfEnding : Doc.LfEnding));

/// <summary>
/// A newline, but only when the group <paramref name="groupId"/> names ended up broken.
/// </summary>
Expand Down
7 changes: 6 additions & 1 deletion src/Nullean.Curb.Core/Documents/DocDumper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ private static void Write(DocArena arena, ReadOnlySpan<char> source, StringBuild
LineType.Normal => "line",
LineType.Soft => "softline",
LineType.Hard => "hardline",
LineType.Literal => "literalline",
LineType.Literal => doc.B switch
{
Doc.LfEnding => "literalline lf",
Doc.CrLfEnding => "literalline crlf",
_ => "literalline",
},
_ => "line?",
});
AppendFlags(output, doc.Flags);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ private static void VerbatimContent(SyntaxNode node, PrintContext context)
if (first.RawKind != 0)
TokenPrinter.PrintLeadingTrivia(first, context);

// preserveLineEndings: the run is a string literal, so its newlines are part of the value.
var span = node.Span;
TokenPrinter.EmitVerbatimRange(context, span.Start, span.Length);
TokenPrinter.EmitVerbatimRange(context, span.Start, span.Length, preserveLineEndings: true);

if (last.RawKind != 0)
TokenPrinter.PrintTrailingTrivia(last, context);
Expand Down
23 changes: 19 additions & 4 deletions src/Nullean.Curb.Core/Printing/CSharp/TokenPrinter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,16 @@ private static void EmitVerbatimBlock(SyntaxTrivia trivia, PrintContext context)
/// Emits <paramref name="length"/> characters from <paramref name="start"/> exactly as written,
/// splitting on newlines so that the printer does not re-indent them.
/// </summary>
internal static void EmitVerbatimRange(PrintContext context, int start, int length)
/// <remarks>
/// <paramref name="preserveLineEndings"/> says the newlines in the range belong to the content
/// rather than to the layout, which is the case inside a string literal: an interpolated string
/// is printed as one verbatim run, and its line endings are characters of the value it builds.
/// Re-issuing them as the configured <c>end_of_line</c> rewrites the literal — an LF raw string
/// in a file formatted to CRLF came back with its string changed, and the re-parse comparer
/// reported it as a changed token (issue #85). Comments and disabled <c>#if</c> branches want the
/// opposite, and leave it false so their endings normalise with everything else.
/// </remarks>
internal static void EmitVerbatimRange(PrintContext context, int start, int length, bool preserveLineEndings = false)
{
var source = context.Text;
var arena = context.Arena;
Expand All @@ -568,13 +577,19 @@ internal static void EmitVerbatimRange(PrintContext context, int start, int leng
continue;

var lineLength = i - lineStart;
// Drop a \r that belongs to this \n; the printer emits the configured line ending.
if (lineLength > 0 && source[lineStart + lineLength - 1] == '\r')
// Drop a \r that belongs to this \n; the printer, not the text leaf, emits the ending.
var crLf = lineLength > 0 && source[lineStart + lineLength - 1] == '\r';
if (crLf)
lineLength--;

if (lineLength > 0)
arena.SourceText(lineStart, lineLength);
arena.LiteralLine();

if (preserveLineEndings)
arena.SourceLine(crLf);
else
arena.LiteralLine();

lineStart = i + 1;
}

Expand Down
41 changes: 10 additions & 31 deletions src/Nullean.Curb.Core/Printing/DocPrinter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,7 @@ public void Print(DocArena arena, ReadOnlyMemory<char> source, in FormatOptions
_depth = 0;
_lastSourceEnd = -1;
_insideLineComment = false;

// Verbatim runs are re-emitted line by line with the configured ending, so when that differs
// from the source's own ending the content of a multi-line string literal changes. The
// boundary tracking below cannot see that — but only files that actually contain a verbatim
// or raw string literal are at risk, so check for the relevant delimiters before forcing a
// round-trip parse.
var sourceNewLine = source.Span.IndexOf('\n');
var sourceUsesCrLf = sourceNewLine > 0 && source.Span[sourceNewLine - 1] == '\r';
RoundTripAtRisk = sourceNewLine >= 0 && sourceUsesCrLf != (_endOfLine == "\r\n")
&& HasVerbatimOrRawString(source.Span);
RoundTripAtRisk = false;

_breaks.Run(arena);
EnsureGroupModes(arena.Count);
Expand Down Expand Up @@ -368,7 +359,15 @@ private void PrintLine(in Doc doc, in Scope scope)
// indent because whatever it sits inside (a raw string) owns its own leading whitespace.
if (type == LineType.Literal)
{
_output.Append(_endOfLine);
// B says whose ending this is. A break between two lines of a comment or a disabled #if
// branch takes the configured one; a newline inside a string literal keeps the source's,
// because there it is a character of the value rather than layout.
_output.Append(doc.B switch
{
Doc.LfEnding => "\n",
Doc.CrLfEnding => "\r\n",
_ => _endOfLine,
});
_column = 0;
_insideLineComment = false;
return;
Expand Down Expand Up @@ -474,26 +473,6 @@ private void TrackRoundTripRisk(in Doc doc, ReadOnlySpan<char> text)
RoundTripAtRisk = true;
}

/// <summary>
/// Returns true when the source contains at least one verbatim or raw string delimiter.
/// Only such strings have their line endings rewritten by the verbatim-run emitter, so only
/// they can have their token text changed when the configured line ending differs from the
/// source's own.
/// </summary>
private static bool HasVerbatimOrRawString(ReadOnlySpan<char> source)
{
// Fast exit: no double-quote means no string literals at all.
if (source.IndexOf('"') < 0)
return false;

// @"..." and $@"..." both contain the two-character sequence @" (in $@" it appears at
// offset 1), so one search covers both. @$"..." has @ followed by $ followed by ", so
// it requires its own check. Raw string literals start with """.
return source.Contains("@\"", StringComparison.Ordinal)
|| source.Contains("@$\"", StringComparison.Ordinal)
|| source.Contains("\"\"\"", StringComparison.Ordinal);
}

private void Emit(ReadOnlySpan<char> text, DocFlags flags, bool suppressWidth)
{
_output.Append(text);
Expand Down
24 changes: 24 additions & 0 deletions tests/Nullean.Curb.Tests/Formatting/Options/CoreOptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,30 @@ public Task Unset_end_of_line_matches_the_platform() => FormatsExactly(
$"public class C{Environment.NewLine}{{{Environment.NewLine}}}{Environment.NewLine}",
editorConfig: "end_of_line = auto");

// A string literal's newlines are characters of its value, not layout, so end_of_line does not
// reach inside one. Re-issuing them with the configured ending changed both the string the
// program builds and the token's own text, which made the file fail its own round-trip check
// (issue #85). Interpolated strings are the case that regressed: they print as one verbatim run
// rather than as a single token, so they took the line-splitting path the others never did.

[Test]
public Task An_interpolated_raw_string_keeps_its_own_line_endings() => FormatsExactly(
"class A\n{\n string M(string x)\n {\n return $\"\"\"\n <a>{x}</a>\n \"\"\";\n }\n}",
"class A\r\n{\r\n string M(string x)\r\n {\r\n return $\"\"\"\n <a>{x}</a>\n \"\"\";\r\n }\r\n}\r\n",
editorConfig: "end_of_line = crlf");

[Test]
public Task An_interpolated_verbatim_string_keeps_its_own_line_endings() => FormatsExactly(
"class A\r\n{\r\n string M(int x)\r\n {\r\n return $@\"one{x}\r\ntwo\";\r\n }\r\n}",
"class A\n{\n string M(int x)\n {\n return $@\"one{x}\r\ntwo\";\n }\n}\n",
editorConfig: "end_of_line = lf");

[Test]
public Task A_raw_string_keeps_its_own_line_endings() => FormatsExactly(
"class A\n{\n string M()\n {\n return \"\"\"\n <a>b</a>\n \"\"\";\n }\n}",
"class A\r\n{\r\n string M()\r\n {\r\n return \"\"\"\n <a>b</a>\n \"\"\";\r\n }\r\n}\r\n",
editorConfig: "end_of_line = crlf");

// ---- insert_final_newline ------------------------------------------------------------------

[Test]
Expand Down
31 changes: 25 additions & 6 deletions tests/Nullean.Curb.Tests/Printing/RoundTripRiskTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,39 @@ public async Task Does_not_fire_when_a_line_comment_is_closed_by_a_break()
}

[Test]
public async Task Fires_when_line_endings_are_being_rewritten()
public async Task Does_not_fire_merely_because_the_file_holds_a_multi_line_literal()
{
// Verbatim string content is re-emitted with the configured ending, which changes the value
// of a multi-line literal. Only files that actually contain a verbatim or raw string need
// the second parse — the guard prevents unnecessary re-parses on files that have none.
// A verbatim string's newlines are content, and DocArena.SourceLine now reproduces them, so
// a file whose endings differ from the configured one no longer has its literals rewritten
// and no longer owes a second parse for that reason alone. Before that, every mixed-ending
// file containing @" or """ paid for one.
var arena = new DocArena();
arena.SourceText(0, 3);

using var output = new OutputBuffer();
var printer = new DocPrinter();
// Source contains @" so HasVerbatimOrRawString fires; CRLF source with LF target triggers the risk.
printer.Print(arena, "@\"a\r\nb\"".AsMemory(), new FormatOptions { EndOfLine = EndOfLine.Lf }, output);

printer.RoundTripAtRisk.Should().BeTrue();
printer.RoundTripAtRisk.Should().BeFalse();
await Task.CompletedTask;
}

[Test]
public async Task A_source_line_keeps_its_own_ending_rather_than_the_configured_one()
{
var arena = new DocArena();
arena.SourceText(0, 3);
arena.SourceLine(crLf: true);
arena.SourceText(4, 3);
arena.SourceLine(crLf: false);
arena.SourceText(8, 1);

using var output = new OutputBuffer();
var printer = new DocPrinter();
printer.Print(arena, Source.AsMemory(), Options, output);

// Options say LF; both endings come from the document, not from the configuration.
output.ToString().Should().Be("out\r\nvar\na");
await Task.CompletedTask;
}

Expand Down
Loading