diff --git a/CHANGELOG.md b/CHANGELOG.md index d739a358b..30e408f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ This document is formatted according to the principles of [Keep A CHANGELOG](htt - [cpp] namespace was changed to 'cucumber::gherkin' to better reflect project structure and prevent clashing - [.NET] Removed dependency on System.Text.Json and related logic in GherkinDialectProvider - [Elixir] Updates dependencies, bumps messages to 27.0.2 +- [.NET] Changed some types from class to struct, removed IGherkinLine interface and changes some functions from Array to Enumerable ### Removed - [Python] Dropped support for Python 3.8 diff --git a/dotnet/Gherkin.Specs/EventStubs/GherkinEventsProvider.cs b/dotnet/Gherkin.Specs/EventStubs/GherkinEventsProvider.cs index 5621dd3bd..3450f6933 100644 --- a/dotnet/Gherkin.Specs/EventStubs/GherkinEventsProvider.cs +++ b/dotnet/Gherkin.Specs/EventStubs/GherkinEventsProvider.cs @@ -69,7 +69,7 @@ private void AddParseError(List events, ParserException e, String uri) Message = e.Message, Source = new SourceReference() { - Location = new Location(e.Location.Column, e.Location.Line), + Location = e.Location.HasValue ? new Location(e.Location.GetValueOrDefault().Column, e.Location.GetValueOrDefault().Line) : null, Uri = uri } } diff --git a/dotnet/Gherkin.Specs/Tokens/TestTokenFormatter.cs b/dotnet/Gherkin.Specs/Tokens/TestTokenFormatter.cs index 1f55aea84..ac69a06a9 100644 --- a/dotnet/Gherkin.Specs/Tokens/TestTokenFormatter.cs +++ b/dotnet/Gherkin.Specs/Tokens/TestTokenFormatter.cs @@ -7,7 +7,8 @@ public string FormatToken(Token token) if (token.IsEOF) return "EOF"; - string stepTypeText; + string stepTypeText = string.Empty; + string matchedItemsText = null; switch (token.MatchedType) { case TokenType.FeatureLine: @@ -22,13 +23,14 @@ public string FormatToken(Token token) var tokenType = token.MatchedGherkinDialect.GetStepKeywordType(token.MatchedKeyword); stepTypeText = $"({tokenType})"; break; - default: - stepTypeText = ""; + case TokenType.TagLine: + matchedItemsText = string.Join(",", token.Line.GetTags().Select(i => i.Column + ":" + i.Text)); + break; + case TokenType.TableRow: + matchedItemsText = string.Join(",", token.Line.GetTableCells().Select(i => i.Column + ":" + i.Text)); break; } - var matchedItemsText = token.MatchedItems == null ? "" : string.Join(",", token.MatchedItems.Select(i => i.Column + ":" + i.Text)); - return $"({token.Location.Line}:{token.Location.Column}){token.MatchedType}:{stepTypeText}{token.MatchedKeyword}/{token.MatchedText}/{matchedItemsText}"; } } diff --git a/dotnet/Gherkin/Ast/Background.cs b/dotnet/Gherkin/Ast/Background.cs index d481b9308..b8ba89001 100644 --- a/dotnet/Gherkin/Ast/Background.cs +++ b/dotnet/Gherkin/Ast/Background.cs @@ -1,4 +1,4 @@ namespace Gherkin.Ast; -public class Background(Location location, string keyword, string name, string description, Step[] steps) +public class Background(Location location, string keyword, string name, string description, IEnumerable steps) : StepsContainer(location, keyword, name, description, steps); \ No newline at end of file diff --git a/dotnet/Gherkin/Ast/DataTable.cs b/dotnet/Gherkin/Ast/DataTable.cs index 3b9692416..ca2d4834c 100644 --- a/dotnet/Gherkin/Ast/DataTable.cs +++ b/dotnet/Gherkin/Ast/DataTable.cs @@ -5,10 +5,10 @@ public class DataTable : StepArgument, IHasRows, IHasLocation public Location Location { get; private set; } public IEnumerable Rows { get; private set; } - public DataTable(TableRow[] rows) + public DataTable(List rows) { if (rows == null) throw new ArgumentNullException("rows"); - if (rows.Length == 0) throw new ArgumentException("DataTable must have at least one row", "rows"); + if (rows.Count == 0) throw new ArgumentException("DataTable must have at least one row", "rows"); Location = rows[0].Location; Rows = rows; diff --git a/dotnet/Gherkin/Ast/Examples.cs b/dotnet/Gherkin/Ast/Examples.cs index 8520886b0..cd5f24c76 100644 --- a/dotnet/Gherkin/Ast/Examples.cs +++ b/dotnet/Gherkin/Ast/Examples.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class Examples(Tag[] tags, Location location, string keyword, string name, string description, TableRow header, TableRow[] body) +public class Examples(IEnumerable tags, Location location, string keyword, string name, string description, TableRow header, IEnumerable body) : IHasLocation, IHasDescription, IHasRows, IHasTags { public IEnumerable Tags { get; } = tags; diff --git a/dotnet/Gherkin/Ast/Feature.cs b/dotnet/Gherkin/Ast/Feature.cs index 22676e205..85bdfa6dc 100644 --- a/dotnet/Gherkin/Ast/Feature.cs +++ b/dotnet/Gherkin/Ast/Feature.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class Feature(Tag[] tags, Location location, string language, string keyword, string name, string description, IHasLocation[] children) +public class Feature(IEnumerable tags, Location location, string language, string keyword, string name, string description, IEnumerable children) : IHasLocation, IHasDescription, IHasTags, IHasChildren { public IEnumerable Tags { get; } = tags; diff --git a/dotnet/Gherkin/Ast/GherkinDocument.cs b/dotnet/Gherkin/Ast/GherkinDocument.cs index 87c7e7b54..3ed039d1f 100644 --- a/dotnet/Gherkin/Ast/GherkinDocument.cs +++ b/dotnet/Gherkin/Ast/GherkinDocument.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class GherkinDocument(Feature feature, Comment[] comments) +public class GherkinDocument(Feature feature, IEnumerable comments) { public Feature Feature { get; } = feature; public IEnumerable Comments { get; } = comments; diff --git a/dotnet/Gherkin/Ast/Location.cs b/dotnet/Gherkin/Ast/Location.cs index 3a8e210d1..95d7db27d 100644 --- a/dotnet/Gherkin/Ast/Location.cs +++ b/dotnet/Gherkin/Ast/Location.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class Location(int line = 0, int column = 0) +public readonly struct Location(int line = 0, int column = 0) { public int Line { get; } = line; public int Column { get; } = column; diff --git a/dotnet/Gherkin/Ast/Rule.cs b/dotnet/Gherkin/Ast/Rule.cs index fc9905ef8..f89e4d50d 100644 --- a/dotnet/Gherkin/Ast/Rule.cs +++ b/dotnet/Gherkin/Ast/Rule.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class Rule(Tag[] tags, Location location, string keyword, string name, string description, IHasLocation[] children) +public class Rule(IEnumerable tags, Location location, string keyword, string name, string description, IEnumerable children) : IHasLocation, IHasDescription, IHasChildren, IHasTags { public Location Location { get; } = location; diff --git a/dotnet/Gherkin/Ast/Scenario.cs b/dotnet/Gherkin/Ast/Scenario.cs index be0cfb19a..b587101f8 100644 --- a/dotnet/Gherkin/Ast/Scenario.cs +++ b/dotnet/Gherkin/Ast/Scenario.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class Scenario(Tag[] tags, Location location, string keyword, string name, string description, Step[] steps, Examples[] examples) +public class Scenario(IEnumerable tags, Location location, string keyword, string name, string description, IEnumerable steps, IEnumerable examples) : StepsContainer(location, keyword, name, description, steps), IHasTags { public IEnumerable Tags { get; } = tags; diff --git a/dotnet/Gherkin/Ast/StepsContainer.cs b/dotnet/Gherkin/Ast/StepsContainer.cs index ec6985ddb..e84e08bd2 100644 --- a/dotnet/Gherkin/Ast/StepsContainer.cs +++ b/dotnet/Gherkin/Ast/StepsContainer.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public abstract class StepsContainer(Location location, string keyword, string name, string description, Step[] steps) +public abstract class StepsContainer(Location location, string keyword, string name, string description, IEnumerable steps) : IHasLocation, IHasDescription, IHasSteps { public Location Location { get; } = location; diff --git a/dotnet/Gherkin/Ast/TableCell.cs b/dotnet/Gherkin/Ast/TableCell.cs index 3fc34800d..81db9d192 100644 --- a/dotnet/Gherkin/Ast/TableCell.cs +++ b/dotnet/Gherkin/Ast/TableCell.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class TableCell(Location location, string value) : IHasLocation +public readonly struct TableCell(Location location, string value) : IHasLocation { public Location Location { get; } = location; public string Value { get; } = value; diff --git a/dotnet/Gherkin/Ast/TableRow.cs b/dotnet/Gherkin/Ast/TableRow.cs index cdb7d55d9..315f0af3f 100644 --- a/dotnet/Gherkin/Ast/TableRow.cs +++ b/dotnet/Gherkin/Ast/TableRow.cs @@ -1,6 +1,6 @@ namespace Gherkin.Ast; -public class TableRow(Location location, TableCell[] cells) : IHasLocation +public class TableRow(Location location, IEnumerable cells) : IHasLocation { public Location Location { get; } = location; public IEnumerable Cells { get; } = cells; diff --git a/dotnet/Gherkin/AstBuilder.cs b/dotnet/Gherkin/AstBuilder.cs index 81f3f3318..176895713 100644 --- a/dotnet/Gherkin/AstBuilder.cs +++ b/dotnet/Gherkin/AstBuilder.cs @@ -92,7 +92,7 @@ private object GetTransformedNode(AstNode node) var description = GetDescription(scenarioNode); var steps = GetSteps(scenarioNode); - var examples = scenarioNode.GetItems(RuleType.ExamplesDefinition).ToArray(); + List examples = [.. scenarioNode.GetItems(RuleType.ExamplesDefinition)]; return CreateScenario(tags, GetLocation(scenarioLine), scenarioLine.MatchedKeyword, scenarioLine.MatchedText, description, steps, examples, node); } case RuleType.ExamplesDefinition: @@ -102,9 +102,9 @@ private object GetTransformedNode(AstNode node) var examplesLine = examplesNode.GetToken(TokenType.ExamplesLine); var description = GetDescription(examplesNode); - var allRows = examplesNode.GetSingle(RuleType.ExamplesTable); - var header = allRows != null ? allRows.First() : null; - var rows = allRows != null ? allRows.Skip(1).ToArray() : null; + var allRows = examplesNode.GetSingle>(RuleType.ExamplesTable); + var header = allRows != null ? allRows[0] : null; + var rows = allRows != null ? allRows.Skip(1).ToList() : null; return CreateExamples(tags, GetLocation(examplesLine), examplesLine.MatchedKeyword, examplesLine.MatchedText, description, header, rows, node); } case RuleType.ExamplesTable: @@ -113,7 +113,7 @@ private object GetTransformedNode(AstNode node) } case RuleType.Description: { - var lineTokens = node.GetTokens(TokenType.Other); + IEnumerable lineTokens = node.GetTokens(TokenType.Other); // Trim trailing empty lines lineTokens = lineTokens.Reverse().SkipWhile(t => string.IsNullOrWhiteSpace(t.MatchedText)).Reverse(); @@ -130,16 +130,16 @@ private object GetTransformedNode(AstNode node) var children = new List(); var background = node.GetSingle(RuleType.Background); if (background != null) - { children.Add(background); - } - var childrenEnumerable = children.Concat(node.GetItems(RuleType.ScenarioDefinition)) - .Concat(node.GetItems(RuleType.Rule)); + foreach (var scenarioDefinition in node.GetItems(RuleType.ScenarioDefinition)) + children.Add(scenarioDefinition); + foreach (var rule in node.GetItems(RuleType.Rule)) + children.Add(rule); var description = GetDescription(header); if (featureLine.MatchedGherkinDialect == null) return null; var language = featureLine.MatchedGherkinDialect.Language; - return CreateFeature(tags, GetLocation(featureLine), language, featureLine.MatchedKeyword, featureLine.MatchedText, description, childrenEnumerable.ToArray(), node); + return CreateFeature(tags, GetLocation(featureLine), language, featureLine.MatchedKeyword, featureLine.MatchedText, description, children, node); } case RuleType.Rule: { @@ -151,14 +151,13 @@ private object GetTransformedNode(AstNode node) var children = new List(); var background = node.GetSingle(RuleType.Background); if (background != null) - { children.Add(background); - } - var childrenEnumerable = children.Concat(node.GetItems(RuleType.ScenarioDefinition)); + foreach (var scenarioDefinition in node.GetItems(RuleType.ScenarioDefinition)) + children.Add(scenarioDefinition); var description = GetDescription(header); if (ruleLine.MatchedGherkinDialect == null) return null; - return CreateRule(tags, GetLocation(ruleLine), ruleLine.MatchedKeyword, ruleLine.MatchedText, description, childrenEnumerable.ToArray(), node); + return CreateRule(tags, GetLocation(ruleLine), ruleLine.MatchedKeyword, ruleLine.MatchedText, description, children, node); } case RuleType.GherkinDocument: { @@ -179,12 +178,12 @@ protected virtual StepKeywordType GetKeywordType(Token stepLine) return stepKeywordType.Value; } - protected virtual Background CreateBackground(Location location, string keyword, string name, string description, Step[] steps, AstNode node) + protected virtual Background CreateBackground(Location location, string keyword, string name, string description, IEnumerable steps, AstNode node) { return new Background(location, keyword, name, description, steps); } - protected virtual DataTable CreateDataTable(TableRow[] rows, AstNode node) + protected virtual DataTable CreateDataTable(List rows, AstNode node) { return new DataTable(rows); } @@ -194,12 +193,12 @@ protected virtual Comment CreateComment(Location location, string text) return new Comment(location, text); } - protected virtual Examples CreateExamples(Tag[] tags, Location location, string keyword, string name, string description, TableRow header, TableRow[] body, AstNode node) + protected virtual Examples CreateExamples(IEnumerable tags, Location location, string keyword, string name, string description, TableRow header, IEnumerable body, AstNode node) { return new Examples(tags, location, keyword, name, description, header, body); } - protected virtual Scenario CreateScenario(Tag[] tags, Location location, string keyword, string name, string description, Step[] steps, Examples[] examples, AstNode node) + protected virtual Scenario CreateScenario(IEnumerable tags, Location location, string keyword, string name, string description, IEnumerable steps, IEnumerable examples, AstNode node) { return new Scenario(tags, location, keyword, name, description, steps, examples); } @@ -214,17 +213,17 @@ protected virtual Step CreateStep(Location location, string keyword, StepKeyword return new Step(location, keyword, keywordType, text, argument); } - protected virtual GherkinDocument CreateGherkinDocument(Feature feature, Comment[] gherkinDocumentComments, AstNode node) + protected virtual GherkinDocument CreateGherkinDocument(Feature feature, IEnumerable gherkinDocumentComments, AstNode node) { return new GherkinDocument(feature, gherkinDocumentComments); } - protected virtual Feature CreateFeature(Tag[] tags, Location location, string language, string keyword, string name, string description, IHasLocation[] children, AstNode node) + protected virtual Feature CreateFeature(IEnumerable tags, Location location, string language, string keyword, string name, string description, IEnumerable children, AstNode node) { return new Feature(tags, location, language, keyword, name, description, children); } - protected virtual Rule CreateRule(Tag[] tags, Location location, string keyword, string name, string description, IHasLocation[] children, AstNode node) + protected virtual Rule CreateRule(IEnumerable tags, Location location, string keyword, string name, string description, IEnumerable children, AstNode node) { return new Rule(tags, location, keyword, name, description, children); } @@ -234,17 +233,17 @@ protected virtual Tag CreateTag(Location location, string name, AstNode node) return new Tag(location, name); } - protected virtual Location CreateLocation(int line, int column) + protected Location CreateLocation(int line, int column) { return new Location(line, column); } - protected virtual TableRow CreateTableRow(Location location, TableCell[] cells, AstNode node) + protected virtual TableRow CreateTableRow(Location location, IEnumerable cells, AstNode node) { return new TableRow(location, cells); } - protected virtual TableCell CreateTableCell(Location location, string value) + protected TableCell CreateTableCell(Location location, string value) { return new TableCell(location, value); } @@ -254,39 +253,44 @@ private Location GetLocation(Token token, int column = 0) return column == 0 ? token.Location : CreateLocation(token.Location.Line, column); } - private Tag[] GetTags(AstNode node) + private IEnumerable GetTags(AstNode node) { var tagsNode = node.GetSingle(RuleType.Tags); if (tagsNode == null) return []; - return tagsNode.GetTokens(TokenType.TagLine) - .SelectMany(t => t.MatchedItems, (t, tagItem) => - CreateTag(GetLocation(t, tagItem.Column), tagItem.Text, tagsNode)) - .ToArray(); - } - - private TableRow[] GetTableRows(AstNode node) - { - var rows = node.GetTokens(TokenType.TableRow).Select(token => CreateTableRow(GetLocation(token), GetCells(token), node)).ToArray(); - CheckCellCountConsistency(rows); - return rows; + var tags = new List(); + foreach (var line in tagsNode.GetTokens(TokenType.TagLine)) + { + foreach (var matchedItem in line.Line.GetTags()) + tags.Add(CreateTag(GetLocation(line, matchedItem.Column), matchedItem.Text, tagsNode)); + } + return tags; } - protected virtual void CheckCellCountConsistency(TableRow[] rows) + private List GetTableRows(AstNode node) { - if (rows.Length == 0) - return; - - int cellCount = rows[0].Cells.Count(); - for (int i = 1; i < rows.Length; i++) + var rows = new List(); + int cellCount = 0; + bool firstRow = true; + foreach (var rowToken in node.GetTokens(TokenType.TableRow)) { - var row = rows[i]; - if (row.Cells.Count() != cellCount) + var rowLocation = GetLocation(rowToken); + var cells = new List(); + foreach (var cellItem in rowToken.Line.GetTableCells()) + cells.Add(CreateTableCell(GetLocation(rowToken, cellItem.Column), cellItem.Text)); + if (firstRow) { - HandleAstError("inconsistent cell count within the table", row.Location); + cellCount = cells.Count; + firstRow = false; } + else if (cells.Count != cellCount) + { + HandleAstError("inconsistent cell count within the table", rowLocation); + } + rows.Add(CreateTableRow(rowLocation, cells, node)); } + return rows; } protected virtual void HandleAstError(string message, Location location) @@ -294,20 +298,9 @@ protected virtual void HandleAstError(string message, Location location) throw new AstBuilderException(message, location); } - private TableCell[] GetCells(Token tableRowToken) - { - var cells = new TableCell[tableRowToken.MatchedItems.Length]; - for (int i = 0; i < cells.Length; i++) - { - var cellItem = tableRowToken.MatchedItems[i]; - cells[i] = CreateTableCell(GetLocation(tableRowToken, cellItem.Column), cellItem.Text); - } - return cells; - } - - private static Step[] GetSteps(AstNode scenarioDefinitionNode) + private static List GetSteps(AstNode scenarioDefinitionNode) { - return scenarioDefinitionNode.GetItems(RuleType.Step).ToArray(); + return [..scenarioDefinitionNode.GetItems(RuleType.Step)]; } private static string GetDescription(AstNode scenarioDefinitionNode) diff --git a/dotnet/Gherkin/AstNode.cs b/dotnet/Gherkin/AstNode.cs index 8c0fbc108..bed59e287 100644 --- a/dotnet/Gherkin/AstNode.cs +++ b/dotnet/Gherkin/AstNode.cs @@ -1,8 +1,12 @@ +using System.Collections; +using System.Diagnostics; + namespace Gherkin; +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public class AstNode(RuleType ruleType) { - private readonly Dictionary subItems = new Dictionary(); + private readonly List<(RuleType RuleType, object Item)> subItems = new(4); public RuleType RuleType { get; } = ruleType; @@ -11,86 +15,90 @@ public Token GetToken(TokenType tokenType) return GetSingle((RuleType)tokenType); } - public IEnumerable GetTokens(TokenType tokenType) + public ItemsEnumerable GetTokens(TokenType tokenType) { return GetItems((RuleType)tokenType); } public T GetSingle(RuleType ruleType) { - if (!subItems.TryGetValue(ruleType, out var items)) - return default; - if (items is List list) + bool foundOne = false; + T ret = default; + foreach ((var itemType, var item) in subItems) { - T ret = default; - bool foundOne = false; - foreach (var item in list) - { - if (item is T tItem) - { - if (foundOne) - throw new InvalidOperationException(); - ret = tItem; - foundOne = true; - } - } + if (itemType != ruleType) + continue; + if (item is not T tItem) + continue; if (foundOne) - return ret; - else throw new InvalidOperationException(); + ret = tItem; + foundOne = true; } - else if (items is T tItem) - { - return tItem; - } - return default; + return ret; } - public IEnumerable GetItems(RuleType ruleType) + public readonly struct ItemsEnumerable : IEnumerable { - if (!subItems.TryGetValue(ruleType, out var items)) - yield break; - if (items is List list) - { - foreach (var item in list) - { - if (item is T tItem) - yield return tItem; - } - } - else if (items is T tItem) + readonly List<(RuleType RuleType, object Item)> subItems; + readonly RuleType ruleType; + + internal ItemsEnumerable(List<(RuleType RuleType, object Item)> subItems, RuleType ruleType) { - yield return tItem; + this.subItems = subItems; + this.ruleType = ruleType; } - } - public void SetSingle(RuleType ruleType, T value) - { - subItems[ruleType] = new object[] { value }; - } + public ItemsEnumerator GetEnumerator() => new ItemsEnumerator(subItems.GetEnumerator(), ruleType); - public void AddRange(RuleType ruleType, IEnumerable values) - { - foreach (var value in values) - { - Add(ruleType, value); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } - public void Add(RuleType ruleType, T obj) + public struct ItemsEnumerator : IEnumerator { - if (!subItems.TryGetValue(ruleType, out var items)) + private List<(RuleType RuleType, object Item)>.Enumerator enumerator; + readonly RuleType ruleType; + + internal ItemsEnumerator(List<(RuleType RuleType, object Item)>.Enumerator enumerator, RuleType ruleType) { - subItems.Add(ruleType, obj); + this.enumerator = enumerator; + this.ruleType = ruleType; } - else if (items is List list) + + public T Current { readonly get; private set; } + readonly object IEnumerator.Current => Current; + + public readonly void Dispose() { - list.Add(obj); + // nothing to do } - else + + public bool MoveNext() { - list = [items, obj]; - subItems[ruleType] = list; + while (enumerator.MoveNext()) + { + (var itemType, var item) = enumerator.Current; + if (itemType != ruleType) + continue; + if (item is not T tItem) + continue; + Current = tItem; + return true; + } + Current = default; + return false; } + + public void Reset() => throw new NotImplementedException(); } + + public ItemsEnumerable GetItems(RuleType ruleType) => new ItemsEnumerable(subItems, ruleType); + + public void Add(RuleType ruleType, T obj) + { + subItems.Add((ruleType, obj)); + } + + string GetDebuggerDisplay() => $"RuleType: {RuleType} with item count: {(subItems.Count == 0 ? "" : string.Join(", ", subItems.GroupBy(x => x.RuleType).Select(x => $"{x.Key}:{x.Count()}")))}"; } diff --git a/dotnet/Gherkin/GherkinDialectProvider.cs b/dotnet/Gherkin/GherkinDialectProvider.cs index 90bd44637..ca99c890d 100644 --- a/dotnet/Gherkin/GherkinDialectProvider.cs +++ b/dotnet/Gherkin/GherkinDialectProvider.cs @@ -5,7 +5,7 @@ namespace Gherkin; public interface IGherkinDialectProvider { GherkinDialect DefaultDialect { get; } - GherkinDialect GetDialect(string language, Location location); + GherkinDialect GetDialect(string language, Location? location); } [LanguageDialectGenerated] @@ -23,13 +23,13 @@ public GherkinDialectProvider(string defaultLanguage = "en") defaultDialect = new Lazy(() => GetDialect(defaultLanguage, null)); } - protected virtual bool TryGetDialect(string language, Location location, out GherkinDialect dialect) + protected virtual bool TryGetDialect(string language, Location? location, out GherkinDialect dialect) { dialect = TryCreateGherkinDialect(language); return dialect is not null; } - public virtual GherkinDialect GetDialect(string language, Location location) + public virtual GherkinDialect GetDialect(string language, Location? location) { if (!TryGetDialect(language, location, out var dialect)) throw new NoSuchLanguageException(language, location); diff --git a/dotnet/Gherkin/GherkinLine.cs b/dotnet/Gherkin/GherkinLine.cs index 51d7ec00a..328ee18b4 100644 --- a/dotnet/Gherkin/GherkinLine.cs +++ b/dotnet/Gherkin/GherkinLine.cs @@ -1,13 +1,21 @@ using Gherkin.Ast; +using System.Collections; namespace Gherkin; -public class GherkinLine : IGherkinLine +/// +/// Represents a line of a Gherkin file +/// +public readonly struct GherkinLine { - private static char[] inlineWhitespaceChars = [' ', '\t', '\u00A0']; + private static readonly char[] inlineWhitespaceChars = [' ', '\t', '\u00A0']; private readonly string lineText; - private readonly string trimmedLineText; + private readonly int trimmedStartIndex; + + /// + /// One-based line number + /// public int LineNumber { get; } public GherkinLine(string line, int lineNumber) @@ -15,33 +23,51 @@ public GherkinLine(string line, int lineNumber) LineNumber = lineNumber; lineText = line; - trimmedLineText = lineText.TrimStart(); - } - - public void Detach() - { - //nop + int start; + for (start = 0; start < lineText.Length; start++) + { + if (!char.IsWhiteSpace(lineText[start])) + break; + } + trimmedStartIndex = start; } + /// + /// The number of whitespace characters in the beginning of the line. + /// public int Indent { - get { return lineText.Length - trimmedLineText.Length; } + get { return trimmedStartIndex; } } + /// + /// Gets if the line is empty or contains whitespaces only. + /// + /// true, if empty or contains whitespaces only; otherwise, false. public bool IsEmpty() { - return trimmedLineText.Length == 0; + return lineText.Length == trimmedStartIndex; } + /// + /// Determines whether the beginning of the line (wihtout whitespaces) matches a specified string. + /// + /// The string to compare. + /// true if text matches the beginning of this line; otherwise, false. public bool StartsWith(string text) { - return trimmedLineText.StartsWith(text, StringComparison.Ordinal); + return string.CompareOrdinal(lineText, trimmedStartIndex, text, 0, text.Length) == 0; } + /// + /// Determines whether the beginning of the line (wihtout whitespaces) matches a specified title keyword (ie. a keyword followed by a ':' character). + /// + /// The keyword to compare. + /// true if keyword matches the beginning of this line and followed by a ':' character; otherwise, false. public bool StartsWithTitleKeyword(string text) { - return StringUtils.StartsWith(trimmedLineText, text) && - StartsWithFrom(trimmedLineText, text.Length, GherkinLanguageConstants.TITLE_KEYWORD_SEPARATOR); + return StartsWith(text) && + StartsWithFrom(lineText, trimmedStartIndex + text.Length, GherkinLanguageConstants.TITLE_KEYWORD_SEPARATOR); } private static bool StartsWithFrom(string text, int textIndex, string value) @@ -49,121 +75,242 @@ private static bool StartsWithFrom(string text, int textIndex, string value) return string.CompareOrdinal(text, textIndex, value, 0, value.Length) == 0; } - public string GetLineText(int indentToRemove) + /// + /// Returns the line text + /// + /// The maximum number of whitespace characters to remove. -1 removes all leading whitespaces. + /// The line text. + public string GetLineText(int indentToRemove = 0) { if (indentToRemove < 0 || indentToRemove > Indent) - return trimmedLineText; + return lineText.Substring(trimmedStartIndex); return lineText.Substring(indentToRemove); } + /// + /// Returns the remaining part of the line. + /// + /// Number of characters to skip from the beginning of the line, including indentation. + /// public string GetRestTrimmed(int length) { - return trimmedLineText.Substring(length).Trim(); + return lineText.Substring(trimmedStartIndex + length).Trim(); } - public IEnumerable GetTags() + public readonly struct TagsEnumerable : IEnumerable { - string uncommentedLine = trimmedLineText; - var commentIndex = trimmedLineText.IndexOf(GherkinLanguageConstants.COMMENT_PREFIX[0]); - while (commentIndex >= 0) + readonly int lineNumber; + readonly string uncommentedLine; + readonly int position; + public TagsEnumerable(int lineNumber, string lineText, int trimmedStartIndex) { - if (commentIndex == 0) - yield break; - if (Array.IndexOf(inlineWhitespaceChars, trimmedLineText[commentIndex - 1]) == 0) + this.lineNumber = lineNumber; + uncommentedLine = lineText; + var commentIndex = lineText.IndexOf(GherkinLanguageConstants.COMMENT_PREFIX[0], trimmedStartIndex); + while (commentIndex >= 0) { - uncommentedLine = uncommentedLine.Substring(0, commentIndex); - break; + if (commentIndex == 0) + { + position = -1; + return; + } + if (Array.IndexOf(inlineWhitespaceChars, lineText[commentIndex - 1]) != -1) + { + uncommentedLine = uncommentedLine.Substring(0, commentIndex); + break; + } + commentIndex = lineText.IndexOf(GherkinLanguageConstants.COMMENT_PREFIX[0], commentIndex + 1); } - commentIndex = trimmedLineText.IndexOf(GherkinLanguageConstants.COMMENT_PREFIX[0], commentIndex + 1); + position = uncommentedLine.IndexOf(GherkinLanguageConstants.TAG_PREFIX[0], trimmedStartIndex); } - int position = Indent; - foreach (string item in uncommentedLine.Split(GherkinLanguageConstants.TAG_PREFIX[0])) + + public TagsEnumerator GetEnumerator() => new TagsEnumerator(lineNumber, uncommentedLine, position); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public struct TagsEnumerator : IEnumerator + { + readonly int lineNumber; + readonly string uncommentedLine; + int position; + + public TagsEnumerator(int lineNumber, string uncommentedLine, int position) : this() { - if (item.Length > 0) + this.lineNumber = lineNumber; + this.uncommentedLine = uncommentedLine; + this.position = position; + } + + public GherkinLineSpan Current { readonly get; private set; } + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (position >= 0) { - var tagName = GherkinLanguageConstants.TAG_PREFIX + item.TrimEnd(inlineWhitespaceChars); - if (tagName.Length == 1) + int nextPos = uncommentedLine.IndexOf(GherkinLanguageConstants.TAG_PREFIX[0], position + 1); + int endPos; + if (nextPos > 0) + endPos = nextPos - 1; + else + endPos = uncommentedLine.Length - 1; + + while (endPos > position && Array.IndexOf(inlineWhitespaceChars, uncommentedLine[endPos]) != -1) // TrimEnd + endPos -= 1; + + int length = endPos - position + 1; + if (length <= 1) + { + position = nextPos; continue; + } + + var tagName = uncommentedLine.Substring(position, length); if (tagName.IndexOfAny(inlineWhitespaceChars) >= 0) - throw new InvalidTagException("A tag may not contain whitespace", new Location(LineNumber, position)); + throw new InvalidTagException("A tag may not contain whitespace", new Location(lineNumber, position + 1)); - yield return new GherkinLineSpan(position, tagName); - position += item.Length; + Current = new GherkinLineSpan(position + 1, tagName); + position = nextPos; + return true; } - position++; // separator + + Current = default; + return false; + } + + readonly void IDisposable.Dispose() + { + // nothing to do } + + void IEnumerator.Reset() => throw new NotImplementedException(); } - public IEnumerable GetTableCells() + /// + /// Tries parsing the line as a tag list, and returns the tags wihtout the leading '@' characters. + /// + /// (position,text) pairs, position is 0-based index + public TagsEnumerable GetTags() => new TagsEnumerable(LineNumber, lineText, trimmedStartIndex); + + public readonly struct TableCellsEnumerable(string lineText, int startPos) : IEnumerable { - var rowEnum = trimmedLineText.GetEnumerator(); - bool isFirstRow = true; + public TableCellsEnumerator GetEnumerator() => new TableCellsEnumerator(lineText, startPos); - string cell = null; - int pos = 0; - int startPos = 0; + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } - static void EnsureCellText(ref string cell, string trimmedLineText, ref int startPos, int pos) + public struct TableCellsEnumerator : IEnumerator + { + readonly string lineText; + int startPos; + int pos; + bool isFirstRow; + + public TableCellsEnumerator(string lineText, int startPos) + { + this.lineText = lineText; + this.startPos = startPos; + this.pos = startPos; + this.isFirstRow = true; + } + + public GherkinLineSpan Current { readonly get; private set; } + readonly object IEnumerator.Current => Current; + + void EnsureCellText(ref string cell, bool trim) { if (cell is not null) + { + if (trim) + cell = cell.TrimEnd(inlineWhitespaceChars); return; + } - while (startPos < pos && Array.IndexOf(inlineWhitespaceChars, trimmedLineText[startPos]) != -1) + while (startPos < pos && Array.IndexOf(inlineWhitespaceChars, lineText[startPos]) != -1) startPos++; - cell = trimmedLineText.Substring(startPos, pos - startPos - 1); + int trimedPos = pos - 2; + while (trimedPos >= startPos && Array.IndexOf(inlineWhitespaceChars, lineText[trimedPos]) != -1) + trimedPos--; + + cell = lineText.Substring(startPos, trimedPos - startPos + 1); } - while (rowEnum.MoveNext()) + public bool MoveNext() { - pos++; - char c = rowEnum.Current; - if (c == GherkinLanguageConstants.TABLE_CELL_SEPARATOR_CHAR) - { - if (isFirstRow) - isFirstRow = false; - else - { - EnsureCellText(ref cell, trimmedLineText, ref startPos, pos); - var cellText = cell.TrimEnd(inlineWhitespaceChars); + string cell = null; - yield return new GherkinLineSpan(Indent + startPos + 1, cellText); - } - cell = null; - startPos = pos; - } - else if (c == GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR) + while (pos < lineText.Length) { - EnsureCellText(ref cell, trimmedLineText, ref startPos, pos); - if (rowEnum.MoveNext()) + char c = lineText[pos]; + pos++; + if (c == GherkinLanguageConstants.TABLE_CELL_SEPARATOR_CHAR) { - pos++; - c = rowEnum.Current; - if (c == GherkinLanguageConstants.TABLE_CELL_NEWLINE_ESCAPE) + if (isFirstRow) { - cell += "\n"; + isFirstRow = false; + startPos = pos; } else { - if (c != GherkinLanguageConstants.TABLE_CELL_SEPARATOR_CHAR && c != GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR) + EnsureCellText(ref cell, true); + + Current = new GherkinLineSpan(startPos + 1, cell); + startPos = pos; + return true; + } + } + else if (c == GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR) + { + EnsureCellText(ref cell, false); + if ((pos + 1) < lineText.Length) + { + c = lineText[pos]; + pos++; + if (c == GherkinLanguageConstants.TABLE_CELL_NEWLINE_ESCAPE) { - cell += GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR; + cell += "\n"; + } + else + { + if (c != GherkinLanguageConstants.TABLE_CELL_SEPARATOR_CHAR && c != GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR) + { + cell += GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR; + } + cell += c; } - cell += c; + } + else + { + cell += GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR; } } else { - cell += GherkinLanguageConstants.TABLE_CELL_ESCAPE_CHAR; + if (cell is not null) + cell += c; } } - else - { - if (cell is not null) - cell += c; - } + + return false; + } + + readonly void IDisposable.Dispose() + { + // nothing to do } + + void IEnumerator.Reset() => throw new NotImplementedException(); } + + /// + /// Tries parsing the line as table row and returns the trimmed cell values. + /// + /// (position,text) pairs, position is 0-based index + public TableCellsEnumerable GetTableCells() => new TableCellsEnumerable(lineText, trimmedStartIndex); } diff --git a/dotnet/Gherkin/GherkinLineSpan.cs b/dotnet/Gherkin/GherkinLineSpan.cs index f99a1458b..2714eeddb 100644 --- a/dotnet/Gherkin/GherkinLineSpan.cs +++ b/dotnet/Gherkin/GherkinLineSpan.cs @@ -1,6 +1,6 @@ namespace Gherkin; -public struct GherkinLineSpan(int column, string text) +public readonly struct GherkinLineSpan(int column, string text) { /// /// One-based line position diff --git a/dotnet/Gherkin/IGherkinLine.cs b/dotnet/Gherkin/IGherkinLine.cs deleted file mode 100644 index 5edd966da..000000000 --- a/dotnet/Gherkin/IGherkinLine.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace Gherkin; - -/// -/// Represents a line of a Gherkin file -/// -public interface IGherkinLine -{ - /// - /// One-based line number - /// - int LineNumber { get; } - - /// - /// Called by the parser to indicate non-streamed reading (e.g. during look-ahead). - /// - /// - /// If the implementation depends on streamed reading behavior, with this method, it can clone itself, so that it will be detached. - /// - void Detach(); - - /// - /// The number of whitespace characters in the beginning of the line. - /// - int Indent { get; } - - /// - /// Gets if the line is empty or contains whitespaces only. - /// - /// true, if empty or contains whitespaces only; otherwise, false. - bool IsEmpty(); - - /// - /// Determines whether the beginning of the line (wihtout whitespaces) matches a specified string. - /// - /// The string to compare. - /// true if text matches the beginning of this line; otherwise, false. - bool StartsWith(string text); - - /// - /// Determines whether the beginning of the line (wihtout whitespaces) matches a specified title keyword (ie. a keyword followed by a ':' character). - /// - /// The keyword to compare. - /// true if keyword matches the beginning of this line and followed by a ':' character; otherwise, false. - bool StartsWithTitleKeyword(string keyword); - /// - /// Returns the line text - /// - /// The maximum number of whitespace characters to remove. -1 removes all leading whitespaces. - /// The line text. - string GetLineText(int indentToRemove = 0); - /// - /// Returns the remaining part of the line. - /// - /// - /// - string GetRestTrimmed(int length); - - /// - /// Tries parsing the line as a tag list, and returns the tags wihtout the leading '@' characters. - /// - /// (position,text) pairs, position is 0-based index - IEnumerable GetTags(); - - /// - /// Tries parsing the line as table row and returns the trimmed cell values. - /// - /// (position,text) pairs, position is 0-based index - IEnumerable GetTableCells(); -} diff --git a/dotnet/Gherkin/ParserException.cs b/dotnet/Gherkin/ParserException.cs index 2afaa755f..73565410d 100644 --- a/dotnet/Gherkin/ParserException.cs +++ b/dotnet/Gherkin/ParserException.cs @@ -2,16 +2,16 @@ namespace Gherkin; -public abstract class ParserException(string message, Location location = null) : Exception(GetMessage(message, location)) +public abstract class ParserException(string message, Location? location = null) : Exception(GetMessage(message, location)) { - public Location Location { get; } = location; + public Location? Location { get; } = location; - private static string GetMessage(string message, Location location) + private static string GetMessage(string message, Location? location) { - if (location == null) + if (location is null) return message; - return string.Format("({0}:{1}): {2}", location.Line, location.Column, message); + return string.Format("({0}:{1}): {2}", location.GetValueOrDefault().Line, location.GetValueOrDefault().Column, message); } } @@ -22,7 +22,7 @@ public class AstBuilderException(string message, Location location) : ParserExce public class NoSuchLanguageException : ParserException { - public NoSuchLanguageException(string language, Location location = null) : + public NoSuchLanguageException(string language, Location? location = null) : base("Language not supported: " + language, location) { if (language == null) throw new ArgumentNullException("language"); @@ -30,7 +30,7 @@ public NoSuchLanguageException(string language, Location location = null) : } -public class InvalidTagException(string message, Location location = null) : ParserException(message, location) +public class InvalidTagException(string message, Location? location = null) : ParserException(message, location) { } diff --git a/dotnet/Gherkin/Token.cs b/dotnet/Gherkin/Token.cs index eace59712..7727b54ef 100644 --- a/dotnet/Gherkin/Token.cs +++ b/dotnet/Gherkin/Token.cs @@ -2,22 +2,30 @@ namespace Gherkin; -public class Token(IGherkinLine line, Location location) +public class Token { - public bool IsEOF { get { return Line == null; } } - public IGherkinLine Line { get; set; } = line; + public Token(GherkinLine line, Location location) + { + Line = line; + Location = location; + } + public Token(Location location) + { + Location = location; + IsEOF = true; + } + + public bool IsEOF { get; } + public GherkinLine Line { get; set; } public TokenType MatchedType { get; set; } public string MatchedKeyword { get; set; } public string MatchedText { get; set; } - public GherkinLineSpan[] MatchedItems { get; set; } public int MatchedIndent { get; set; } public GherkinDialect MatchedGherkinDialect { get; set; } - public Location Location { get; set; } = location; + public Location Location { get; set; } public void Detach() { - if (Line != null) - Line.Detach(); } public string GetTokenValue() diff --git a/dotnet/Gherkin/TokenMatcher.cs b/dotnet/Gherkin/TokenMatcher.cs index 93110834c..51a6bab25 100644 --- a/dotnet/Gherkin/TokenMatcher.cs +++ b/dotnet/Gherkin/TokenMatcher.cs @@ -32,14 +32,13 @@ public void Reset() currentDialect = dialectProvider.DefaultDialect; } - protected virtual void SetTokenMatched(Token token, TokenType matchedType, string text = null, string keyword = null, int? indent = null, GherkinLineSpan[] items = null) + protected virtual void SetTokenMatched(Token token, TokenType matchedType, string text = null, string keyword = null, int? indent = null) { token.MatchedType = matchedType; token.MatchedKeyword = keyword; token.MatchedText = text; - token.MatchedItems = items; token.MatchedGherkinDialect = CurrentDialect; - token.MatchedIndent = indent ?? (token.Line == null ? 0 : token.Line.Indent); + token.MatchedIndent = indent ?? (token.IsEOF ? 0 : token.Line.Indent); token.Location = new Ast.Location(token.Location.Line, token.MatchedIndent + 1); } @@ -113,7 +112,7 @@ public bool Match_TagLine(Token token) { if (token.Line.StartsWith(GherkinLanguageConstants.TAG_PREFIX)) { - SetTokenMatched(token, TokenType.TagLine, items: token.Line.GetTags().ToArray()); + SetTokenMatched(token, TokenType.TagLine); return true; } return false; @@ -212,7 +211,7 @@ public bool Match_TableRow(Token token) { if (token.Line.StartsWith(GherkinLanguageConstants.TABLE_CELL_SEPARATOR)) { - SetTokenMatched(token, TokenType.TableRow, items: token.Line.GetTableCells().ToArray()); + SetTokenMatched(token, TokenType.TableRow); return true; } return false; diff --git a/dotnet/Gherkin/TokenScanner.cs b/dotnet/Gherkin/TokenScanner.cs index e3fdf9292..6d0ea73f2 100644 --- a/dotnet/Gherkin/TokenScanner.cs +++ b/dotnet/Gherkin/TokenScanner.cs @@ -19,6 +19,6 @@ public virtual Token Read() { var line = reader.ReadLine(); var location = new Ast.Location(++lineNumber); - return line == null ? new Token(null, location) : new Token(new GherkinLine(line, lineNumber), location); + return line == null ? new Token(location) : new Token(new GherkinLine(line, lineNumber), location); } } \ No newline at end of file