Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Treat Blank Lines As Leading/Trailing Trivia #18

Merged
merged 26 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1cd5f57
Treat leading empty newlines as trivia
bash Jun 24, 2024
87eafcf
Promote section header to a proper node
bash Jun 24, 2024
c065f54
Rewrite internal helpers to use PeekRange / Read
bash Jun 24, 2024
ba91956
Separate peek / parse step
bash Jun 24, 2024
da620e9
Document how trivia works
bash Jun 24, 2024
6b18102
Test trailing trivia handling for all nodes
bash Jun 24, 2024
121b7de
Use `PeekNextNodeType` while parsing section children
bash Jun 24, 2024
52b0367
Treat trailing empty newlines as trivia
bash Jun 24, 2024
9294ff2
Rename parameter
bash Jun 24, 2024
ced516e
Fix parsing of section trailing trivia
bash Jun 24, 2024
a08cff7
Auto-generate test data
bash Jun 24, 2024
e98f6f8
Test and fix behaviour of trailing newlines for consecutive nodes
bash Jun 24, 2024
7830dc3
Remove no longer needed detection of empty unrecognized nodes in editing
bash Jun 24, 2024
c7a3896
Rename test
bash Jun 24, 2024
f697aad
Add example as test
bash Jun 24, 2024
fc68bf0
Refactoring
bash Jun 25, 2024
f0d8bd6
Move to separate test class
bash Jun 25, 2024
1691a15
Rename file to match type
bash Jun 25, 2024
7ba45f2
Break up parser into multiple files
bash Jun 25, 2024
626cbdc
Improve naming
bash Jun 25, 2024
81959eb
Use SkipLast
bash Jun 25, 2024
108d877
Using static
bash Jun 25, 2024
58c9e35
Make interface internal
bash Jun 25, 2024
2164772
Prevent accidental instantiations of Epsilon token
bash Jun 25, 2024
f1a08f2
Bump version / update changelog
bash Jun 25, 2024
49fe5b4
Mark API as shipped
bash Jun 25, 2024
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
10 changes: 3 additions & 7 deletions Broccolini.Test/ParserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ public Property ParsesArbitraryComment(string commentValue)
{
var input = $"; {commentValue}";
var document = Parse(input);
return (document.NodesOutsideSection.Count == 1
&& document.NodesOutsideSection.First() is CommentIniNode triviaNode
&& triviaNode.ToString() == input)
return (document.NodesOutsideSection is [CommentIniNode commentNode] && commentNode.ToString() == input)
.ToProperty()
.When(!input.Contains('\r') && !input.Contains('\n'));
}
Expand Down Expand Up @@ -67,16 +65,14 @@ public void ParsesSectionNames(string name, string input)
public bool ParsesArbitrarySectionName(SectionName name, Whitespace ws1, Whitespace ws2, Whitespace ws3, InlineText trailing)
{
var document = Parse($"{ws1.Value}[{ws2.Value}{name.Value}{ws3.Value}]{trailing.Value}");
return (document.Sections.Count == 1
&& document.Sections[0].Name == name.Value);
return document.Sections is [{ Name: var actualName }] && actualName == name.Value;
}

[Property]
public bool ParsesArbitrarySectionNameWithoutClosingBracket(SectionName name, Whitespace ws1, Whitespace ws2, Whitespace ws3)
{
var document = Parse($"{ws1.Value}[{ws2.Value}{name.Value}{ws3.Value}");
return (document.Sections.Count == 1
&& document.Sections[0].Name == name.Value);
return document.Sections is [{ Name: var actualName }] && actualName == name.Value;
}

public static TheoryData<string, string> GetSectionNameData()
Expand Down
2 changes: 1 addition & 1 deletion Broccolini.Test/RoundtripTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static TheoryData<string> PreservesFormattingData()
=> Sequence.Concat(
CommentNodes,
GarbageNodes,
LeadingNodes,
LeadingNodesOrTrivia,
NewLines,
SectionsWithNames.Select(s => s.Input),
KeyValuePairsWithKeyAndValue.Select(s => s.Input)).ToTheoryData();
Expand Down
33 changes: 31 additions & 2 deletions Broccolini.Test/TestData.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Broccolini.Syntax;
using Broccolini.Tokenization;

namespace Broccolini.Test;

internal static class TestData
Expand Down Expand Up @@ -26,9 +29,19 @@ public static IEnumerable<string> LeadingNodes
"; comment\r\n" +
"garbage\r\n",
"[section]\r\n",
"\r\n",
"key = value\r\n");

public static IEnumerable<string> InlineTrivia
=> ["\t", " ", ""];

public static IEnumerable<string> LineBreakingTrivia
=> ContextFreeNewLines
.Concat(ContextFreeNewLines.SelectMany(_ => InlineTrivia, (nl, inline) => $"{nl}{inline}{nl}"))
.Append("");

public static IEnumerable<string> LeadingNodesOrTrivia
=> LeadingNodes.Concat(InlineTrivia.SelectMany(_ => LineBreakingTrivia, (inline, breaking) => inline + breaking));

public static IEnumerable<SectionWithName> SectionsWithNames
=> Sequence.Return(
new SectionWithName("[", string.Empty),
Expand Down Expand Up @@ -70,7 +83,9 @@ public static IEnumerable<KeyValuePairWithKeyAndValue> KeyValuePairsWithKeyAndVa
.SelectMany(VaryLeadingNewLines);
// TODO: vary leading and trailing whitespace and line break

public static IEnumerable<string> NewLines => Sequence.Return("\r\n", "\r", "\n");
public static IEnumerable<string> NewLines => ["\r\n", "\r", "\n"];

public static IEnumerable<string> ContextFreeNewLines => ["\r\n", "\n"];

public static IEnumerable<CaseSensitivityInput> CaseSensitivityInputs
=> Sequence.Return(
Expand All @@ -89,6 +104,15 @@ public static IEnumerable<char> WhiteSpace
.Select(n => (char)n)
.Except(Sequence.Return('\r', '\n'));

public static IEnumerable<ExampleNode> ExampleNodes
=> [
new UnrecognizedIniNode(Tokenizer.Tokenize("garbage")) { NewLine = new IniToken.NewLine("\n") },
new CommentIniNode("comment") { NewLine = new IniToken.NewLine("\n") },
new KeyValueIniNode("key", "value") { NewLine = new IniToken.NewLine("\n") },
new SectionIniNode(new SectionHeaderIniNode("section") { NewLine = new IniToken.NewLine("\n") }, []),
new SectionIniNode(new SectionHeaderIniNode("section") { NewLine = new IniToken.NewLine("\n") }, [new KeyValueIniNode("child-key", "value") { NewLine = new IniToken.NewLine("\n") }]),
];

private static IEnumerable<KeyValuePairWithKeyAndValue> KeyValuePairsWithQuotes
=> Sequence.Return(
new KeyValuePairWithKeyAndValue("\"quoted key\" = \"quoted value\"", "\"quoted key\"", "quoted value"),
Expand Down Expand Up @@ -151,3 +175,8 @@ public sealed record SectionWithName(string Input, string Name);
public sealed record KeyValuePairWithKeyAndValue(string Input, string Key, string Value);

public sealed record CaseSensitivityInput(string Variant1, string Variant2, bool ShouldBeEqual);

public sealed record ExampleNode(IniNode Value)
{
public static implicit operator ExampleNode(IniNode node) => new(node);
}
146 changes: 146 additions & 0 deletions Broccolini.Test/TriviaTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.Diagnostics;
using Broccolini.Editing;
using Broccolini.Syntax;
using FsCheck;
using Xunit;
using static Broccolini.IniParser;
using static Broccolini.Test.TestData;
using static Broccolini.Tokenization.Tokenizer;

namespace Broccolini.Test;

public sealed class TriviaTest
{
[Fact]
public void Example()
{
var input =
"""
\t
\t[section]\t
\t
\tkey=value\t
\t\n
"""
.Replace(@"\t", "\t")
.Replace(@"\n", "\n")
.ReplaceLineEndings("\n");

var sectionHeader = new SectionHeaderIniNode("section")
{
LeadingTrivia = Tokenize("\t"),
TrailingTrivia = Tokenize("\t\n\t"),
NewLine = new IniToken.NewLine("\n"),
};

var keyValue = new KeyValueIniNode("key", "value")
{
LeadingTrivia = Tokenize("\t"),
TrailingTrivia = Tokenize("\t"),
NewLine = new IniToken.NewLine("\n"),
};

var section = new SectionIniNode(sectionHeader, [keyValue])
{
LeadingTrivia = Tokenize("\t\n"),
TrailingTrivia = Tokenize("\t\n"),
};

var expectedDocument = IniDocument.Empty with
{
Sections = [section],
};
Assert.Equal(expectedDocument.ToString(), input); // Sanity check
var parsed = Parse(input);
Assert.Equal(expectedDocument, parsed);
}

[Theory]
[MemberData(nameof(LeadingTriviaData))]
public void RecognizedLeadingWhitespaceAndNewLinesAsTrivia(IniNode node)
{
var expectedDocument = ToIniDocument(node);
var parsedDocument = Parse(node.ToString());
Assert.Equal(expectedDocument, parsedDocument);
}

public static TheoryData<IniNode> LeadingTriviaData()
=> (from node in ExampleNodes
from breaking in LineBreakingTrivia
from inline in InlineTrivia
from inlineBeforeBreaking in InlineTrivia
where (inlineBeforeBreaking.Length == 0) == (breaking.Length == 0)
select ApplyLeadingTrivia(node.Value, inlineBeforeBreaking + breaking, inline)).ToTheoryData();

private static IniNode ApplyLeadingTrivia(IniNode node, string trivia, string inlineTrivia)
=> node switch
{
SectionIniNode section => section with { LeadingTrivia = Tokenize(trivia), Header = section.Header with { LeadingTrivia = Tokenize(inlineTrivia) } },
_ => node with { LeadingTrivia = Tokenize(trivia + inlineTrivia) },
};

[Theory]
[MemberData(nameof(TrailingTriviaData))]
public void RecognizedTrailingWhitespaceAndNewLinesAsTrivia(IniNode node)
{
var expectedDocument = ToIniDocument(node);
var parsedDocument = Parse(expectedDocument.ToString());
Assert.Equal(expectedDocument.ToString(), parsedDocument.ToString()); // Sanity check
Assert.Equal(expectedDocument, parsedDocument);
}

private static TheoryData<IniNode> TrailingTriviaData()
=> (from node in ExampleNodes
from inline in InlineTrivia
from breaking in LineBreakingTrivia
from inlineAfterBreaking in InlineTrivia
where (inlineAfterBreaking.Length == 0) == (breaking.Length == 0)
select ApplyTrailingTrivia(node.Value, inline, breaking + inlineAfterBreaking)).ToTheoryData();

[Theory]
[MemberData(nameof(TriviaForConsecutiveNodes))]
public void RecognizedWhitespaceAndNewLinesAsTriviaForConsecutiveNodes(IniNode a, IniNode b)
{
var expectedDocument = Append(ToIniDocument(a), b);
var parsedDocument = Parse(expectedDocument.ToString());
Assert.Equal(expectedDocument.ToString(), parsedDocument.ToString()); // Sanity check
Assert.Equal(expectedDocument, parsedDocument);
}

private static TheoryData<IniNode, IniNode> TriviaForConsecutiveNodes()
=> (from node1 in ExampleNodes
from node2 in ExampleNodes
from inline in InlineTrivia
from breaking in LineBreakingTrivia
from inlineLeading in InlineTrivia
select (ApplyTrailingTrivia(node1.Value, inline, breaking), ApplyLeadingTrivia(node2.Value, "", inlineLeading))).ToTheoryData();

private static IniNode ApplyTrailingTrivia(IniNode node, string inlineTrivia, string trivia)
=> node switch
{
SectionIniNode section => section with { TrailingTrivia = Tokenize(trivia), Header = section.Header with { TrailingTrivia = Tokenize(inlineTrivia) } },
_ => node with { TrailingTrivia = Tokenize(inlineTrivia + trivia) },
};

private static IniDocument ToIniDocument(IniNode node)
=> Append(IniDocument.Empty, node);

private static IniDocument Append(IniDocument document, IniNode node)
{
document = document.EnsureTrailingNewLine(new IniToken.NewLine("\n"));
return node switch
{
SectionIniNode section => document with { Sections = document.Sections.Add(section) },
SectionChildIniNode child when document.Sections.Any() => AppendToLastSection(document, child),
SectionChildIniNode child => document with { NodesOutsideSection = document.NodesOutsideSection.Add(child) },
_ => throw new UnreachableException(),
};
}

private static IniDocument AppendToLastSection(IniDocument document, SectionChildIniNode node)
{
var lastSection = document.Sections.Last();
var updatedSection = lastSection with { Children = lastSection.Children.Add(node) };
return document with { Sections = document.Sections.SetItem(document.Sections.Count - 1, updatedSection) };
}
}
2 changes: 1 addition & 1 deletion Broccolini/Broccolini.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<PropertyGroup Label="NuGet Packing">
<Version>1.0.1</Version>
<Version>2.0.0-rc.1</Version>
<Description>Broccolini is a non-destructive parser for INI files compatible with GetPrivateProfileString.</Description>
<Authors>Tau Gärtli</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
2 changes: 1 addition & 1 deletion Broccolini/Compatibility/DistinctBy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace System.Linq;

internal static class EnumerableCompatibility
internal static partial class EnumerableCompatibility
{
public static IEnumerable<TSource> DistinctBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IEqualityComparer<TKey>? comparer = null)
{
Expand Down
35 changes: 35 additions & 0 deletions Broccolini/Compatibility/SkipLast.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if NETSTANDARD2_0
// Source: https://github.com/dotnet/runtime/blob/v5.0.18/src/libraries/System.Linq/src/System/Linq/Skip.cs

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Linq;

internal static partial class EnumerableCompatibility
{
public static IEnumerable<TSource> SkipLast<TSource>(this IEnumerable<TSource> source, int count)
{
var queue = new Queue<TSource>();
using IEnumerator<TSource> e = source.GetEnumerator();
while (e.MoveNext())
{
if (queue.Count == count)
{
do
{
yield return queue.Dequeue();
queue.Enqueue(e.Current);
}
while (e.MoveNext());
break;
}
else
{
queue.Enqueue(e.Current);
}
}
}
}

#endif
17 changes: 2 additions & 15 deletions Broccolini/Editing/EditingExtensions.Section.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,7 @@ public static SectionIniNode RemoveKeyValue(this SectionIniNode sectionNode, str

private static SectionIniNode AppendChild(this SectionIniNode sectionNode, SectionChildIniNode node)
{
return sectionNode.Children.TryFindIndex(IsBlank, out var index)
? InsertAtIndex(index)
: AppendTrailing();

SectionIniNode InsertAtIndex(int childIndex)
=> sectionNode with { Children = sectionNode.Children.Insert(childIndex, node.EnsureTrailingNewLine(sectionNode.DetectNewLine())) };

SectionIniNode AppendTrailing()
{
var sectionWithNewLine = sectionNode.EnsureTrailingNewLine(sectionNode.DetectNewLine());
return sectionWithNewLine with { Children = sectionWithNewLine.Children.Add(node) };
}

static bool IsBlank(SectionChildIniNode node)
=> node is UnrecognizedIniNode unrecognizedNode && unrecognizedNode.IsBlank();
var sectionWithNewLine = sectionNode.EnsureTrailingNewLine(sectionNode.DetectNewLine());
return sectionWithNewLine with { Children = sectionWithNewLine.Children.Add(node) };
}
}
3 changes: 2 additions & 1 deletion Broccolini/Editing/NewlineDetectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ internal static class NewlineDetectionExtensions

public static IniToken.NewLine DetectNewLine(this IniDocument document)
=> document.GetNodes()
.OfType<IIniNodeWithNewLine>()
.Select(n => n.NewLine)
.FirstOrDefault()
?? NativeNewLine;

public static IniToken.NewLine DetectNewLine(this SectionIniNode node)
=> node.NewLine ?? node.NewLineHint ?? NativeNewLine;
=> node.Header.NewLine ?? node.NewLineHint ?? NativeNewLine;
}
2 changes: 1 addition & 1 deletion Broccolini/Editing/NewlineExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static SectionIniNode EnsureTrailingNewLine(this SectionIniNode node, Ini
=> node switch
{
{ Children: { Count: >=1 } children } => node with { Children = children.ReplaceLast(n => EnsureTrailingNewLine(n, newLine)) },
{ Children.Count: 0, NewLine: null } => node with { NewLine = newLine },
{ Children.Count: 0, Header: { NewLine: null } header } => node with { Header = header with { NewLine = newLine } },
_ => node,
};

Expand Down
34 changes: 34 additions & 0 deletions Broccolini/Parsing/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Broccolini.Syntax;

namespace Broccolini.Parsing;

internal static class EnumerableExtensions
{
public static IniToken FirstOrEpsilon(this IEnumerable<IniToken> tokens)
=> tokens.FirstOrDefault() ?? IniToken.Epsilon.Instance;

public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source, TSource @default)
=> source.Append(@default).First();

public static IEnumerable<TSource> DropLast<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
=> source.WithLast().Where(x => !x.IsLast || !predicate(x.Item)).Select(x => x.Item);

private static IEnumerable<(TSource Item, bool IsLast)> WithLast<TSource>(this IEnumerable<TSource> source)
{
using var enumerator = source.GetEnumerator();

if (!enumerator.MoveNext())
{
yield break;
}

var current = enumerator.Current;
while (enumerator.MoveNext())
{
yield return (current, false);
current = enumerator.Current;
}

yield return (current, true);
}
}
6 changes: 6 additions & 0 deletions Broccolini/Parsing/IParserInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ namespace Broccolini.Parsing;

internal interface IParserInput
{
int AvailableLength { get; }

IniToken Peek(int lookAhead = 0);

IEnumerable<IniToken> PeekRange();

IniToken Read();

ImmutableArray<IniToken> Read(IEnumerable<IniToken> peeked);
}
Loading