From abb43255de3f8e4a1a423255115842c09d48a949 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Mon, 18 May 2026 10:46:33 -0500 Subject: [PATCH 01/13] Refactored `TagName` API to improve naming consistency and clarity. Updated related classes, methods, and tests to align with the new API. Added new `ClearData` method in `StringData`. --- src/L5Sharp.Core/Code/Block.cs | 8 +- src/L5Sharp.Core/Code/Sheet.cs | 10 +-- src/L5Sharp.Core/Common/TagName.cs | 46 ++++++----- src/L5Sharp.Core/Components/Tag.cs | 14 ++-- src/L5Sharp.Core/Data/StringData.cs | 7 ++ src/L5Sharp.Core/L5X.cs | 16 ++-- src/L5Sharp.Core/LogixElement.cs | 53 ++++++++++-- .../L5Sharp.Tests.Core/Common/TagNameTests.cs | 82 +++++++++---------- .../L5Sharp.Tests.Core/Components/TagTests.cs | 2 +- tests/L5Sharp.Tests.Core/Examples.cs | 9 -- tests/L5Sharp.Tests.Gateway/ExtensionTests.cs | 2 +- 11 files changed, 147 insertions(+), 102 deletions(-) diff --git a/src/L5Sharp.Core/Code/Block.cs b/src/L5Sharp.Core/Code/Block.cs index 95fd4312..1ce16f3d 100644 --- a/src/L5Sharp.Core/Code/Block.cs +++ b/src/L5Sharp.Core/Code/Block.cs @@ -209,8 +209,8 @@ public Block WireTo(TagName target, TagName? from = null) if (target is null || target.IsEmpty) throw new ArgumentException("Can not wire block with null or empty target tag name."); - var operand = target.Base; - var pin = target.Member; + var operand = target.BaseName; + var pin = target.MemberPath; var to = Element.Parent?.Elements().FirstOrDefault(e => e.GetBlockOperand() == operand @@ -256,8 +256,8 @@ public Block WireFrom(TagName source, TagName? to = null) if (source is null || source.IsEmpty) throw new ArgumentException("Can not wire block with null or empty target tag name."); - var operand = source.Base; - var pin = source.Member; + var operand = source.BaseName; + var pin = source.MemberPath; var from = Element.Parent?.Elements().FirstOrDefault(e => e.GetBlockOperand() == operand diff --git a/src/L5Sharp.Core/Code/Sheet.cs b/src/L5Sharp.Core/Code/Sheet.cs index a7e81606..b1337371 100644 --- a/src/L5Sharp.Core/Code/Sheet.cs +++ b/src/L5Sharp.Core/Code/Sheet.cs @@ -291,16 +291,16 @@ public Sheet Connect(TagName from, TagName to) if (from is null) throw new ArgumentNullException(nameof(from)); if (to is null) throw new ArgumentNullException(nameof(to)); - var source = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == from.Base)?.Deserialize(); - var target = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == to.Base)?.Deserialize(); + var source = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == from.BaseName)?.Deserialize(); + var target = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == to.BaseName)?.Deserialize(); if (source is null) - throw new InvalidOperationException($"No source block with operand '{from.Base}' exists in the sheet."); + throw new InvalidOperationException($"No source block with operand '{from.BaseName}' exists in the sheet."); if (target is null) - throw new InvalidOperationException($"No target block with operand '{to.Base}' exists in the sheet."); + throw new InvalidOperationException($"No target block with operand '{to.BaseName}' exists in the sheet."); - WireBlocks(source.ID, target.ID, from.Member, to.Member); + WireBlocks(source.ID, target.ID, from.MemberPath, to.MemberPath); return this; } diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index 3b372ea7..08b8394e 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -94,7 +94,7 @@ public TagName(string name) /// equality and value comparison methods. /// // ReSharper disable once ConvertToAutoPropertyWhenPossible - public string Path => _path; + public string FullPath => _path; /// /// Gets the local portion of the tag name, excluding any program scope prefix. @@ -102,19 +102,18 @@ public TagName(string name) /// /// If the tag name is program-scoped (begins with "Program:"), this property returns /// only the tag name portion after the program prefix and separator. For controller-scoped - /// tags, this property returns the same value as . + /// tags, this property returns the same value as . /// For example, "Program:MyProgram.MyTag" would return "MyTag". /// public string LocalPath => GetLocalTagName(_path); /// - /// Gets the base portion of the tag name or an empty string if not defined. + /// Represents the hierarchical portion of the tag name that excludes the base tag name and is stripped of the leading separator. + /// This property returns the remaining portion of the tag name, starting from the first member or array notation, + /// if applicable, after the base tag. It is derived from the property by removing the leading separator. + /// Commonly used for accessing specific levels of a tag's hierarchy within complex or structured tag definitions. /// - /// - /// The Base part of a tag name is the beginning part of the tag name up to the first member - /// separator character (e.g., '.' or '['). For Module-defined tags, this includes the colon separator. - /// - public string Base => GetBase(_path); + public string MemberPath => GetOperand(_path).TrimStart(Separator); /// /// Represents the operand portion of the tag path, including all members, elements, and indices, @@ -125,14 +124,13 @@ public TagName(string name) public string Operand => GetOperand(_path); /// - /// Gets the portion of the tag name that represents the member path. + /// Gets the base portion of the tag name or an empty string if not defined. /// - /// /// - /// The member is derived by stripping the base tag name and any leading separators - /// from the operand. This property encapsulates the hierarchical component - /// beneath the base, including nested structures or indices when applicable. + /// + /// The Base part of a tag name is the beginning part of the tag name up to the first member + /// separator character (e.g., '.' or '['). For Module-defined tags, this includes the colon separator. /// - public string Member => GetOperand(_path).TrimStart(Separator); + public string BaseName => GetBase(_path); /// /// Retrieves the terminal component or final segment of the tag path represented by the instance. @@ -142,7 +140,7 @@ public TagName(string name) /// submember, or indexed element in a hierarchical path structure. /// It is particularly useful for identifying the immediate target or endpoint in a tag hierarchy. /// - public string Element => GetElement(_path); + public string MemberName => GetMember(_path); /// /// A zero-based number representing the depth of the tag name. In other words, the number of members @@ -291,7 +289,7 @@ public bool Contains(TagName tagName) return _path.IndexOf(tagName._path, StringComparison.OrdinalIgnoreCase) >= 0; } - + /// /// Creates a new by replacing the base portion of the current tag name /// with the specified base name while preserving the operand (member path, indices, and elements). @@ -301,7 +299,7 @@ public bool Contains(TagName tagName) /// A new instance with the updated base name and the original operand. /// /// This method is useful when you need to change the root portion of a tag reference while keeping - /// the structural path intact. For example, renaming "OldTag.Member[1].Value" with base name "NewTag" + /// the structural path intact. For example, renaming "OldTag.Member[1].Value" with the base name "NewTag" /// would result in "NewTag.Member[1].Value". /// public TagName Rename(string baseName) => Combine(baseName, Operand); @@ -454,7 +452,7 @@ private static string GetOperand(string path) /// Gets the last member of the tag name path, or the portion of the string from the last member separator to the /// end of the string. We are calling this the element. /// - private static string GetElement(string path) + private static string GetMember(string path) { var tagName = GetLocalTagName(path); @@ -521,7 +519,7 @@ private static int GetDepth(string path) /// /// Determines if the tag name path contains a program prefix name and if so uses that to return a new /// object to identify the scope of the tag name. If no program prefix is present, we always assume - /// a controller scoped tag name. + /// a controller-scoped tag name. /// private static Scope GetScope(string path) { @@ -552,8 +550,11 @@ private static string GetLocalTagName(string path) } /// - /// Handles combining an enumerable containing string member names into a single value. + /// Concatenates the provided collection of tag members into a single string representation + /// using appropriate delimiters. /// + /// The collection of tag members to concatenate. + /// A string representing the concatenated tag members. private static string ConcatenateMembers(IEnumerable members) { var builder = new StringBuilder(); @@ -569,6 +570,11 @@ private static string ConcatenateMembers(IEnumerable members) return builder.ToString(); } + /// + /// Determines whether the specified string value represents a valid qualified tag name. + /// + /// The string value to validate as a qualified tag name. + /// True if the value is a qualified tag name; otherwise, false. private static bool IsQualifiedTagName(string value) { if (value.IsEmpty()) return false; diff --git a/src/L5Sharp.Core/Components/Tag.cs b/src/L5Sharp.Core/Components/Tag.cs index 565c548a..d8f896a3 100644 --- a/src/L5Sharp.Core/Components/Tag.cs +++ b/src/L5Sharp.Core/Components/Tag.cs @@ -36,8 +36,8 @@ public class Tag : LogixComponent L5XName.Description, L5XName.Comments, L5XName.EngineeringUnits, - L5XName.Mins, L5XName.Maxes, + L5XName.Mins, L5XName.State0s, L5XName.State1s, L5XName.Data, @@ -481,14 +481,14 @@ public Tag this[TagName tagName] if (tagName is null) throw new ArgumentNullException(nameof(tagName)); if (tagName.IsEmpty) return this; - var member = Value.GetMember(tagName.Base); + var member = Value.GetMember(tagName.BaseName); if (member is null) throw new ArgumentException( - $"No member with name '{tagName.Base}' exists in the tag data structure for type {DataType}."); + $"No member with name '{tagName.BaseName}' exists in the tag data structure for type {DataType}."); var tag = new Tag(member, this); - return tagName.Depth == 0 ? tag : tag[tagName.Member]; + return tagName.Depth == 0 ? tag : tag[tagName.MemberPath]; } } @@ -547,11 +547,11 @@ public void RemoveMember(string name) if (tagName is null) throw new ArgumentNullException(nameof(tagName)); if (tagName.IsEmpty) return this; - var member = Value.GetMember(tagName.Base); + var member = Value.GetMember(tagName.BaseName); if (member is null) return null; var tag = new Tag(member, this); - return tagName.Depth == 0 ? tag : tag.Member(tagName.Member); + return tagName.Depth == 0 ? tag : tag.Member(tagName.MemberPath); } /// @@ -644,7 +644,7 @@ public IEnumerable MembersOf(TagName tagName) if (tagName is null) throw new ArgumentNullException(nameof(tagName)); if (tagName.IsEmpty) return Members(); - var member = Value.GetMember(tagName.Base); + var member = Value.GetMember(tagName.BaseName); if (member is null) return []; var tag = new Tag(member, this); diff --git a/src/L5Sharp.Core/Data/StringData.cs b/src/L5Sharp.Core/Data/StringData.cs index eda6b2c8..2c37804a 100644 --- a/src/L5Sharp.Core/Data/StringData.cs +++ b/src/L5Sharp.Core/Data/StringData.cs @@ -114,6 +114,13 @@ public override int UpdateData(byte[] data, int offset) return offset + GetSize(); } + /// + public override void ClearData() + { + SetLength(0); + SetString(string.Empty); + } + /// public override bool Equals(object? obj) { diff --git a/src/L5Sharp.Core/L5X.cs b/src/L5Sharp.Core/L5X.cs index 494257aa..b9aa0ef4 100644 --- a/src/L5Sharp.Core/L5X.cs +++ b/src/L5Sharp.Core/L5X.cs @@ -315,11 +315,11 @@ public ILogixEntity Get(Reference reference) var tagName = reference.Id.ToTagName(); // Always search the index using the base name and the reference's explicit scope. - var baseReference = Reference.To(tagName.Base, reference.Scope); + var baseReference = Reference.To(tagName.BaseName, reference.Scope); var tag = _index.GetElement(baseReference); // Navigate to the specified member (this will return the base tag if no member is specified). - return tag[tagName.Member]; + return tag[tagName.MemberPath]; } /// @@ -349,9 +349,9 @@ public TComponent Get(string name) where TComponent : LogixComponent throw new ArgumentException("Name can not be null or empty.", nameof(name)); var tagName = name.ToTagName(); - var reference = Reference.To(tagName.Base, tagName.Scope); + var reference = Reference.To(tagName.BaseName, tagName.Scope); var element = _index.GetElement(reference); - return element is Tag tag ? tag[tagName.Member].As() : element; + return element is Tag tag ? tag[tagName.MemberPath].As() : element; } /// @@ -388,9 +388,9 @@ public bool TryGet(Reference reference, out ILogixEntity entity) var tagName = reference.Id.ToTagName(); // Always search the index using the base name and the reference's explicit scope. - if (_index.TryGetElement(Reference.To(tagName.Base, reference.Scope), out var tag)) + if (_index.TryGetElement(Reference.To(tagName.BaseName, reference.Scope), out var tag)) { - var target = tag.Member(tagName.Member); + var target = tag.Member(tagName.MemberPath); return target.IsNull(out entity); } @@ -412,11 +412,11 @@ public bool TryGet(string name, out TComponent component) where TCom throw new ArgumentException("Name can not be null or empty.", nameof(name)); var tagName = name.ToTagName(); - var reference = Reference.To(tagName.Base, tagName.Scope); + var reference = Reference.To(tagName.BaseName, tagName.Scope); if (_index.TryGetElement(reference, out var element)) { - var target = element is Tag tag ? tag.Member(tagName.Member)?.As() : element; + var target = element is Tag tag ? tag.Member(tagName.MemberPath)?.As() : element; return target.IsNull(out component); } diff --git a/src/L5Sharp.Core/LogixElement.cs b/src/L5Sharp.Core/LogixElement.cs index 98f75fb0..91e8bc0d 100644 --- a/src/L5Sharp.Core/LogixElement.cs +++ b/src/L5Sharp.Core/LogixElement.cs @@ -53,6 +53,27 @@ public interface ILogixElement /// True if the document was successfully retrieved; otherwise, false. bool TryGetDocument(out L5X document); + /// + /// Retrieves the nearest ancestor of the current element that matches the specified type. + /// + /// The type of the ancestor element to retrieve, which must implement . + /// An instance of the ancestor element of type , or if no matching ancestor is found. + /// + /// This method traverses the hierarchy of elements upwards, starting from the current element, to locate the first ancestor + /// that matches the specified type. If no such ancestor exists, it returns . + /// + TElement? GetParent() where TElement : ILogixElement; + + /// + /// Attempts to retrieve the parent element of the current as the specified type. + /// + /// The type of the parent element to retrieve. + /// When this method returns, contains the parent element if found; otherwise, the default value for . + /// + /// if the parent element of the specified type is found; otherwise, . + /// + bool TryGetParent(out TElement element) where TElement : ILogixElement; + /// /// Casts the current instance into the specified type, /// which must implement . @@ -133,11 +154,17 @@ protected LogixElement(XElement element) protected readonly XElement Element; /// - /// A list containing the order of any child elements for the current logix element. - /// By default, this is an empty collection, but derived classes can override this. When this collection contains names, - /// any adding of properties, containers, or complex types will then use this list to sort the order of the elements - /// in the underlying parent element. + /// Gets the explicit ordering of child elements for the current . /// + /// + /// A of representing the sequence in which child elements + /// should be arranged within the underlying L5X XML structure. + /// + /// + /// ElementOrder defines the hierarchy and serialization order of L5X elements, ensuring proper structure + /// and compliance with the L5X schema during export or import operations. Overriding this property allows + /// derived classes to specify a unique sequence tailored to their respective XML representations. + /// protected virtual List ElementOrder { get; } = []; /// @@ -158,6 +185,20 @@ public bool TryGetDocument(out L5X document) return true; } + /// + public TElement? GetParent() where TElement : ILogixElement + { + return GetAncestor(); + } + + /// + public bool TryGetParent(out TElement element) where TElement : ILogixElement + { + var ancestor = GetAncestor(); + element = ancestor!; + return ancestor is not null; + } + /// public TElement As() where TElement : ILogixElement { @@ -438,12 +479,12 @@ protected LogixContainer GetContainer([CallerMemberName] str /// to a L5X document then this will return null. Note that we only get parent but don't set it. A parent is /// defined by adding a given logix element to the corresponding parent logix container. /// - protected TElement? GetAncestor(string? ancestorName = null) where TElement : LogixElement + protected TElement? GetAncestor(string? ancestorName = null) where TElement : ILogixElement { ancestorName ??= LogixSerializer.NamesFor(typeof(TElement)).First(); if (!Element.TryGetAncestor(e => e.Name.LocalName == ancestorName, out var ancestor)) - return null; + return default; return ancestor.Deserialize(); } diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index 829ed393..2d2d9501 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -28,10 +28,10 @@ public void Empty_WhenCalled_ShouldHaveExpectedValues() { var tagName = TagName.Empty; - tagName.Base.Should().BeEmpty(); + tagName.BaseName.Should().BeEmpty(); tagName.Operand.Should().BeEmpty(); - tagName.Member.Should().BeEmpty(); - tagName.Element.Should().BeEmpty(); + tagName.MemberPath.Should().BeEmpty(); + tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); tagName.IsEmpty.Should().BeTrue(); tagName.IsQualified.Should().BeFalse(); @@ -42,11 +42,11 @@ public void New_BaseTagOnly_ShouldHaveExpectedProperties() { var tagName = new TagName("MyTag"); - tagName.Path.Should().Be("MyTag"); - tagName.Base.Should().Be("MyTag"); + tagName.FullPath.Should().Be("MyTag"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().BeEmpty(); - tagName.Member.Should().BeEmpty(); - tagName.Element.Should().BeEmpty(); + tagName.MemberPath.Should().BeEmpty(); + tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); } @@ -55,11 +55,11 @@ public void New_SingleMemberTagName_ShouldHaveExpectedValues() { var tagName = new TagName("MyTag.MemberName"); - tagName.Path.Should().Be("MyTag.MemberName"); - tagName.Base.Should().Be("MyTag"); + tagName.FullPath.Should().Be("MyTag.MemberName"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().Be(".MemberName"); - tagName.Member.Should().Be("MemberName"); - tagName.Element.Should().Be("MemberName"); + tagName.MemberPath.Should().Be("MemberName"); + tagName.MemberName.Should().Be("MemberName"); tagName.Depth.Should().Be(1); } @@ -68,11 +68,11 @@ public void New_MultipleMemberTagName_ShouldHaveExpectedValues() { var tagName = new TagName("MyTag.MemberName.SubMember"); - tagName.Path.Should().Be("MyTag.MemberName.SubMember"); - tagName.Base.Should().Be("MyTag"); + tagName.FullPath.Should().Be("MyTag.MemberName.SubMember"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().Be(".MemberName.SubMember"); - tagName.Member.Should().Be("MemberName.SubMember"); - tagName.Element.Should().Be("SubMember"); + tagName.MemberPath.Should().Be("MemberName.SubMember"); + tagName.MemberName.Should().Be("SubMember"); tagName.Depth.Should().Be(2); } @@ -81,11 +81,11 @@ public void New_TagNameWithArrayIndex_ShouldHaveExpectedValues() { var tagName = new TagName("MyTag[13].MemberName"); - tagName.Path.Should().Be("MyTag[13].MemberName"); - tagName.Base.Should().Be("MyTag"); + tagName.FullPath.Should().Be("MyTag[13].MemberName"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().Be("[13].MemberName"); - tagName.Member.Should().Be("[13].MemberName"); - tagName.Element.Should().Be("MemberName"); + tagName.MemberPath.Should().Be("[13].MemberName"); + tagName.MemberName.Should().Be("MemberName"); tagName.Depth.Should().Be(2); } @@ -94,11 +94,11 @@ public void New_TagNameWithBitReference_ShouldHaveExpectedValues() { var tagName = new TagName("MyTag.MemberName.1"); - tagName.Path.Should().Be("MyTag.MemberName.1"); - tagName.Base.Should().Be("MyTag"); + tagName.FullPath.Should().Be("MyTag.MemberName.1"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().Be(".MemberName.1"); - tagName.Member.Should().Be("MemberName.1"); - tagName.Element.Should().Be("1"); + tagName.MemberPath.Should().Be("MemberName.1"); + tagName.MemberName.Should().Be("1"); tagName.Depth.Should().Be(2); } @@ -107,11 +107,11 @@ public void New_ComplexTagName_ShouldBeExpected() { var tagName = new TagName("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - tagName.Path.Should().Be("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - tagName.Base.Should().Be("Module:1:I"); + tagName.FullPath.Should().Be("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); + tagName.BaseName.Should().Be("Module:1:I"); tagName.Operand.Should().Be(".TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - tagName.Member.Should().Be("TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - tagName.Element.Should().Be("12"); + tagName.MemberPath.Should().Be("TagName.Member[1].SubTag.Another[12,13,14].Value.12"); + tagName.MemberName.Should().Be("12"); tagName.Depth.Should().Be(8); } @@ -138,12 +138,12 @@ public void Scope_WithProgramPrefix_ShouldHaveExpectedProperties() { var tagName = new TagName("Program:SomeProgram.MyTag"); - tagName.Path.Should().Be("Program:SomeProgram.MyTag"); + tagName.FullPath.Should().Be("Program:SomeProgram.MyTag"); tagName.LocalPath.Should().Be("MyTag"); - tagName.Base.Should().Be("MyTag"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().BeEmpty(); - tagName.Member.Should().BeEmpty(); - tagName.Element.Should().BeEmpty(); + tagName.MemberPath.Should().BeEmpty(); + tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); } @@ -152,12 +152,12 @@ public void Scope_WithProgramPrefixAndMemberTag_ShouldHaveExpectedProperties() { var tagName = new TagName("Program:SomeProgram.MyTag.Member[0].Value.12"); - tagName.Path.Should().Be("Program:SomeProgram.MyTag.Member[0].Value.12"); + tagName.FullPath.Should().Be("Program:SomeProgram.MyTag.Member[0].Value.12"); tagName.LocalPath.Should().Be("MyTag.Member[0].Value.12"); - tagName.Base.Should().Be("MyTag"); + tagName.BaseName.Should().Be("MyTag"); tagName.Operand.Should().Be(".Member[0].Value.12"); - tagName.Member.Should().Be("Member[0].Value.12"); - tagName.Element.Should().Be("12"); + tagName.MemberPath.Should().Be("Member[0].Value.12"); + tagName.MemberName.Should().Be("12"); tagName.Depth.Should().Be(4); } @@ -173,7 +173,7 @@ public void Base_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); - var path = tagName.Base; + var path = tagName.BaseName; path.Should().Be(expected); } @@ -187,7 +187,7 @@ public void Base_WhenCalledManyTimes_ShouldBeEfficient() .ToList(); var stopwatch = Stopwatch.StartNew(); - var elements = tags.Select(t => t.Base).ToList(); + var elements = tags.Select(t => t.BaseName).ToList(); stopwatch.Stop(); Console.WriteLine(stopwatch.Elapsed); @@ -206,7 +206,7 @@ public void Member_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); - var path = tagName.Member; + var path = tagName.MemberPath; path.Should().Be(expected); } @@ -220,7 +220,7 @@ public void Member_CalledOnLargeList_ShouldExecuteInExpectedTime() .ToList(); var stopwatch = Stopwatch.StartNew(); - var elements = tags.Select(t => t.Member).ToList(); + var elements = tags.Select(t => t.MemberPath).ToList(); stopwatch.Stop(); Console.WriteLine(stopwatch.Elapsed); @@ -237,7 +237,7 @@ public void Element_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); - var element = tagName.Element; + var element = tagName.MemberName; element.Should().Be(expected); } @@ -251,7 +251,7 @@ public void Element_CalledOnLargeList_ShouldExecuteInExpectedTime() .ToList(); var stopwatch = Stopwatch.StartNew(); - var elements = tags.Select(t => t.Element).ToList(); + var elements = tags.Select(t => t.MemberName).ToList(); stopwatch.Stop(); Console.WriteLine(stopwatch.Elapsed); diff --git a/tests/L5Sharp.Tests.Core/Components/TagTests.cs b/tests/L5Sharp.Tests.Core/Components/TagTests.cs index 73521da1..2d072b7c 100644 --- a/tests/L5Sharp.Tests.Core/Components/TagTests.cs +++ b/tests/L5Sharp.Tests.Core/Components/TagTests.cs @@ -820,7 +820,7 @@ public void Members_TagNameEqualToMemberWithMemberNameComparer_ShouldReturnExpec Value = new MyNestedData() }; - var members = tag.Members(t => t.Element == "M1"); + var members = tag.Members(t => t.MemberName == "M1"); members.Should().HaveCount(1); } diff --git a/tests/L5Sharp.Tests.Core/Examples.cs b/tests/L5Sharp.Tests.Core/Examples.cs index 29a82b0e..d3a12992 100644 --- a/tests/L5Sharp.Tests.Core/Examples.cs +++ b/tests/L5Sharp.Tests.Core/Examples.cs @@ -18,15 +18,6 @@ public void SampleQuery001() .ToList(); results.Should().NotBeEmpty(); - - var program = new Program(); - // Create a duplicate with updated config. - var duplicate = program.Duplicate(p => - { - p.Name = "Program_02"; - p.Description = "This is a different instance"; - p.Replace("Tag_01", "Tag_02"); //This method will perform find/replace of text in the new object. - }); } [Test] diff --git a/tests/L5Sharp.Tests.Gateway/ExtensionTests.cs b/tests/L5Sharp.Tests.Gateway/ExtensionTests.cs index c897e945..003c2380 100644 --- a/tests/L5Sharp.Tests.Gateway/ExtensionTests.cs +++ b/tests/L5Sharp.Tests.Gateway/ExtensionTests.cs @@ -28,7 +28,7 @@ public async Task UploadAsync_AllTags_ShouldHaveExpectedResponse() //Get the 1000 auto-generated tags to verify they read a value. var tags = content.Query() - .Where(t => t.IsPublic() && t.TagName.Base.StartsWith("Tag_") && t.TagName != "Tag_0") + .Where(t => t.IsPublic() && t.TagName.BaseName.StartsWith("Tag_") && t.TagName != "Tag_0") .SelectMany(t => t.Members()) .ToList(); From 88c201bba97272035c0db170851d81d4b333f361 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Mon, 18 May 2026 10:47:12 -0500 Subject: [PATCH 02/13] Incremented version to 7.0.0 for major release. --- src/L5Sharp.Core/L5Sharp.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/L5Sharp.Core/L5Sharp.Core.csproj b/src/L5Sharp.Core/L5Sharp.Core.csproj index 9bee4b33..eda7ebf1 100644 --- a/src/L5Sharp.Core/L5Sharp.Core.csproj +++ b/src/L5Sharp.Core/L5Sharp.Core.csproj @@ -11,7 +11,7 @@ L5Sharp A library for intuitively interacting with Rockwell's L5X import/export files. Timothy Nunnink - 6.0.7 + 7.0.0 https://github.com/tnunnink/L5Sharp git csharp allen-bradely l5x logix plc-programming rockwell-automation logix5000 From 7615a05a63d2a599fd858d98ae3aad54e33e9f5d Mon Sep 17 00:00:00 2001 From: tnunnink Date: Mon, 18 May 2026 10:50:28 -0500 Subject: [PATCH 03/13] Refactored test method names in `TagNameTests` for improved naming consistency and clarity. --- tests/L5Sharp.Tests.Core/Common/TagNameTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index 2d2d9501..8d8ab5f4 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -169,7 +169,7 @@ public void Scope_WithProgramPrefixAndMemberTag_ShouldHaveExpectedProperties() [TestCase(".Member[32].Value", "")] [TestCase("Member[32].Value", "Member")] [TestCase("[32].Value", "[32]")] - public void Base_WhenCalled_ShouldBeExpected(string value, string expected) + public void BaseName_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); @@ -179,7 +179,7 @@ public void Base_WhenCalled_ShouldBeExpected(string value, string expected) } [Test] - public void Base_WhenCalledManyTimes_ShouldBeEfficient() + public void BaseName_WhenCalledManyTimes_ShouldBeEfficient() { var tags = Enumerable.Range(0, 1000000) .Select(i => $"MyTagName_{i}.SomeMember.21") @@ -202,7 +202,7 @@ public void Base_WhenCalledManyTimes_ShouldBeEfficient() [TestCase("MyTag[1].SomeMember.1", "[1].SomeMember.1")] [TestCase("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12", "TagName.Member[1].SubTag.Another[12,13,14].Value.12")] - public void Member_WhenCalled_ShouldBeExpected(string value, string expected) + public void MemberPath_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); @@ -212,7 +212,7 @@ public void Member_WhenCalled_ShouldBeExpected(string value, string expected) } [Test] - public void Member_CalledOnLargeList_ShouldExecuteInExpectedTime() + public void MemberPath_CalledOnLargeList_ShouldExecuteInExpectedTime() { var tags = Enumerable.Range(0, 1000000) .Select(i => $"MyTagName_{i}.SomeMember.Target") @@ -233,7 +233,7 @@ public void Member_CalledOnLargeList_ShouldExecuteInExpectedTime() [TestCase("", "")] [TestCase("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12", "12")] [TestCase("MyTag.Member[1]", "[1]")] - public void Element_WhenCalled_ShouldBeExpected(string value, string expected) + public void MemberName_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); @@ -243,7 +243,7 @@ public void Element_WhenCalled_ShouldBeExpected(string value, string expected) } [Test] - public void Element_CalledOnLargeList_ShouldExecuteInExpectedTime() + public void MemberName_CalledOnLargeList_ShouldExecuteInExpectedTime() { var tags = Enumerable.Range(0, 1000000) .Select(i => $"MyTagName_{i}.SomeMember.Target") From 3d71296bea5849234c4955856df2e433619f772d Mon Sep 17 00:00:00 2001 From: tnunnink Date: Mon, 18 May 2026 11:07:58 -0500 Subject: [PATCH 04/13] Refactored `TagName` API to rename `Operand` to `RelativePath` and `Scrape` to `ExtractAll` for improved clarity and consistency. Updated all references, methods, and tests to align with the new naming. --- src/L5Sharp.Core/Common/Argument.cs | 2 +- src/L5Sharp.Core/Common/TagName.cs | 44 +++++++++---------- src/L5Sharp.Core/Components/Tag.cs | 4 +- .../L5Sharp.Tests.Core/Common/TagNameTests.cs | 36 +++++++-------- 4 files changed, 41 insertions(+), 45 deletions(-) diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index df23afa7..6ab913cd 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -221,7 +221,7 @@ private static IEnumerable ExtractTags(string argument) return [argument]; if (type == ArgumentType.Expression) - return TagName.Scrape(argument); + return TagName.ExtractAll(argument); return []; } diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index 08b8394e..ba9e7d7e 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -110,37 +110,33 @@ public TagName(string name) /// /// Represents the hierarchical portion of the tag name that excludes the base tag name and is stripped of the leading separator. /// This property returns the remaining portion of the tag name, starting from the first member or array notation, - /// if applicable, after the base tag. It is derived from the property by removing the leading separator. + /// if applicable, after the base tag. It is derived from the property by removing the leading separator. /// Commonly used for accessing specific levels of a tag's hierarchy within complex or structured tag definitions. /// - public string MemberPath => GetOperand(_path).TrimStart(Separator); + public string MemberPath => GetRelativePath(_path).TrimStart(Separator); /// - /// Represents the operand portion of the tag path, including all members, elements, and indices, - /// excluding the base portion of the tag path. This string begins with the separator used between - /// the base and later members, providing a detailed representation of the tag structure beyond the base. - /// Used for operations requiring deeper levels of the tag hierarchy. + /// Represents the portion of the tag name that follows the base tag name, containing all members, + /// elements, and indices if present, including the leading separator for context. + /// The relative path provides a hierarchical breakdown of the tag's structure beyond its base name. /// - public string Operand => GetOperand(_path); + public string RelativePath => GetRelativePath(_path); /// - /// Gets the base portion of the tag name or an empty string if not defined. + /// The base name of the tag represented by the instance. + /// This string corresponds to the root tag name, excluding any member accessor, + /// indices, or hierarchy information. It is derived from the full path and is used + /// in scenarios where only the top-level tag name is required. /// - /// - /// The Base part of a tag name is the beginning part of the tag name up to the first member - /// separator character (e.g., '.' or '['). For Module-defined tags, this includes the colon separator. - /// public string BaseName => GetBase(_path); /// - /// Retrieves the terminal component or final segment of the tag path represented by the instance. + /// Gets the immediate member name of the tag represented by the current instance. + /// The member name refers to the most specific, non-hierarchical segment of the tag, typically + /// the final component following any hierarchical path or indexing. + /// If the tag does not include any hierarchical or member path, the value will be an empty string. /// - /// - /// This property isolates the most specific portion of the logical reference, such as the last member, - /// submember, or indexed element in a hierarchical path structure. - /// It is particularly useful for identifying the immediate target or endpoint in a tag hierarchy. - /// - public string MemberName => GetMember(_path); + public string? MemberName => GetMember(_path); /// /// A zero-based number representing the depth of the tag name. In other words, the number of members @@ -302,7 +298,7 @@ public bool Contains(TagName tagName) /// the structural path intact. For example, renaming "OldTag.Member[1].Value" with the base name "NewTag" /// would result in "NewTag.Member[1].Value". /// - public TagName Rename(string baseName) => Combine(baseName, Operand); + public TagName Rename(string baseName) => Combine(baseName, RelativePath); /// public int CompareTo(TagName? other) @@ -337,7 +333,7 @@ public override bool Equals(object? obj) /// rung of neutral text in which multiple tag names are embedded. /// /// A collection of objects representing the tags found within the input text. - public static IEnumerable Scrape(string text) + public static IEnumerable ExtractAll(string text) { return TagNamePattern.Matches(text).Cast().Select(m => new TagName(m.Value)); } @@ -441,7 +437,7 @@ private static string GetBase(string path) /// /// Retrieves the operand portion of a tag name from the provided path string. /// - private static string GetOperand(string path) + private static string GetRelativePath(string path) { var tagName = GetLocalTagName(path); var separator = tagName.IndexOfAny([Separator, ArrayOpen]); @@ -452,12 +448,12 @@ private static string GetOperand(string path) /// Gets the last member of the tag name path, or the portion of the string from the last member separator to the /// end of the string. We are calling this the element. /// - private static string GetMember(string path) + private static string? GetMember(string path) { var tagName = GetLocalTagName(path); var lastSeparator = tagName.LastIndexOfAny([Separator, ArrayOpen]); - if (lastSeparator < 0) return string.Empty; + if (lastSeparator < 0) return null; var length = tagName.Length - lastSeparator; return tagName.Substring(lastSeparator, length).TrimStart(Separator); diff --git a/src/L5Sharp.Core/Components/Tag.cs b/src/L5Sharp.Core/Components/Tag.cs index d8f896a3..68549213 100644 --- a/src/L5Sharp.Core/Components/Tag.cs +++ b/src/L5Sharp.Core/Components/Tag.cs @@ -968,7 +968,7 @@ private void SetTagDescription(string? value) return; } - Comments!.Add(new Comment(TagName.Operand, value)); + Comments!.Add(new Comment(TagName.RelativePath, value)); } /// @@ -998,7 +998,7 @@ private void SetUnit(string? value) return; } - Units!.Add(new Unit(TagName.Operand, value)); + Units!.Add(new Unit(TagName.RelativePath, value)); } /// diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index 8d8ab5f4..b3303ff7 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -29,7 +29,7 @@ public void Empty_WhenCalled_ShouldHaveExpectedValues() var tagName = TagName.Empty; tagName.BaseName.Should().BeEmpty(); - tagName.Operand.Should().BeEmpty(); + tagName.RelativePath.Should().BeEmpty(); tagName.MemberPath.Should().BeEmpty(); tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); @@ -43,9 +43,9 @@ public void New_BaseTagOnly_ShouldHaveExpectedProperties() var tagName = new TagName("MyTag"); tagName.FullPath.Should().Be("MyTag"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().BeEmpty(); + tagName.RelativePath.Should().BeEmpty(); tagName.MemberPath.Should().BeEmpty(); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); } @@ -56,9 +56,9 @@ public void New_SingleMemberTagName_ShouldHaveExpectedValues() var tagName = new TagName("MyTag.MemberName"); tagName.FullPath.Should().Be("MyTag.MemberName"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().Be(".MemberName"); + tagName.RelativePath.Should().Be(".MemberName"); tagName.MemberPath.Should().Be("MemberName"); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().Be("MemberName"); tagName.Depth.Should().Be(1); } @@ -69,9 +69,9 @@ public void New_MultipleMemberTagName_ShouldHaveExpectedValues() var tagName = new TagName("MyTag.MemberName.SubMember"); tagName.FullPath.Should().Be("MyTag.MemberName.SubMember"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().Be(".MemberName.SubMember"); + tagName.RelativePath.Should().Be(".MemberName.SubMember"); tagName.MemberPath.Should().Be("MemberName.SubMember"); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().Be("SubMember"); tagName.Depth.Should().Be(2); } @@ -82,9 +82,9 @@ public void New_TagNameWithArrayIndex_ShouldHaveExpectedValues() var tagName = new TagName("MyTag[13].MemberName"); tagName.FullPath.Should().Be("MyTag[13].MemberName"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().Be("[13].MemberName"); + tagName.RelativePath.Should().Be("[13].MemberName"); tagName.MemberPath.Should().Be("[13].MemberName"); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().Be("MemberName"); tagName.Depth.Should().Be(2); } @@ -95,9 +95,9 @@ public void New_TagNameWithBitReference_ShouldHaveExpectedValues() var tagName = new TagName("MyTag.MemberName.1"); tagName.FullPath.Should().Be("MyTag.MemberName.1"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().Be(".MemberName.1"); + tagName.RelativePath.Should().Be(".MemberName.1"); tagName.MemberPath.Should().Be("MemberName.1"); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().Be("1"); tagName.Depth.Should().Be(2); } @@ -108,9 +108,9 @@ public void New_ComplexTagName_ShouldBeExpected() var tagName = new TagName("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); tagName.FullPath.Should().Be("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - tagName.BaseName.Should().Be("Module:1:I"); - tagName.Operand.Should().Be(".TagName.Member[1].SubTag.Another[12,13,14].Value.12"); + tagName.RelativePath.Should().Be(".TagName.Member[1].SubTag.Another[12,13,14].Value.12"); tagName.MemberPath.Should().Be("TagName.Member[1].SubTag.Another[12,13,14].Value.12"); + tagName.BaseName.Should().Be("Module:1:I"); tagName.MemberName.Should().Be("12"); tagName.Depth.Should().Be(8); } @@ -140,9 +140,9 @@ public void Scope_WithProgramPrefix_ShouldHaveExpectedProperties() tagName.FullPath.Should().Be("Program:SomeProgram.MyTag"); tagName.LocalPath.Should().Be("MyTag"); - tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().BeEmpty(); + tagName.RelativePath.Should().BeEmpty(); tagName.MemberPath.Should().BeEmpty(); + tagName.BaseName.Should().Be("MyTag"); tagName.MemberName.Should().BeEmpty(); tagName.Depth.Should().Be(0); } @@ -155,7 +155,7 @@ public void Scope_WithProgramPrefixAndMemberTag_ShouldHaveExpectedProperties() tagName.FullPath.Should().Be("Program:SomeProgram.MyTag.Member[0].Value.12"); tagName.LocalPath.Should().Be("MyTag.Member[0].Value.12"); tagName.BaseName.Should().Be("MyTag"); - tagName.Operand.Should().Be(".Member[0].Value.12"); + tagName.RelativePath.Should().Be(".Member[0].Value.12"); tagName.MemberPath.Should().Be("Member[0].Value.12"); tagName.MemberName.Should().Be("12"); tagName.Depth.Should().Be(4); @@ -259,7 +259,7 @@ public void MemberName_CalledOnLargeList_ShouldExecuteInExpectedTime() elements.Should().AllSatisfy(s => s.Should().Be("Target")); stopwatch.Elapsed.Seconds.Should().BeLessThan(1); } - + [Test] [TestCase("", 0)] [TestCase("MyTag.SomeMember.1", 2)] @@ -299,7 +299,7 @@ public void Members_ComplexTag_ShouldContainExpectedValues() public void Members_ToSpecifiedDepth_ShouldBeExpected() { var tagName = new TagName("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12"); - + var members = tagName.Members(3).ToList(); members.Count.Should().Be(3); From b5b7249e914554cacdcd919659cee314f1b588c5 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Mon, 18 May 2026 12:58:19 -0500 Subject: [PATCH 05/13] Added regex-based atomic pattern matching to `Argument.Values` for enhanced parsing of diverse atomic formats. Updated tests to ensure coverage for multiple argument scenarios. --- src/L5Sharp.Core/Common/Argument.cs | 20 +++++-- .../Common/ArgumentTests.cs | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index 6ab913cd..e1fc5c97 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; namespace L5Sharp.Core; @@ -12,6 +13,16 @@ namespace L5Sharp.Core; /// public class Argument { + /// + /// A compiled regular expression pattern used to identify atomic values within an argument. + /// The pattern supports various formats, including hexadecimal, binary, octal, floating-point, + /// signed integers, boolean literals, and special markers like #QNAN and #IND. + /// This enables parsing and validation of diverse atomic data types in instruction arguments. + /// + private static readonly Regex AtomicPattern = new( + @"(?:16#[0-9a-fA-F_]+)|(?:2#[01_]+)|(?:8#[0-7_]+)|(?:[A-Z]{1,4}#[^ ]+)|(?:[+-]?\d+\.\d+(?:[eE][+-]?\d+)?)|(?:[+-]?\d+)|(?:#QNAN|#IND)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// /// The value typically found in Studio for undefined argument values in certain instructions. /// @@ -237,9 +248,12 @@ private static IEnumerable ExtractValues(string argument) return [AtomicData.Parse(argument)]; if (type == ArgumentType.Expression) - //todo handle nested values in expression - return []; - + { + return AtomicPattern.Matches(argument) + .Cast() + .Select(m => AtomicData.Parse(m.Value)); + } + return []; } diff --git a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs index c1482ff7..75698218 100644 --- a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs @@ -158,4 +158,56 @@ public void Values_ArgumentSingleAtomic_ShouldHaveExpectedCount() values.Should().HaveCount(1); } + + [Test] + public void Values_ExpressionWithSingleAtomic_ShouldHaveExpectedValue() + { + Argument argument = "MyTag > 100"; + + var values = argument.Values; + + values.Should().HaveCount(1); + values[0].Should().Be(new DINT(100)); + } + + [Test] + public void Values_ExpressionWithMultipleAtomics_ShouldHaveExpectedValues() + { + Argument argument = "MyTag > 100 AND MyOtherTag < 16#ABCD"; + + var values = argument.Values; + + values.Should().HaveCount(2); + values[0].Should().Be(new DINT(100)); + values[1].Should().Be(new DINT(43981)); // 16#ABCD + } + + [Test] + public void Values_ExpressionWithVariousAtomicFormats_ShouldExtractAll() + { + Argument argument = "16#1234 + 2#1010 + 8#77 + DT#2023-05-18-11:08:00Z + 1.23 + 123 + 1.#QNAN"; + + var values = argument.Values; + + values.Should().HaveCount(7); + values.Select(v => v.ToString()).Should().Contain([ + "16#0000_1234", + "2#0000_0000_0000_0000_0000_0000_0000_1010", + "8#0000_0000_077", + "DT#2023-05-18-11:08:00Z", + "1.23", + "123", + "1.#QNAN" + ]); + } + + [Test] + public void Values_ExpressionWithNoAtomics_ShouldBeEmpty() + { + Argument argument = "MyTag + OtherTag"; + + var values = argument.Values; + + values.Should().BeEmpty(); + } } \ No newline at end of file From 545bf7e0cb0a3b979e089d7b427fc0b8cab9eb3d Mon Sep 17 00:00:00 2001 From: tnunnink Date: Tue, 19 May 2026 08:56:16 -0500 Subject: [PATCH 06/13] Added comprehensive validation logic using regex patterns to `Radix` types for enhanced format checking. Updated corresponding tests to ensure complete coverage for validity checks. Refined exception handling in `Parse` methods for consistent error responses. --- src/L5Sharp.Core/Common/TagName.cs | 12 ++- src/L5Sharp.Core/Data/Atomics/LREAL.cs | 4 +- src/L5Sharp.Core/Data/Atomics/REAL.cs | 2 +- src/L5Sharp.Core/Enums/Radix.cs | 94 ++++++++----------- .../L5Sharp.Tests.Core/Common/TagNameTests.cs | 26 ++--- .../Enums/RadixBinaryTests.cs | 20 +++- .../Enums/RadixDateTimeNsTests.cs | 12 +++ .../Enums/RadixDateTimeTests.cs | 13 +++ .../Enums/RadixDecimalTests.cs | 17 ++++ .../Enums/RadixExponentialTests.cs | 14 +++ .../Enums/RadixFloatTests.cs | 21 +++++ .../L5Sharp.Tests.Core/Enums/RadixHexTests.cs | 25 +++-- .../Enums/RadixOctalTests.cs | 22 ++++- .../Enums/RadixTime32Tests.cs | 14 +++ .../Enums/RadixTimeNsTests.cs | 26 +++-- .../Enums/RadixTimeTests.cs | 14 +++ 16 files changed, 238 insertions(+), 98 deletions(-) diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index ba9e7d7e..10412a41 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -112,15 +112,17 @@ public TagName(string name) /// This property returns the remaining portion of the tag name, starting from the first member or array notation, /// if applicable, after the base tag. It is derived from the property by removing the leading separator. /// Commonly used for accessing specific levels of a tag's hierarchy within complex or structured tag definitions. + /// Returns null if the tag has no member path. /// - public string MemberPath => GetRelativePath(_path).TrimStart(Separator); + public string? MemberPath => GetRelativePath(_path)?.TrimStart(Separator); /// /// Represents the portion of the tag name that follows the base tag name, containing all members, /// elements, and indices if present, including the leading separator for context. /// The relative path provides a hierarchical breakdown of the tag's structure beyond its base name. + /// Returns null if the tag has no relative path. /// - public string RelativePath => GetRelativePath(_path); + public string? RelativePath => GetRelativePath(_path); /// /// The base name of the tag represented by the instance. @@ -134,7 +136,7 @@ public TagName(string name) /// Gets the immediate member name of the tag represented by the current instance. /// The member name refers to the most specific, non-hierarchical segment of the tag, typically /// the final component following any hierarchical path or indexing. - /// If the tag does not include any hierarchical or member path, the value will be an empty string. + /// If the tag does not include any hierarchical or member path, the value will be null. /// public string? MemberName => GetMember(_path); @@ -437,11 +439,11 @@ private static string GetBase(string path) /// /// Retrieves the operand portion of a tag name from the provided path string. /// - private static string GetRelativePath(string path) + private static string? GetRelativePath(string path) { var tagName = GetLocalTagName(path); var separator = tagName.IndexOfAny([Separator, ArrayOpen]); - return separator >= 0 ? tagName.Substring(separator) : string.Empty; + return separator >= 0 ? tagName.Substring(separator) : null; } /// diff --git a/src/L5Sharp.Core/Data/Atomics/LREAL.cs b/src/L5Sharp.Core/Data/Atomics/LREAL.cs index b2b111ac..4a878b3c 100644 --- a/src/L5Sharp.Core/Data/Atomics/LREAL.cs +++ b/src/L5Sharp.Core/Data/Atomics/LREAL.cs @@ -36,9 +36,7 @@ public LREAL(double value) : this() /// public double Value { - get => Element.Attribute(L5XName.Value)?.Value.Contains("QNAN") is false - ? GetAtomicValue() - : double.NaN; + get => GetAtomicValue(); set => SetAtomicValue(value); } diff --git a/src/L5Sharp.Core/Data/Atomics/REAL.cs b/src/L5Sharp.Core/Data/Atomics/REAL.cs index dab96612..479807fc 100644 --- a/src/L5Sharp.Core/Data/Atomics/REAL.cs +++ b/src/L5Sharp.Core/Data/Atomics/REAL.cs @@ -36,7 +36,7 @@ public REAL(float value) : this() /// public float Value { - get => Element.Attribute(L5XName.Value)?.Value.Contains("QNAN") is false ? GetAtomicValue() : float.NaN; + get => GetAtomicValue(); set => SetAtomicValue(value); } diff --git a/src/L5Sharp.Core/Enums/Radix.cs b/src/L5Sharp.Core/Enums/Radix.cs index ff0a1a80..ce8804c3 100644 --- a/src/L5Sharp.Core/Enums/Radix.cs +++ b/src/L5Sharp.Core/Enums/Radix.cs @@ -229,10 +229,11 @@ private class BinaryRadix() : Radix(nameof(Binary), nameof(Binary)) private const int BaseNumber = 2; private const int SegmentSize = 4; private const string Separator = "_"; + private static readonly Regex ValidPattern = new("^2#[01_]*[01][01_]*$", RegexOptions.Compiled); public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } public override string Format(TValue value) where TValue : struct @@ -273,10 +274,11 @@ private class OctalRadix() : Radix(nameof(Octal), nameof(Octal)) private const int BaseNumber = 8; private const int SegmentSize = 3; private const string Separator = "_"; + private static readonly Regex ValidPattern = new("^8#[0-7_]*[0-7][0-7_]*$", RegexOptions.Compiled); public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } public override string Format(TValue value) where TValue : struct @@ -311,17 +313,12 @@ private class DecimalRadix() : Radix(nameof(Decimal), nameof(Decimal)) { private const int BaseNumber = 10; + //Decimal needs to handle true/false values since L5X contains specialized tag data formats which use these values. + private static readonly Regex ValidPattern = new(@"^(?:[+-]?\d+|true|false)$", RegexOptions.Compiled); + public override bool IsValid(string? value) { - if (value is null) - return false; - - return value.ToLower() switch - { - "true" => true, - "false" => true, - _ => value.All(c => char.IsDigit(c) || c == '+' || c == '-') - }; + return value is not null && ValidPattern.IsMatch(value); } public override string Format(TValue value) where TValue : struct @@ -358,9 +355,12 @@ private class HexRadix() : Radix(nameof(Hex), nameof(Hex)) private const int SegmentSize = 4; private const string Separator = "_"; + private static readonly Regex ValidPattern = + new("^16#[0-9a-fA-F_]*[0-9a-fA-F][0-9a-fA-F_]*$", RegexOptions.Compiled); + public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } public override string Format(TValue value) where TValue : struct @@ -393,27 +393,16 @@ public override TValue Parse(string value) /// private class FloatRadix() : Radix(nameof(Float), nameof(Float)) { - // This is IEEE 754 floating-point representation known as Indeterminate (NaN). - // Not sure why Rockwell uses both this and QNAN, but we want to avoid parsing exceptions. - // ReSharper disable once InconsistentNaming - private const string IND = "#IND"; - private const string QNAN = "#QNAN"; + private const string QNAN = "1.#QNAN"; private const string DoubleFormat = "0.0##############"; private const string SingleFormat = "0.0######"; - private static readonly HashSet ValidCharacters = ['.', '+', '-']; + + private static readonly Regex ValidPattern = + new(@"^(?:[+-]?\d+\.\d+|(?:[+-]?\d+\.)?#QNAN|(?:[+-]?\d+\.)?#IND)$", RegexOptions.Compiled); public override bool IsValid(string? value) { - if (value is null || string.IsNullOrEmpty(value)) return false; - if (value.EndsWith(QNAN) || value.EndsWith(IND)) return true; - - foreach (var c in value) - { - if (char.IsDigit(c)) continue; - if (!ValidCharacters.Contains(c)) return false; - } - - return true; + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -429,8 +418,8 @@ public override string Format(TValue value) where TValue : struct return value switch { float.NaN => QNAN, - float a => a.ToString(SingleFormat, CultureInfo.InvariantCulture), double.NaN => QNAN, + float a => a.ToString(SingleFormat, CultureInfo.InvariantCulture), double a => a.ToString(DoubleFormat, CultureInfo.InvariantCulture), _ => base.Format(value) }; @@ -455,30 +444,16 @@ public override TValue Parse(string value) /// private class ExponentialRadix() : Radix(nameof(Exponential), nameof(Exponential)) { - // This is IEEE 754 floating-point representation known as Indeterminate (NaN). - // Not sure why Rockwell uses both this and QNAN, but we want to avoid parsing exceptions. - // ReSharper disable once InconsistentNaming - private const string IND = "#IND"; - private const string QNAN = "#QNAN"; + private const string QNAN = "1.#QNAN"; private const string DoubleExponent = "e16"; private const string SingleExponent = "e8"; - private static readonly HashSet ValidCharacters = ['.', '+', '-', 'e', 'E']; + + private static readonly Regex ValidPattern = + new(@"^(?:[+-]?\d+\.\d+[eE][+-]?\d+|(?:[+-]?\d+\.)?#QNAN|(?:[+-]?\d+\.)?#IND)$", RegexOptions.Compiled); public override bool IsValid(string? value) { - if (value is null || string.IsNullOrEmpty(value)) return false; - if (value.EndsWith(QNAN) || value.EndsWith(IND)) return true; - - var hasExponent = false; - foreach (var c in value) - { - if (char.IsDigit(c)) continue; - if (!ValidCharacters.Contains(c)) return false; - if (c is 'e' or 'E') hasExponent = true; - } - - // Strict: If there's no 'e'/'E', it's NOT exponential (it's likely Float or Decimal) - return hasExponent; + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -493,6 +468,8 @@ public override string Format(TValue value) where TValue : struct return value switch { + float.NaN => QNAN, + double.NaN => QNAN, float a => a.ToString(SingleExponent, CultureInfo.InvariantCulture), double a => a.ToString(DoubleExponent, CultureInfo.InvariantCulture), _ => base.Format(value) @@ -647,6 +624,8 @@ private class DateTimeRadix() : Radix(nameof(DateTime), "Date/Time") private const string Separator = "_"; private const string Suffix = "Z"; private const string DateTimeFormat = "yyyy-MM-dd-HH:mm:ss.ffffff"; + private static readonly Regex ValidPattern = new(@"^DT#[\d\-_:.]+Z$", RegexOptions.Compiled); + private const long TicksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000; private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); private static readonly Func ValueConverter = l => l; @@ -654,7 +633,7 @@ private class DateTimeRadix() : Radix(nameof(DateTime), "Date/Time") public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -674,7 +653,6 @@ public override string Format(TValue value) where TValue : struct var time = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).AddTicks(ticks); var formatted = time.ToString(DateTimeFormat); - //var str = Regex.Replace(formatted, InsertPattern, Separator); return $"{Specifier}{formatted}{Suffix}"; } @@ -708,6 +686,7 @@ private class DateTimeNsRadix() : Radix(nameof(DateTimeNs), "Date/Time (ns)") private const string Separator = "_"; private const string Suffix = "00Z"; private const string DateTimeFormat = "yyyy-MM-dd-HH:mm:ss.fffffff"; + private static readonly Regex ValidPattern = new(@"^LDT#[\d\-_:.]+Z$", RegexOptions.Compiled); private const long NanosecondsPerTick = 100; private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); private static readonly Func ValueConverter = l => l; @@ -715,7 +694,7 @@ private class DateTimeNsRadix() : Radix(nameof(DateTimeNs), "Date/Time (ns)") public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -765,10 +744,11 @@ private class Time32Radix() : Radix(nameof(Time32), "Time32 (us)") private const string Specifier = "T32#"; private const string Separator = "_"; private static readonly Func ValueConverter = l => l; + private static readonly Regex ValidPattern = new(@"^T32#[+-]?(?:\d+[msu]s?_?)+$", RegexOptions.Compiled); public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -844,10 +824,11 @@ private class TimeRadix() : Radix(nameof(Time), "Time (us)") private const string Specifier = "T#"; private const string Separator = "_"; private static readonly Func ValueConverter = l => l; + private static readonly Regex ValidPattern = new(@"^T#[+-]?(?:\d+[dhmsu]s?_?)+$", RegexOptions.Compiled); public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -929,10 +910,11 @@ private class TimeNsRadix() : Radix(nameof(TimeNs), "LTime (ns)") private const string Specifier = "LT#"; private const string Separator = "_"; private static readonly Func ValueConverter = l => l; + private static readonly Regex ValidPattern = new(@"^LT#[+-]?(?:\d+[dhmsun]s?_?)+$", RegexOptions.Compiled); public override bool IsValid(string? value) { - return value is not null && value.StartsWith(Specifier); + return value is not null && ValidPattern.IsMatch(value); } protected override bool IsSupportedType(Type type) @@ -1362,7 +1344,7 @@ private static double ConvertToDouble(string value) { //So far I have found Logix export both #QNAN and #IND for undefined or not-a-number floating point values. if (value.EndsWith("#QNAN") || value.EndsWith("#IND")) - return float.NaN; + return double.NaN; return Convert.ToDouble(value, CultureInfo.InvariantCulture); } @@ -1393,6 +1375,6 @@ private void ValidateFormat(string value) throw new ArgumentException("Value can not be null or empty.", nameof(value)); if (!IsValid(value)) - throw new FormatException($"Invalid {Name} format: '{value}' "); + throw new FormatException($"Invalid {Name} format: '{value}'"); } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index b3303ff7..239961e5 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -29,9 +29,9 @@ public void Empty_WhenCalled_ShouldHaveExpectedValues() var tagName = TagName.Empty; tagName.BaseName.Should().BeEmpty(); - tagName.RelativePath.Should().BeEmpty(); - tagName.MemberPath.Should().BeEmpty(); - tagName.MemberName.Should().BeEmpty(); + tagName.RelativePath.Should().BeNull(); + tagName.MemberPath.Should().BeNull(); + tagName.MemberName.Should().BeNull(); tagName.Depth.Should().Be(0); tagName.IsEmpty.Should().BeTrue(); tagName.IsQualified.Should().BeFalse(); @@ -43,10 +43,10 @@ public void New_BaseTagOnly_ShouldHaveExpectedProperties() var tagName = new TagName("MyTag"); tagName.FullPath.Should().Be("MyTag"); - tagName.RelativePath.Should().BeEmpty(); - tagName.MemberPath.Should().BeEmpty(); + tagName.RelativePath.Should().BeNull(); + tagName.MemberPath.Should().BeNull(); tagName.BaseName.Should().Be("MyTag"); - tagName.MemberName.Should().BeEmpty(); + tagName.MemberName.Should().BeNull(); tagName.Depth.Should().Be(0); } @@ -140,10 +140,10 @@ public void Scope_WithProgramPrefix_ShouldHaveExpectedProperties() tagName.FullPath.Should().Be("Program:SomeProgram.MyTag"); tagName.LocalPath.Should().Be("MyTag"); - tagName.RelativePath.Should().BeEmpty(); - tagName.MemberPath.Should().BeEmpty(); + tagName.RelativePath.Should().BeNull(); + tagName.MemberPath.Should().BeNull(); tagName.BaseName.Should().Be("MyTag"); - tagName.MemberName.Should().BeEmpty(); + tagName.MemberName.Should().BeNull(); tagName.Depth.Should().Be(0); } @@ -197,12 +197,12 @@ public void BaseName_WhenCalledManyTimes_ShouldBeEfficient() } [Test] - [TestCase("", "")] + [TestCase("", null)] [TestCase("MyTag.SomeMember.1", "SomeMember.1")] [TestCase("MyTag[1].SomeMember.1", "[1].SomeMember.1")] [TestCase("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12", "TagName.Member[1].SubTag.Another[12,13,14].Value.12")] - public void MemberPath_WhenCalled_ShouldBeExpected(string value, string expected) + public void MemberPath_WhenCalled_ShouldBeExpected(string value, string? expected) { var tagName = new TagName(value); @@ -230,10 +230,10 @@ public void MemberPath_CalledOnLargeList_ShouldExecuteInExpectedTime() } [Test] - [TestCase("", "")] + [TestCase("", null)] [TestCase("Module:1:I.TagName.Member[1].SubTag.Another[12,13,14].Value.12", "12")] [TestCase("MyTag.Member[1]", "[1]")] - public void MemberName_WhenCalled_ShouldBeExpected(string value, string expected) + public void MemberName_WhenCalled_ShouldBeExpected(string value, string? expected) { var tagName = new TagName(value); diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixBinaryTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixBinaryTests.cs index 455d51dc..f3544bbc 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixBinaryTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixBinaryTests.cs @@ -24,6 +24,23 @@ public void Binary_WhenCalled_ShouldBeSameAsOtherInstance() first.Should().BeSameAs(second); } + [TestCase("2#0", true)] + [TestCase("2#1", true)] + [TestCase("2#0000_0000", true)] + [TestCase("2#1010_1100", true)] + [TestCase("2#10101100", true)] + [TestCase("2#", false)] + [TestCase("2#2", false)] + [TestCase("100", false)] + [TestCase("16#FF", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Binary.IsValid(value); + + result.Should().Be(expected); + } + [Test] [TestCase(false, "2#0")] [TestCase(true, "2#1")] @@ -141,8 +158,7 @@ public void Parse_NoSpecifier_ShouldThrowException() [Test] public void Parse_LengthZero_ShouldThrowException() { - FluentActions.Invoking(() => Radix.Binary.Parse("2#")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Binary.Parse("2#")).Should().Throw(); } [Test] diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeNsTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeNsTests.cs index a94758de..28faf4fc 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeNsTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeNsTests.cs @@ -92,5 +92,17 @@ public void Parse_ValidTimeExample3_ShouldBeExpectedFormat() result.Should().Be(1777473944498115700); } + + [Test] + [TestCase("LDT#2023-05-18-11:08:00.000000000Z", true)] + [TestCase("LDT#2023-05-18-11:08:00Z", true)] + [TestCase("LDT#2023-05-18-11:08:00.000000000", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.DateTimeNs.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeTests.cs index d93faa97..0222a957 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixDateTimeTests.cs @@ -61,5 +61,18 @@ public void Parse_ValidTimeExample2_ShouldBeExpectedValue() result.Should().Be(1641016800100100); } + + [Test] + [TestCase("DT#2023-05-18-11:08:00Z", true)] + [TestCase("DT#2023-05-18-11:08:00.123456Z", true)] + [TestCase("DT#2023-05-18-11:08:00", false)] + [TestCase("2023-05-18-11:08:00Z", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.DateTime.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixDecimalTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixDecimalTests.cs index c7927cb0..5f2b30ea 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixDecimalTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixDecimalTests.cs @@ -14,6 +14,23 @@ public void Decimal_WhenCalled_ShouldBeExpected() radix.Should().Be(Radix.Decimal); } + [Test] + [TestCase("123", true)] + [TestCase("-123", true)] + [TestCase("+123", true)] + [TestCase("0", true)] + [TestCase("true", true)] + [TestCase("false", true)] + [TestCase("1.23", false)] + [TestCase("16#FF", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Decimal.IsValid(value); + + result.Should().Be(expected); + } + [Test] [TestCase(false, "0")] [TestCase(true, "1")] diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixExponentialTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixExponentialTests.cs index b9c5a081..0ba363a8 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixExponentialTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixExponentialTests.cs @@ -89,5 +89,19 @@ public void Parse_Nan_ShouldBeExpected(string value) result.Should().Be(float.NaN); } + + [Test] + [TestCase("1.23e10", true)] + [TestCase("-1.23E-10", true)] + [TestCase("0.0e0", true)] + [TestCase("1.23", false)] + [TestCase("123", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Exponential.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixFloatTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixFloatTests.cs index 3d09b119..048276ab 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixFloatTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixFloatTests.cs @@ -104,5 +104,26 @@ public void Parse_Nan_ShouldBeExpected(string value) result.Should().Be(float.NaN); } + + [Test] + [TestCase("1.23", true)] + [TestCase("-1.23", true)] + [TestCase("+1.23", true)] + [TestCase("0.0", true)] + [TestCase("1.#QNAN", true)] + [TestCase("-1.#QNAN", true)] + [TestCase("#QNAN", true)] + [TestCase("1.#IND", true)] + [TestCase("-1.#IND", true)] + [TestCase("#IND", true)] + [TestCase("123", false)] + [TestCase("1.23e10", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Float.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixHexTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixHexTests.cs index 31a7bbf1..7978c7f0 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixHexTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixHexTests.cs @@ -65,22 +65,19 @@ public void Format_ValidLint_ShouldBeExpectedFormat() [Test] public void Parse_Null_ShouldThrowArgumentNullException() { - FluentActions.Invoking(() => Radix.Hex.Parse(null!)) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Hex.Parse(null!)).Should().Throw(); } [Test] public void Parse_InvalidSpecifier_ShouldThrowException() { - FluentActions.Invoking(() => Radix.Hex.Parse("0000_0024")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Hex.Parse("0000_0024")).Should().Throw(); } [Test] public void Parse_LengthZero_ShouldThrowException() { - FluentActions.Invoking(() => Radix.Hex.Parse("16#")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Hex.Parse("16#")).Should().Throw(); } [Test] @@ -132,5 +129,21 @@ public void Parse_ValidLint_ShouldBeExpected() value.Should().Be(20); } + + [Test] + [TestCase("16#1234", true)] + [TestCase("16#ABCD", true)] + [TestCase("16#abcd", true)] + [TestCase("16#1234_ABCD", true)] + [TestCase("16#", false)] + [TestCase("16#GHIJ", false)] + [TestCase("1234", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Hex.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixOctalTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixOctalTests.cs index 8b6f1a90..4cc3f0fb 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixOctalTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixOctalTests.cs @@ -153,15 +153,13 @@ public void Parse_Null_ShouldThrowArgumentNullException() [Test] public void Parse_NoSpecifier_ShouldThrowException() { - FluentActions.Invoking(() => Radix.Octal.Parse("00_000_000_024")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Octal.Parse("00_000_000_024")).Should().Throw(); } [Test] public void Parse_LengthZero_ShouldThrowException() { - FluentActions.Invoking(() => Radix.Octal.Parse("8#")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.Octal.Parse("8#")).Should().Throw(); } [Test] @@ -225,5 +223,21 @@ public void Parse_ValidLint_ShouldBeExpected(string value, long expected) result.Should().Be(expected); } + + [Test] + [TestCase("8#0", true)] + [TestCase("8#7", true)] + [TestCase("8#07", true)] + [TestCase("8#12_34", true)] + [TestCase("8#", false)] + [TestCase("8#8", false)] + [TestCase("123", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Octal.IsValid(value); + + result.Should().Be(expected); + } } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixTime32Tests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixTime32Tests.cs index 8d896091..fa4b0c19 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixTime32Tests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixTime32Tests.cs @@ -15,6 +15,20 @@ public void Time32_WhenCalled_ShouldBeExpected() radix.Value.Should().Be("Time32 (us)"); } + [Test] + [TestCase("T32#35m_47s", true)] + [TestCase("T32#-500ms", true)] + [TestCase("T32#0us", true)] + [TestCase("T32#35m_47s_483ms_647us", true)] + [TestCase("T32#1d", false)] // Time32 doesn't support 'd' unit in Radix.cs + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Time32.IsValid(value); + + result.Should().Be(expected); + } + [Test] [TestCase(0, "T32#0us")] [TestCase(1, "T32#1us")] diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixTimeNsTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixTimeNsTests.cs index cbd2247b..7fedb7d8 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixTimeNsTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixTimeNsTests.cs @@ -15,6 +15,20 @@ public void TimeNs_WhenCalled_ShouldBeExpected() radix.Value.Should().Be("LTime (ns)"); } + [Test] + [TestCase("LT#10d_7h", true)] + [TestCase("LT#-500ns", true)] + [TestCase("LT#1d_2h_3m_4s_5ms_6us_7ns", true)] + [TestCase("LT#0ns", true)] + [TestCase("123ns", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.TimeNs.IsValid(value); + + result.Should().Be(expected); + } + [Test] [TestCase(0, "LT#0ns")] [TestCase(1, "LT#1ns")] @@ -33,29 +47,25 @@ public void Format_ValidExamples_ShouldBeExpected(long value, string expected) [Test] public void Parse_NullValue_ShouldThrowArgumentException() { - FluentActions.Invoking(() => Radix.TimeNs.Parse(null!)) - .Should().Throw(); + FluentActions.Invoking(() => Radix.TimeNs.Parse(null!)).Should().Throw(); } [Test] public void Parse_EmptyValue_ShouldThrowArgumentException() { - FluentActions.Invoking(() => Radix.TimeNs.Parse(string.Empty)) - .Should().Throw(); + FluentActions.Invoking(() => Radix.TimeNs.Parse(string.Empty)).Should().Throw(); } [Test] public void Parse_NoSpecifier_ShouldThrowFormatException() { - FluentActions.Invoking(() => Radix.TimeNs.Parse("123us")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.TimeNs.Parse("123us")).Should().Throw(); } [Test] public void Parse_InvalidType_ShouldThrowNotSupportedException() { - FluentActions.Invoking(() => Radix.TimeNs.Parse("LT#1ns")) - .Should().Throw(); + FluentActions.Invoking(() => Radix.TimeNs.Parse("LT#1ns")).Should().Throw(); } [Test] diff --git a/tests/L5Sharp.Tests.Core/Enums/RadixTimeTests.cs b/tests/L5Sharp.Tests.Core/Enums/RadixTimeTests.cs index 0d45b06a..06615e92 100644 --- a/tests/L5Sharp.Tests.Core/Enums/RadixTimeTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/RadixTimeTests.cs @@ -51,4 +51,18 @@ public void Parse_ValidTimeExample1_ShouldBeExpectedValue(string value, long exp result.Should().Be(expected); } + + [Test] + [TestCase("T#12h_30m", true)] + [TestCase("T#-500ms", true)] + [TestCase("T#1d_2h_3m_4s_5ms_6us", true)] + [TestCase("T#0us", true)] + [TestCase("123us", false)] + [TestCase(null, false)] + public void IsValid_WhenCalled_ShouldBeExpected(string? value, bool expected) + { + var result = Radix.Time.IsValid(value); + + result.Should().Be(expected); + } } \ No newline at end of file From 87d8208064637e17b1ca79dc6232bb4c33f1c594 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Tue, 19 May 2026 10:08:10 -0500 Subject: [PATCH 07/13] Refactored `Argument` by replacing regex-based atomic parsing with operator-based splitting for improved performance and maintainability. Introduced new properties (`IsInvalid`, `IsLiteral`, `IsTag`, `IsAtomic`, `IsExpression`) for enhanced argument type checks. Updated `ArgumentType` and related APIs to align with the changes. Revised and expanded test coverage to reflect the updates. --- src/L5Sharp.Core/Code/Block.cs | 2 +- src/L5Sharp.Core/Common/Argument.cs | 143 +++++++++++------- src/L5Sharp.Core/Common/Instruction.cs | 4 +- src/L5Sharp.Core/Enums/ArgumentType.cs | 17 --- src/L5Sharp.Core/LogixIndex.cs | 4 +- .../Common/ArgumentTests.cs | 54 ++++--- .../Enums/ArgumentTypeTests.cs | 56 ------- 7 files changed, 115 insertions(+), 165 deletions(-) diff --git a/src/L5Sharp.Core/Code/Block.cs b/src/L5Sharp.Core/Code/Block.cs index 1ce16f3d..8c37b2d1 100644 --- a/src/L5Sharp.Core/Code/Block.cs +++ b/src/L5Sharp.Core/Code/Block.cs @@ -469,7 +469,7 @@ private static IEnumerable GetBlockTags(XElement element) { var operand = element.GetBlockOperand(); - if (operand.Type.IsInvalid) return []; + if (operand.IsInvalid) return []; return element.Attributes() .Where(a => PinNames.Contains(a.Name.LocalName)) diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index e1fc5c97..8545ae6c 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text.RegularExpressions; namespace L5Sharp.Core; @@ -14,14 +13,9 @@ namespace L5Sharp.Core; public class Argument { /// - /// A compiled regular expression pattern used to identify atomic values within an argument. - /// The pattern supports various formats, including hexadecimal, binary, octal, floating-point, - /// signed integers, boolean literals, and special markers like #QNAN and #IND. - /// This enables parsing and validation of diverse atomic data types in instruction arguments. + /// A cached array of all known Logix operator symbols used for splitting and parsing expression arguments. /// - private static readonly Regex AtomicPattern = new( - @"(?:16#[0-9a-fA-F_]+)|(?:2#[01_]+)|(?:8#[0-7_]+)|(?:[A-Z]{1,4}#[^ ]+)|(?:[+-]?\d+\.\d+(?:[eE][+-]?\d+)?)|(?:[+-]?\d+)|(?:#QNAN|#IND)", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly string[] Operators = Operator.All().Select(x => x.Value).ToArray(); /// /// The value typically found in Studio for undefined argument values in certain instructions. @@ -53,26 +47,56 @@ public Argument(string value) public ArgumentType Type => ArgumentType.Of(_value); /// - /// The collection of values found in the argument. + /// Gets a value indicating whether this argument is invalid (either empty or unknown). /// - /// A of values. - /// - /// Since an argument could represent a complex expression, it may contain more than one tag name value. - /// We need a way to get all tag names from a single argument, whether it's a single tag name or expression or - /// multiple tag names. - /// - public IReadOnlyList Tags => ExtractTags(_value).ToArray(); + /// + /// true if the argument type is or ; otherwise, false. + /// + public bool IsInvalid => Type == ArgumentType.Empty || Type == ArgumentType.Unknown; + + /// + /// Gets a value indicating whether this argument represents an immediate value (atomic or string). + /// + /// + /// true if the argument type is or ; otherwise, false. + /// + public bool IsLiteral => Type == ArgumentType.Atomic || this == ArgumentType.String; + + /// + /// Gets a value indicating whether this argument represents a tag name reference. + /// + /// + /// true if the argument type is ; otherwise, false. + /// + public bool IsTag => Type == ArgumentType.Tag; + + /// + /// Gets a value indicating whether this argument represents an atomic value. + /// + /// + /// true if the argument type is ; otherwise, false. + /// + public bool IsAtomic => Type == ArgumentType.Atomic; /// - /// The collection of values found in the argument. + /// Gets a value indicating whether this argument represents an expression containing operators. + /// + /// + /// true if the argument type is ; otherwise, false. + /// + public bool IsExpression => Type == ArgumentType.Expression; + + /// + /// Retrieves a read-only collection of arguments derived from the current argument string value. + /// Useful for parsing and analyzing composite argument structures within expressions. /// - /// A of values. /// - /// Since an argument could represent a complex expression, it may contain more than one tag name value. - /// We need a way to get all tag names from a single argument, whether it's a single tag name or expression or - /// multiple tag names. + /// If the argument type is , this property returns a collection of + /// individual component arguments extracted by splitting the expression on known Logix operators. + /// If the argument is not an expression (e.g., a tag name, atomic value, or string literal), + /// this property returns a single-item collection containing the argument itself. /// - public IReadOnlyList Values => ExtractValues(_value).ToArray(); + public IReadOnlyList Arguments => ExtractArguments(); /// /// Represents an unknown argument that can be found in certain instruction text. @@ -88,6 +112,34 @@ public Argument(string value) /// public static Argument Empty => new(string.Empty); + /// + /// Converts this argument to a instance. + /// + /// A representing the tag reference in this argument. + /// Thrown when the argument type is not . + public TagName ToTag() + { + if (Type != ArgumentType.Tag) + throw new InvalidOperationException( + $"Cannot convert argument '{_value}' to TagName. The argument type is {Type}, but expected {ArgumentType.Tag}."); + + return new TagName(_value); + } + + /// + /// Converts this argument to an instance by parsing its immediate atomic value. + /// + /// An representing the parsed atomic value from this argument. + /// Thrown when the argument type is not . + public AtomicData ToAtomic() + { + if (Type != ArgumentType.Atomic) + throw new InvalidOperationException( + $"Cannot convert argument '{_value}' to AtomicData. The argument type is {Type}, but expected {ArgumentType.Atomic}."); + + return AtomicData.Parse(_value); + } + #region Equality /// @@ -211,51 +263,26 @@ public Argument(string value) public static implicit operator Argument(double value) => new(value.ToString(CultureInfo.InvariantCulture)); /// - /// Explicitly converts the provided to a . + /// Explicitly converts the provided to a . /// /// The object to convert. - /// A object representing the value of the argument. + /// A object representing the value of the argument. public static implicit operator string(Argument argument) => argument._value; #endregion - #region Internal - /// - /// Extracts all tag names from the provided text based on a predefined search pattern. + /// Extracts individual component arguments from an expression by splitting on known Logix operators. /// - private static IEnumerable ExtractTags(string argument) + /// An array of objects representing each component of the expression, + /// or an empty array if not an expression. + private Argument[] ExtractArguments() { - var type = ArgumentType.Of(argument); - - if (type == ArgumentType.Tag) - return [argument]; + if (!IsExpression) return [this]; - if (type == ArgumentType.Expression) - return TagName.ExtractAll(argument); - - return []; + return _value + .Split(Operators, StringSplitOptions.RemoveEmptyEntries) + .Select(x => new Argument(x)) + .ToArray(); } - - /// - /// Extracts a collection of from the given argument string. - /// - private static IEnumerable ExtractValues(string argument) - { - var type = ArgumentType.Of(argument); - - if (type == ArgumentType.Atomic) - return [AtomicData.Parse(argument)]; - - if (type == ArgumentType.Expression) - { - return AtomicPattern.Matches(argument) - .Cast() - .Select(m => AtomicData.Parse(m.Value)); - } - - return []; - } - - #endregion } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/Instruction.cs b/src/L5Sharp.Core/Common/Instruction.cs index 5524486d..0431453d 100644 --- a/src/L5Sharp.Core/Common/Instruction.cs +++ b/src/L5Sharp.Core/Common/Instruction.cs @@ -1826,7 +1826,7 @@ private TagName[] ParseTags() var arguments = IsRoutineCall ? Arguments.Skip(1) : Arguments; //And then anything else return all tag arguments. - return arguments.SelectMany(a => a.Tags).ToArray(); + return arguments.SelectMany(a => a.Arguments.Where(x => x.IsTag).Select(t => t.ToTag())).ToArray(); } /// @@ -1840,7 +1840,7 @@ private AtomicData[] ParseValues() //Skip the first argument of a routine instruction as it does not refer to a tag name. var arguments = IsRoutineCall ? Arguments.Skip(1) : Arguments; - return arguments.SelectMany(a => a.Values).ToArray(); + return arguments.SelectMany(a => a.Arguments.Where(x => x.IsAtomic).Select(t => t.ToAtomic())).ToArray(); } /// diff --git a/src/L5Sharp.Core/Enums/ArgumentType.cs b/src/L5Sharp.Core/Enums/ArgumentType.cs index 065f1c39..345c84ef 100644 --- a/src/L5Sharp.Core/Enums/ArgumentType.cs +++ b/src/L5Sharp.Core/Enums/ArgumentType.cs @@ -33,23 +33,6 @@ public static ArgumentType Of(string value) return Unknown; } - /// - /// Determines whether the current argument type is either or . - /// - public bool IsInvalid => this == Empty || this == Unknown; - - /// - /// Indicates whether the argument type represents an immediate value. Immediate values are - /// or type arguments. - /// - public bool IsValue => this == Atomic || this == String; - - /// - /// Determines whether the argument type represents a value. Tag arguments are references to - /// values and not values themselves. - /// - public bool IsTag => this == Tag; - /// /// Represents an argument type that is specifically empty. /// This value is used when no argument type has been defined or assigned. diff --git a/src/L5Sharp.Core/LogixIndex.cs b/src/L5Sharp.Core/LogixIndex.cs index 64ebac08..7a4c3970 100644 --- a/src/L5Sharp.Core/LogixIndex.cs +++ b/src/L5Sharp.Core/LogixIndex.cs @@ -304,10 +304,8 @@ private void IndexCodeElement(XElement element) AddOrUpdateReference(instruction.Key, reference); - foreach (var tag in instruction.Arguments.Where(a => a.Type.IsTag)) + foreach (var tag in instruction.Arguments.Where(a => a.IsTag)) AddOrUpdateReference(tag, reference); - - } } diff --git a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs index 75698218..f3f6976b 100644 --- a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs @@ -12,6 +12,7 @@ public void Empty_WhenCalled_ShouldHaveExpectedValue() argument.Should().Be(string.Empty); argument.Type.Should().Be(ArgumentType.Empty); + argument.IsInvalid.Should().BeTrue(); } [Test] @@ -21,6 +22,7 @@ public void Unknown_WhenCalled_ShouldHaveExpectedValue() argument.Should().Be("?"); argument.Type.Should().Be(ArgumentType.Unknown); + argument.IsInvalid.Should().BeTrue(); } [Test] @@ -30,6 +32,8 @@ public void New_AtomicArgument_ShouldBeExpected() argument.Should().Be("100"); argument.Type.Should().Be(ArgumentType.Atomic); + argument.IsLiteral.Should().BeTrue(); + argument.IsAtomic.Should().BeTrue(); } [Test] @@ -39,6 +43,7 @@ public void New_TagArgument_ShouldBeExpected() argument.Should().Be("MyTagName.Member[1].Active.1"); argument.Type.Should().Be(ArgumentType.Tag); + argument.IsTag.Should().BeTrue(); } [Test] @@ -48,6 +53,7 @@ public void New_StringArgument_ShouldBeExpected() argument.Should().Be("'This is a test string'"); argument.Type.Should().Be(ArgumentType.String); + argument.IsLiteral.Should().BeTrue(); } [Test] @@ -57,6 +63,7 @@ public void New_ExpressionArgument_ShouldBeExpected() argument.Should().Be("SomeTag.Value > 100"); argument.Type.Should().Be(ArgumentType.Expression); + argument.IsExpression.Should().BeTrue(); } [Test] @@ -130,52 +137,52 @@ public void New_ExpressionValue_ShouldHaveExpectedValueAndType() } [Test] - public void Tags_ArgumentWithSingleTag_ShouldHaveExpectedCount() + public void Arguments_ArgumentWithSingleTag_ShouldHaveExpectedCount() { var argument = new Argument("MyTagName.Member[1].Active.1"); - var tags = argument.Tags.ToArray(); + var args = argument.Arguments.ToArray(); - tags.Should().HaveCount(1); + args.Should().HaveCount(1); } [Test] - public void Tags_ExpressionArgumentMultipleTags_ShouldHaveExpectedCount() + public void Arguments_ExpressionArgumentMultipleArguments_ShouldHaveExpectedCount() { Argument argument = "CMP(MyTagName.Member[1].Active >= MyConstant)"; - var tags = argument.Tags; + var arguments = argument.Arguments; - tags.Should().HaveCount(2); + arguments.Should().HaveCount(2); } [Test] - public void Values_ArgumentSingleAtomic_ShouldHaveExpectedCount() + public void Arguments_ArgumentSingleAtomic_ShouldHaveExpectedCount() { Argument argument = 100; - var values = argument.Values; + var values = argument.Arguments; values.Should().HaveCount(1); } [Test] - public void Values_ExpressionWithSingleAtomic_ShouldHaveExpectedValue() + public void Arguments_ExpressionWithSingleAtomic_ShouldHaveExpectedValue() { Argument argument = "MyTag > 100"; - var values = argument.Values; + var values = argument.Arguments; - values.Should().HaveCount(1); - values[0].Should().Be(new DINT(100)); + values.Should().HaveCount(2); + values[0].Should().Be("100"); } [Test] - public void Values_ExpressionWithMultipleAtomics_ShouldHaveExpectedValues() + public void Arguments_ExpressionWithMultipleAtomics_ShouldHaveExpectedValues() { Argument argument = "MyTag > 100 AND MyOtherTag < 16#ABCD"; - var values = argument.Values; + var values = argument.Arguments; values.Should().HaveCount(2); values[0].Should().Be(new DINT(100)); @@ -183,14 +190,15 @@ public void Values_ExpressionWithMultipleAtomics_ShouldHaveExpectedValues() } [Test] - public void Values_ExpressionWithVariousAtomicFormats_ShouldExtractAll() + public void Arguments_ExpressionWithVariousAtomicFormats_ShouldExtractAll() { Argument argument = "16#1234 + 2#1010 + 8#77 + DT#2023-05-18-11:08:00Z + 1.23 + 123 + 1.#QNAN"; - var values = argument.Values; + var arguments = argument.Arguments; - values.Should().HaveCount(7); - values.Select(v => v.ToString()).Should().Contain([ + arguments.Should().HaveCount(7); + + arguments.Select(v => v.ToString()).Should().Contain([ "16#0000_1234", "2#0000_0000_0000_0000_0000_0000_0000_1010", "8#0000_0000_077", @@ -200,14 +208,4 @@ public void Values_ExpressionWithVariousAtomicFormats_ShouldExtractAll() "1.#QNAN" ]); } - - [Test] - public void Values_ExpressionWithNoAtomics_ShouldBeEmpty() - { - Argument argument = "MyTag + OtherTag"; - - var values = argument.Values; - - values.Should().BeEmpty(); - } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs b/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs index e1925bf5..dd2f49f1 100644 --- a/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs @@ -104,60 +104,4 @@ public void Of_ValidTagName_ShouldBeTag() type.Should().Be(ArgumentType.Tag); } - - [Test] - public void IsInvalid_Empty_ShouldBeTrue() - { - var type = ArgumentType.Empty; - - type.IsInvalid.Should().BeTrue(); - } - - [Test] - public void IsInvalid_Tag_ShouldBeFalse() - { - var type = ArgumentType.Tag; - - type.IsInvalid.Should().BeFalse(); - } - - [Test] - public void IsValue_Empty_ShouldBeFalse() - { - var type = ArgumentType.Empty; - - type.IsValue.Should().BeFalse(); - } - - [Test] - public void IsValue_Tag_ShouldBeFalse() - { - var type = ArgumentType.Tag; - - type.IsValue.Should().BeFalse(); - } - - [Test] - public void IsValue_Atomic_ShouldBeTrue() - { - var type = ArgumentType.Atomic; - - type.IsValue.Should().BeTrue(); - } - - [Test] - public void IsValue_String_ShouldBeTrue() - { - var type = ArgumentType.String; - - type.IsValue.Should().BeTrue(); - } - - [Test] - public void IsTag_Tag_ShouldBeTrue() - { - var type = ArgumentType.Tag; - - type.IsTag.Should().BeTrue(); - } } \ No newline at end of file From edf55c70f6d442a9ab6b19cfc7d169b0e36f1c83 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Tue, 19 May 2026 11:14:35 -0500 Subject: [PATCH 08/13] Added `Function` enum to define built-in Logix functions, updated `Argument` class for improved expression parsing, and added corresponding tests for enhanced functionality and coverage. --- src/L5Sharp.Core/Common/Argument.cs | 32 ++++- src/L5Sharp.Core/Enums/Function.cs | 76 ++++++++++++ src/L5Sharp.Gateway/PlcClient.cs | 2 +- .../Common/ArgumentTests.cs | 44 +++---- .../L5Sharp.Tests.Core/Enums/FunctionTests.cs | 117 ++++++++++++++++++ 5 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 src/L5Sharp.Core/Enums/Function.cs create mode 100644 tests/L5Sharp.Tests.Core/Enums/FunctionTests.cs diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index 8545ae6c..e7e572aa 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; namespace L5Sharp.Core; @@ -15,7 +16,8 @@ public class Argument /// /// A cached array of all known Logix operator symbols used for splitting and parsing expression arguments. /// - private static readonly string[] Operators = Operator.All().Select(x => x.Value).ToArray(); + private static readonly string[] Operators = + Operator.All().Select(x => x.Value).OrderByDescending(x => x.Length).ToArray(); /// /// The value typically found in Studio for undefined argument values in certain instructions. @@ -60,7 +62,7 @@ public Argument(string value) /// /// true if the argument type is or ; otherwise, false. /// - public bool IsLiteral => Type == ArgumentType.Atomic || this == ArgumentType.String; + public bool IsLiteral => Type == ArgumentType.Atomic || Type == ArgumentType.String; /// /// Gets a value indicating whether this argument represents a tag name reference. @@ -122,7 +124,6 @@ public TagName ToTag() if (Type != ArgumentType.Tag) throw new InvalidOperationException( $"Cannot convert argument '{_value}' to TagName. The argument type is {Type}, but expected {ArgumentType.Tag}."); - return new TagName(_value); } @@ -136,14 +137,21 @@ public AtomicData ToAtomic() if (Type != ArgumentType.Atomic) throw new InvalidOperationException( $"Cannot convert argument '{_value}' to AtomicData. The argument type is {Type}, but expected {ArgumentType.Atomic}."); - return AtomicData.Parse(_value); } #region Equality /// - public override bool Equals(object? obj) => _value.Equals(obj?.ToString()); + public override bool Equals(object? obj) + { + return obj switch + { + Argument other => _value.Equals(other._value), + string value => _value.Equals(value), + _ => false + }; + } /// public override int GetHashCode() => _value.GetHashCode(); @@ -278,10 +286,22 @@ public AtomicData ToAtomic() /// or an empty array if not an expression. private Argument[] ExtractArguments() { + // If this is a tag or literal, then just return itself. if (!IsExpression) return [this]; + // If it looks like a function call "NAME(ARGS)", extract the arguments inside the parentheses + // and return the nested argument. + var functionMatch = Regex.Match(_value, @"^[A-Z_]+\((.*)\)$", RegexOptions.IgnoreCase); + if (functionMatch.Success) + { + Argument nested = functionMatch.Groups[1].Value; + return nested.ExtractArguments(); + } + + // Split expression on known operators... return _value .Split(Operators, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()) .Select(x => new Argument(x)) .ToArray(); } diff --git a/src/L5Sharp.Core/Enums/Function.cs b/src/L5Sharp.Core/Enums/Function.cs new file mode 100644 index 00000000..a360728a --- /dev/null +++ b/src/L5Sharp.Core/Enums/Function.cs @@ -0,0 +1,76 @@ +namespace L5Sharp.Core; + +/// +/// An enumeration of known Logix built-in functions found within the Logix programming languages. +/// +public class Function : LogixEnum +{ + private Function(string name, string value) : base(name, value) + { + } + + /// + /// Represents the absolute value Logix . + /// + public static readonly Function Abs = new(nameof(Abs), "ABS"); + + /// + /// Represents the arc cosine Logix . + /// + public static readonly Function Acos = new(nameof(Acos), "ACOS"); + + /// + /// Represents the arc sine Logix . + /// + public static readonly Function Asin = new(nameof(Asin), "ASIN"); + + /// + /// Represents the arc tangent Logix . + /// + public static readonly Function Atan = new(nameof(Atan), "ATAN"); + + /// + /// Represents the cosine Logix . + /// + public static readonly Function Cos = new(nameof(Cos), "COS"); + + /// + /// Represents the radians to degrees Logix . + /// + public static readonly Function Deg = new(nameof(Deg), "DEG"); + + /// + /// Represents the natural log Logix . + /// + public static readonly Function Ln = new(nameof(Ln), "LN"); + + /// + /// Represents the log base 10 Logix . + /// + public static readonly Function Log = new(nameof(Log), "LOG"); + + /// + /// Represents the degrees to radians Logix . + /// + public static readonly Function Rad = new(nameof(Rad), "RAD"); + + /// + /// Represents the sine Logix . + /// + public static readonly Function Sin = new(nameof(Sin), "SIN"); + + /// + /// Represents the square root Logix . + /// + public static readonly Function Sqrt = new(nameof(Sqrt), "SQRT"); + + /// + /// Represents the tangent Logix . + /// + public static readonly Function Tan = new(nameof(Tan), "TAN"); + + /// + /// Represents the truncate Logix . + /// + public static readonly Function Trunc = new(nameof(Trunc), "TRUNC"); +} \ No newline at end of file diff --git a/src/L5Sharp.Gateway/PlcClient.cs b/src/L5Sharp.Gateway/PlcClient.cs index 79ac0f43..c478a944 100644 --- a/src/L5Sharp.Gateway/PlcClient.cs +++ b/src/L5Sharp.Gateway/PlcClient.cs @@ -575,7 +575,7 @@ private static Tag[] GetUpdatedMembersFor(Tag tag, IReadOnlyList<(TagName Member void OnTagValueChanged(object? sender, XObjectChangeEventArgs args) { - //We only care about value changes (todo need to check with string CDATA though) + //We only care about value changes if (args.ObjectChange != XObjectChange.Value) return; // Should be an XObject from which we can get the parent element which should be the data member. diff --git a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs index f3f6976b..f78dc71c 100644 --- a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs @@ -146,16 +146,6 @@ public void Arguments_ArgumentWithSingleTag_ShouldHaveExpectedCount() args.Should().HaveCount(1); } - [Test] - public void Arguments_ExpressionArgumentMultipleArguments_ShouldHaveExpectedCount() - { - Argument argument = "CMP(MyTagName.Member[1].Active >= MyConstant)"; - - var arguments = argument.Arguments; - - arguments.Should().HaveCount(2); - } - [Test] public void Arguments_ArgumentSingleAtomic_ShouldHaveExpectedCount() { @@ -167,42 +157,44 @@ public void Arguments_ArgumentSingleAtomic_ShouldHaveExpectedCount() } [Test] - public void Arguments_ExpressionWithSingleAtomic_ShouldHaveExpectedValue() + public void Arguments_ExpressionWithSingleTagAndAtomic_ShouldHaveExpectedValue() { Argument argument = "MyTag > 100"; - var values = argument.Arguments; + var arguments = argument.Arguments; - values.Should().HaveCount(2); - values[0].Should().Be("100"); + arguments.Should().HaveCount(2); + arguments[0].Should().Be("MyTag"); + arguments[1].Should().Be("100"); } [Test] - public void Arguments_ExpressionWithMultipleAtomics_ShouldHaveExpectedValues() + public void Arguments_ExpressionWithMultipleTagsAndAtomics_ShouldHaveExpectedValues() { Argument argument = "MyTag > 100 AND MyOtherTag < 16#ABCD"; - var values = argument.Arguments; + var arguments = argument.Arguments; - values.Should().HaveCount(2); - values[0].Should().Be(new DINT(100)); - values[1].Should().Be(new DINT(43981)); // 16#ABCD + arguments.Should().HaveCount(4); + arguments[0].Should().Be("MyTag"); + arguments[1].Should().Be("100"); + arguments[2].Should().Be("MyOtherTag"); + arguments[3].Should().Be("16#ABCD"); } [Test] public void Arguments_ExpressionWithVariousAtomicFormats_ShouldExtractAll() { - Argument argument = "16#1234 + 2#1010 + 8#77 + DT#2023-05-18-11:08:00Z + 1.23 + 123 + 1.#QNAN"; + Argument argument = "16#1234 + 2#1010 + 8#77 + 1.23 + 123 + 1.#QNAN"; var arguments = argument.Arguments; - arguments.Should().HaveCount(7); + arguments.Should().HaveCount(6); - arguments.Select(v => v.ToString()).Should().Contain([ - "16#0000_1234", - "2#0000_0000_0000_0000_0000_0000_0000_1010", - "8#0000_0000_077", - "DT#2023-05-18-11:08:00Z", + arguments.Select(v => v.ToString()).Should().BeEquivalentTo([ + "16#1234", + "2#1010", + "8#77", "1.23", "123", "1.#QNAN" diff --git a/tests/L5Sharp.Tests.Core/Enums/FunctionTests.cs b/tests/L5Sharp.Tests.Core/Enums/FunctionTests.cs new file mode 100644 index 00000000..ad7f2b6b --- /dev/null +++ b/tests/L5Sharp.Tests.Core/Enums/FunctionTests.cs @@ -0,0 +1,117 @@ +using FluentAssertions; + +namespace L5Sharp.Tests.Core.Enums; + +[TestFixture] +public class FunctionTests +{ + [Test] + public void Abs_WhenCalled_ShouldNotBeNull() + { + Function.Abs.Should().NotBeNull(); + Function.Abs.Name.Should().Be("Abs"); + Function.Abs.Value.Should().Be("ABS"); + } + + [Test] + public void Acos_WhenCalled_ShouldNotBeNull() + { + Function.Acos.Should().NotBeNull(); + Function.Acos.Name.Should().Be("Acos"); + Function.Acos.Value.Should().Be("ACOS"); + } + + [Test] + public void Asin_WhenCalled_ShouldNotBeNull() + { + Function.Asin.Should().NotBeNull(); + Function.Asin.Name.Should().Be("Asin"); + Function.Asin.Value.Should().Be("ASIN"); + } + + [Test] + public void Atan_WhenCalled_ShouldNotBeNull() + { + Function.Atan.Should().NotBeNull(); + Function.Atan.Name.Should().Be("Atan"); + Function.Atan.Value.Should().Be("ATAN"); + } + + [Test] + public void Cos_WhenCalled_ShouldNotBeNull() + { + Function.Cos.Should().NotBeNull(); + Function.Cos.Name.Should().Be("Cos"); + Function.Cos.Value.Should().Be("COS"); + } + + [Test] + public void Deg_WhenCalled_ShouldNotBeNull() + { + Function.Deg.Should().NotBeNull(); + Function.Deg.Name.Should().Be("Deg"); + Function.Deg.Value.Should().Be("DEG"); + } + + [Test] + public void Ln_WhenCalled_ShouldNotBeNull() + { + Function.Ln.Should().NotBeNull(); + Function.Ln.Name.Should().Be("Ln"); + Function.Ln.Value.Should().Be("LN"); + } + + [Test] + public void Log_WhenCalled_ShouldNotBeNull() + { + Function.Log.Should().NotBeNull(); + Function.Log.Name.Should().Be("Log"); + Function.Log.Value.Should().Be("LOG"); + } + + [Test] + public void Rad_WhenCalled_ShouldNotBeNull() + { + Function.Rad.Should().NotBeNull(); + Function.Rad.Name.Should().Be("Rad"); + Function.Rad.Value.Should().Be("RAD"); + } + + [Test] + public void Sin_WhenCalled_ShouldNotBeNull() + { + Function.Sin.Should().NotBeNull(); + Function.Sin.Name.Should().Be("Sin"); + Function.Sin.Value.Should().Be("SIN"); + } + + [Test] + public void Sqrt_WhenCalled_ShouldNotBeNull() + { + Function.Sqrt.Should().NotBeNull(); + Function.Sqrt.Name.Should().Be("Sqrt"); + Function.Sqrt.Value.Should().Be("SQRT"); + } + + [Test] + public void Tan_WhenCalled_ShouldNotBeNull() + { + Function.Tan.Should().NotBeNull(); + Function.Tan.Name.Should().Be("Tan"); + Function.Tan.Value.Should().Be("TAN"); + } + + [Test] + public void Trunc_WhenCalled_ShouldNotBeNull() + { + Function.Trunc.Should().NotBeNull(); + Function.Trunc.Name.Should().Be("Trunc"); + Function.Trunc.Value.Should().Be("TRUNC"); + } + + [Test] + public void All_ShouldHaveExpectedCount() + { + Function.All().Should().HaveCount(13); + } +} From a5447c375373d1bc609d2b166a24d411c15fca75 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Wed, 20 May 2026 15:24:19 -0500 Subject: [PATCH 09/13] Refactored `Argument` to replace `Tag` with `Reference` for improved clarity and context. Updated associated methods, properties, enums, and exceptions. Added `NeutralText` and `NeutralTextExtensions` for tokenization support. Expanded and updated tests to cover new functionality and changes. --- .../NeutralText/INeutralTextBuilder.cs | 13 + src/L5Sharp.Core/Common/Argument.cs | 45 ++-- src/L5Sharp.Core/Common/Instruction.cs | 2 +- src/L5Sharp.Core/Common/NeutralStream.cs | 90 +++++++ src/L5Sharp.Core/Common/NeutralText.cs | 218 ++++++++++++++++ .../Common/NeutralTextExtensions.cs | 20 ++ src/L5Sharp.Core/Common/NeutralToken.cs | 66 +++++ src/L5Sharp.Core/Common/TagName.cs | 13 +- src/L5Sharp.Core/Enums/ArgumentType.cs | 13 +- src/L5Sharp.Core/Enums/TokenType.cs | 126 +++++++++ .../L5Sharp.Core.csproj.DotSettings | 1 + src/L5Sharp.Core/LogixIndex.cs | 2 +- src/L5Sharp.sln.DotSettings | 1 + .../Common/ArgumentTests.cs | 176 ++++++------- .../Common/NeutralTextTests.cs | 244 ++++++++++++++++++ .../Common/NeutralTokenTests.cs | 72 ++++++ .../Enums/ArgumentTypeTests.cs | 4 +- .../Enums/TokenTypeTests.cs | 205 +++++++++++++++ 18 files changed, 1178 insertions(+), 133 deletions(-) create mode 100644 src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs create mode 100644 src/L5Sharp.Core/Common/NeutralStream.cs create mode 100644 src/L5Sharp.Core/Common/NeutralText.cs create mode 100644 src/L5Sharp.Core/Common/NeutralTextExtensions.cs create mode 100644 src/L5Sharp.Core/Common/NeutralToken.cs create mode 100644 src/L5Sharp.Core/Enums/TokenType.cs create mode 100644 tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs create mode 100644 tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs create mode 100644 tests/L5Sharp.Tests.Core/Enums/TokenTypeTests.cs diff --git a/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs b/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs new file mode 100644 index 00000000..5f7c9251 --- /dev/null +++ b/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs @@ -0,0 +1,13 @@ +namespace L5Sharp.Core; + +/// +/// +/// +public interface INeutralTextBuilder +{ + /// + /// + /// + /// + NeutralText Build(); +} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index e7e572aa..4e7a71de 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -51,41 +51,31 @@ public Argument(string value) /// /// Gets a value indicating whether this argument is invalid (either empty or unknown). /// - /// - /// true if the argument type is or ; otherwise, false. - /// public bool IsInvalid => Type == ArgumentType.Empty || Type == ArgumentType.Unknown; /// /// Gets a value indicating whether this argument represents an immediate value (atomic or string). /// - /// - /// true if the argument type is or ; otherwise, false. - /// public bool IsLiteral => Type == ArgumentType.Atomic || Type == ArgumentType.String; /// - /// Gets a value indicating whether this argument represents a tag name reference. + /// Indicates whether the current argument is of type . /// - /// - /// true if the argument type is ; otherwise, false. - /// - public bool IsTag => Type == ArgumentType.Tag; + public bool IsReference => Type == ArgumentType.Reference; /// /// Gets a value indicating whether this argument represents an atomic value. /// - /// - /// true if the argument type is ; otherwise, false. - /// public bool IsAtomic => Type == ArgumentType.Atomic; + /// + /// Indicates whether the argument represents a string literal type. + /// + public bool IsString => Type == ArgumentType.String; + /// /// Gets a value indicating whether this argument represents an expression containing operators. /// - /// - /// true if the argument type is ; otherwise, false. - /// public bool IsExpression => Type == ArgumentType.Expression; /// @@ -118,12 +108,13 @@ public Argument(string value) /// Converts this argument to a instance. /// /// A representing the tag reference in this argument. - /// Thrown when the argument type is not . - public TagName ToTag() + /// Thrown when the argument type is not . + public TagName ToTagName() { - if (Type != ArgumentType.Tag) + if (Type != ArgumentType.Reference) throw new InvalidOperationException( - $"Cannot convert argument '{_value}' to TagName. The argument type is {Type}, but expected {ArgumentType.Tag}."); + $"Cannot convert argument '{_value}' to TagName. The argument type is {Type}, but expected {ArgumentType.Reference}."); + return new TagName(_value); } @@ -137,6 +128,7 @@ public AtomicData ToAtomic() if (Type != ArgumentType.Atomic) throw new InvalidOperationException( $"Cannot convert argument '{_value}' to AtomicData. The argument type is {Type}, but expected {ArgumentType.Atomic}."); + return AtomicData.Parse(_value); } @@ -286,7 +278,7 @@ public override bool Equals(object? obj) /// or an empty array if not an expression. private Argument[] ExtractArguments() { - // If this is a tag or literal, then just return itself. + // If this is a reference or literal, then just return itself. if (!IsExpression) return [this]; // If it looks like a function call "NAME(ARGS)", extract the arguments inside the parentheses @@ -294,15 +286,18 @@ private Argument[] ExtractArguments() var functionMatch = Regex.Match(_value, @"^[A-Z_]+\((.*)\)$", RegexOptions.IgnoreCase); if (functionMatch.Success) { - Argument nested = functionMatch.Groups[1].Value; - return nested.ExtractArguments(); + Argument function = functionMatch.Groups[1].Value; + return function.ExtractArguments(); } - // Split expression on known operators... + // Split expression on known operators. Trim spaces and parenthesis since they are not important. return _value .Split(Operators, StringSplitOptions.RemoveEmptyEntries) .Select(t => t.Trim()) + .Select(t => t.TrimStart('(')) + .Select(t => t.TrimEnd(')')) .Select(x => new Argument(x)) + .SelectMany(a => a.ExtractArguments()) .ToArray(); } } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/Instruction.cs b/src/L5Sharp.Core/Common/Instruction.cs index 0431453d..d833835e 100644 --- a/src/L5Sharp.Core/Common/Instruction.cs +++ b/src/L5Sharp.Core/Common/Instruction.cs @@ -1826,7 +1826,7 @@ private TagName[] ParseTags() var arguments = IsRoutineCall ? Arguments.Skip(1) : Arguments; //And then anything else return all tag arguments. - return arguments.SelectMany(a => a.Arguments.Where(x => x.IsTag).Select(t => t.ToTag())).ToArray(); + return arguments.SelectMany(a => a.Arguments.Where(x => x.IsReference).Select(t => t.ToTagName())).ToArray(); } /// diff --git a/src/L5Sharp.Core/Common/NeutralStream.cs b/src/L5Sharp.Core/Common/NeutralStream.cs new file mode 100644 index 00000000..73f1a982 --- /dev/null +++ b/src/L5Sharp.Core/Common/NeutralStream.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; + +namespace L5Sharp.Core; + +/// +/// Provides a stream-based interface for sequentially processing tokens from neutral text. +/// Maintains position state and supports lookahead operations for token-by-token parsing. +/// +public class NeutralStream +{ + /// + /// The internal collection of tokens to be processed by this stream. + /// + private readonly List _tokens; + + /// + /// The zero-based index of the current position in the token stream. + /// + private int _currentIndex; + + /// + /// Initializes a new instance of the class with the specified token sequence. + /// + /// The sequence of tokens to process. The tokens are materialized into an internal list. + public NeutralStream(IEnumerable tokens) + { + _tokens = tokens.ToList(); + } + + /// + /// Consumes the current token from the stream and advances the position to the next token. + /// + /// The token at the current position before advancing. + public NeutralToken Consume() => ConsumeToken(); + + /// + /// Returns the current token without consuming it or advancing the stream position. + /// If the stream is at the end, returns an end-of-file token. + /// + /// The token at the current position, or an end-of-file token if no more tokens are available. + public NeutralToken Peek() => GetToken(); + + /// + /// Checks if the current token matches the specified token type without consuming it. + /// + /// The token type to compare against the current token. + /// true if the current token's type matches the specified type; otherwise, false. + public bool Match(TokenType type) => GetToken().Type == type; + + /// + /// Indicates whether the stream has reached the end of the token collection + /// or encountered an end-of-file (EOF) token. + /// + public bool Ended => _currentIndex >= _tokens.Count || GetToken().Type == TokenType.EOF; + + /// + /// Consumes the current token from the stream, advances the position to the next token, and returns the consumed token. + /// If the stream is at the end, it returns an end-of-file token. + /// + /// The token at the current position before advancing, or an end-of-file token if the stream is at the end. + private NeutralToken ConsumeToken() + { + var token = GetToken(); + + if (_currentIndex < _tokens.Count) + { + _currentIndex++; + } + + return token; + } + + /// + /// Retrieves the token at the current stream position without advancing the position. + /// If the current position exceeds the number of available tokens, an end-of-file token is returned. + /// + /// The token at the current position, or an end-of-file token if no more tokens are available. + private NeutralToken GetToken() + { + if (_currentIndex < _tokens.Count) + return _tokens[_currentIndex]; + + if (_tokens.Count == 0) + return NeutralToken.EOF(0); + + var last = _tokens[_tokens.Count - 1]; + return NeutralToken.EOF(last.Index + last.Length); + } +} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralText.cs b/src/L5Sharp.Core/Common/NeutralText.cs new file mode 100644 index 00000000..86b1cf94 --- /dev/null +++ b/src/L5Sharp.Core/Common/NeutralText.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; + +namespace L5Sharp.Core; + +/// +/// Represents case-insensitive text that can be tokenized into neutral tokens for parsing operations. +/// This class provides string comparison using ordinal case-insensitive rules and supports tokenization +/// of Logix programming language syntax, including operators, identifiers, literals, and structural elements. +/// +public class NeutralText +{ + /// + /// The underlying text value stored in this NeutralText instance. + /// + private readonly string _text; + + /// + /// Initializes a new instance of the class with the specified text value. + /// + /// The text value to wrap. Cannot be null. + public NeutralText(string text) + { + _text = text ?? throw new ArgumentNullException(nameof(text)); + } + + /// + /// Tokenizes the text into a sequence of neutral tokens representing operators, identifiers, literals, + /// structural elements, and string literals. Whitespace is ignored during tokenization. + /// + /// An enumerable sequence of instances representing the parsed tokens, + /// terminated with an EOF token. + /// Thrown when an unrecognized character is encountered during tokenization. + public IEnumerable Tokenize() + { + var position = 0; + + while (position < _text.Length) + { + var current = _text[position]; + + if (char.IsWhiteSpace(current)) + { + position++; + continue; + } + + var token = current switch + { + _ when IsStructural(current) => Consume(_text, ref position), + _ when IsOperator(current) => Consume(_text, ref position, GetOperatorLength(position, _text)), + _ when IsStringQuote(current) => ConsumeString(_text, ref position), + //todo just not sure if we need to cover date times. I don't think we do, so this should work. + _ when char.IsDigit(current) => ConsumeWhile(_text, ref position, IsLiteral), + _ when char.IsLetter(current) || current is '_' => ConsumeWhile(_text, ref position, IsIdentifier), + _ => throw new ArgumentException( + $"Unexpected character '{current}' at position {position} of text {_text}") + }; + + yield return token; + } + + yield return NeutralToken.EOF(position); + yield break; + + bool IsStructural(char c) => c is '(' or ')' or '[' or ']' or ',' or '.' or ';'; + bool IsOperator(char c) => c is '+' or '-' or '/' or '*' or '=' or '<' or '>' or ':'; + bool IsStringQuote(char c) => c is '\''; + bool IsLiteral(char c) => char.IsLetterOrDigit(c) || c is '#' or '.'; + bool IsIdentifier(char c) => char.IsLetterOrDigit(c) || c is '_'; + } + + /// + /// Consumes a specified number of characters from the given text, starting at the current position, + /// and creates a representing the consumed text. + /// + /// The source text to consume characters from. + /// A reference to the current position within the text, updated as characters are consumed. + /// The number of characters to consume, with a default value of 1. + /// A representing the consumed text and its type. + private static NeutralToken Consume(string text, ref int position, int count = 1) + { + var start = position; + + while (position < text.Length && position - start < count) + position++; + + var token = text.Substring(start, position - start); + var type = TokenType.FromToken(token); + return new NeutralToken(type, token, start); + } + + /// + /// Consumes characters from the input text starting from the given position until the specified condition is no longer met, + /// and returns a parsed . + /// + /// The input text being parsed. + /// + /// A reference to the current index within the . This value will be updated to point to the position + /// immediately after the last character consumed. + /// + /// + /// A function that determines the condition for consuming characters. Characters will continue to be consumed + /// as long as this function returns . + /// + /// + /// A representing the consumed characters, including their type, value, and position within the text. + /// + private static NeutralToken ConsumeWhile(string text, ref int position, Func condition) + { + var start = position; + + while (position < text.Length && condition.Invoke(text[position])) + position++; + + var token = text.Substring(start, position - start); + var type = TokenType.FromToken(token); + return new NeutralToken(type, token, start); + } + + /// + /// Processes a substring within the provided text, starting from the current position, and consumes characters + /// until the closing quote of a string literal is reached. + /// + /// The input text from which the string is consumed. Cannot be null. + /// + /// A reference to the current position in the text being processed. The position will be updated to point + /// to the next character after the closing quote upon method completion. + /// + /// A representing the consumed string literal, including its type, value, and starting index. + private static NeutralToken ConsumeString(string text, ref int position) + { + var start = position; + position++; // Consume opening quote before detecting closing quote + + while (position < text.Length) + { + // Closing quote without a previous escape character is the terminal position for the string. + if (text[position] is '\'' && text[position - 1] is not '$') + { + position++; + break; + } + + position++; + } + + var token = text.Substring(start, position - start); + return new NeutralToken(TokenType.Literal, token, start); + } + + /// + /// Determines the length of an operator at the specified position in the given text. + /// + /// The current position in the text from which to check the operator. + /// The text in which the operator's length is to be determined. + /// The length of the operator at the specified position, typically 1 or 2 characters. + private static int GetOperatorLength(int position, string text) + { + if (position >= text.Length) + return 0; + + var current = text[position]; + + switch (current) + { + case ':' when Peek(position, text) is '=': + case '<' or '>' when Peek(position, text) is '=': + case '<' when Peek(position, text) is '>': + case '*' when Peek(position, text) is '*': + return 2; + default: + return 1; + } + } + + /// + /// Retrieves the character at the specified position plus one in the given text, + /// or returns the minimum value of the type if the index is out of range. + /// + /// The base zero position of the character to peek at. + /// The string from which the character is to be retrieved. + /// The character at the position + 1 in the given string, + /// or if the position is out of the text's bounds. + private static char Peek(int index, string text) => index + 1 < text.Length ? text[index + 1] : char.MinValue; + + + /// + public override bool Equals(object? obj) + { + return obj switch + { + string text => StringComparer.OrdinalIgnoreCase.Equals(_text, text), + NeutralText other => StringComparer.OrdinalIgnoreCase.Equals(_text, other._text), + _ => false + }; + } + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(_text); + + /// + public override string ToString() => _text; + + /// + /// Implicitly converts a instance to its underlying value. + /// + /// The instance to convert. + /// The underlying string value of the . + public static implicit operator string(NeutralText text) => text.ToString(); + + /// + /// Implicitly converts a value to a new instance. + /// + /// The string value to convert. + /// A new instance wrapping the provided string. + public static implicit operator NeutralText(string text) => new NeutralText(text); +} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralTextExtensions.cs b/src/L5Sharp.Core/Common/NeutralTextExtensions.cs new file mode 100644 index 00000000..ce46d021 --- /dev/null +++ b/src/L5Sharp.Core/Common/NeutralTextExtensions.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace L5Sharp.Core; + +/// +/// Provides extension methods for working with neutral text tokens and converting them +/// into streamable representations for processing and manipulation. +/// +public static class NeutralTextExtensions +{ + /// + /// Converts a collection of neutral tokens into a NeutralStream for sequential processing. + /// + /// The collection of instances to convert into a stream. + /// A that wraps the provided tokens for sequential access and manipulation. + public static NeutralStream ToStream(this IEnumerable tokens) + { + return new NeutralStream(tokens); + } +} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralToken.cs b/src/L5Sharp.Core/Common/NeutralToken.cs new file mode 100644 index 00000000..3e977e10 --- /dev/null +++ b/src/L5Sharp.Core/Common/NeutralToken.cs @@ -0,0 +1,66 @@ +using System; + +namespace L5Sharp.Core; + +/// +/// Represents a parsed token from neutral text, containing the token's type classification, +/// raw text value, and position information for error reporting and parsing operations. +/// +public readonly struct NeutralToken +{ + /// + /// Creates a new NeutralToken with the specified type, value, and position information. + /// + /// The type classification of the token (e.g., Identifier, Operator). + /// The raw text content of the token as it appears in the source. + /// The zero-based starting position of the token in the original source string. + /// Thrown when is null. + /// Thrown when is null. + public NeutralToken(TokenType type, string value, int index) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + Value = value ?? throw new ArgumentNullException(nameof(value)); + Index = index; + } + + /// + /// Gets the type classification of the token, represented as an instance of . + /// This property specifies the role or category of the token within the parsed text, + /// such as identifier, literal, operator, or other token types. + /// + public TokenType Type { get; } + + /// + /// Gets the raw text content of the token as it was parsed from the source. + /// This property holds the exact string value of the token, which may represent + /// identifiers, literals, operators, or other syntactical elements in the original text. + /// + public string Value { get; } + + /// + /// Gets the zero-based starting position of the token in the original source string. + /// This property provides the positional information required for error reporting, + /// diagnostics, and locating the token within the parsed text. + /// + public int Index { get; } + + /// + /// Gets the total number of characters in the raw text content represented by the token. + /// This property provides the length of the associated + /// with the token, which can be useful for text processing or validation. + /// + public int Length => Value.Length; + + /// + /// Returns a string representation of the token showing its type, value, and position. + /// + /// A formatted string in the format "[Type] Value (at Index)". + public override string ToString() => $"[{Type.Name}] {Value} (at {Index})"; + + /// + /// Creates an end-of-file (EOF) NeutralToken with an optional position index. + /// + /// The zero-based position index where the EOF token is created. Default is -1. + /// A NeutralToken that represents the EOF, with an empty value and the specified index. + public static NeutralToken EOF(int index = -1) => new(TokenType.EOF, string.Empty, index); +} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index 10412a41..1faf38d8 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -359,9 +359,10 @@ public static IEnumerable ExtractAll(string text) /// two strings to join together, as it does not iterate a collection or use a string builder class. /// This method simply concatenates to strings. /// - public static TagName Concat(string left, string right) + public static TagName Concat(string left, string? right) { - if (string.IsNullOrEmpty(right)) return left; + if (right is null || right.IsEmpty()) + return left; if (right[0] == ArrayOpen || right[0] == Separator) return new TagName(left + right); @@ -376,7 +377,7 @@ public static TagName Concat(string left, string right) /// The series of strings that, in order, comprise the full tag name value. /// A new value that represents the combination of all provided member names. /// If any provided member does not match the member pattern format. - public static TagName Combine(params string[] members) => new(ConcatenateMembers(members.AsEnumerable())); + public static TagName Combine(params string?[] members) => new(ConcatenateMembers(members.AsEnumerable())); /// /// Combines a collection of member names into a single value. @@ -384,7 +385,7 @@ public static TagName Concat(string left, string right) /// The collection of strings that represent the member names of the tag name value. /// A new A new value that is the combination of all provided member names. /// If a provided name does not match the member pattern format. - public static TagName Combine(IEnumerable members) => new(ConcatenateMembers(members)); + public static TagName Combine(IEnumerable members) => new(ConcatenateMembers(members)); /// /// Determines if the provided objects are equal. @@ -553,12 +554,14 @@ private static string GetLocalTagName(string path) /// /// The collection of tag members to concatenate. /// A string representing the concatenated tag members. - private static string ConcatenateMembers(IEnumerable members) + private static string ConcatenateMembers(IEnumerable members) { var builder = new StringBuilder(); foreach (var member in members) { + if (member is null) continue; + if (!(member.StartsWith(ArrayOpen) || member.StartsWith(Separator)) && builder.Length > 1) builder.Append(Separator); diff --git a/src/L5Sharp.Core/Enums/ArgumentType.cs b/src/L5Sharp.Core/Enums/ArgumentType.cs index 345c84ef..f02edb55 100644 --- a/src/L5Sharp.Core/Enums/ArgumentType.cs +++ b/src/L5Sharp.Core/Enums/ArgumentType.cs @@ -19,7 +19,7 @@ private ArgumentType(string name, string value) : base(name, value) /// Returns if the input is null or empty, /// if the input is enclosed with single quotes, /// if the input infers a valid radix/numeric format, - /// if the input matches a valid tag name, + /// if the input matches a valid tag name, /// if the input contains expression characters, /// or otherwise. /// @@ -28,7 +28,9 @@ public static ArgumentType Of(string value) if (string.IsNullOrEmpty(value)) return Empty; if (value.StartsWith('\'') && value.EndsWith('\'')) return String; if (Radix.TryInfer(value, out _)) return Atomic; - if (TagName.IsTag(value)) return Tag; + // We can use the tag name patter match because system components should pass this as well + if (TagName.IsTag(value)) return Reference; + // todo I think we need to include bitwise operators (AND, OR, XOR, etc.) if (value.IndexOfAny(['=', '>', '<', '+', '-', '*', '/', '(', ')']) >= 0) return Expression; return Unknown; } @@ -58,10 +60,11 @@ public static ArgumentType Of(string value) public static readonly ArgumentType String = new(nameof(String), nameof(String)); /// - /// Represents an argument type that is specifically a tag. - /// This value is used for arguments defined as a reference to a specific tag. + /// Represents an argument type that corresponds to a tag or system component reference. + /// A value of this type signifies that the input matches a valid tag name + /// and adheres to the specific syntax and format required for tag references. /// - public static readonly ArgumentType Tag = new(nameof(Tag), nameof(Tag)); + public static readonly ArgumentType Reference = new(nameof(Reference), nameof(Reference)); /// /// Represents an argument type that is an expression. diff --git a/src/L5Sharp.Core/Enums/TokenType.cs b/src/L5Sharp.Core/Enums/TokenType.cs new file mode 100644 index 00000000..81cfe867 --- /dev/null +++ b/src/L5Sharp.Core/Enums/TokenType.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace L5Sharp.Core; + +/// +/// Represents the type of token identified during lexical analysis of Logix neutral text. +/// Used by the internal lexer to parse Logix code structures such as instructions, tag names, and expressions. +/// +public class TokenType : LogixEnum +{ + /// + /// Represents a collection of token types corresponding to various operators + /// used within the Logix neutral text framework. This collection aggregates + /// all available operator tokens from the core operator definitions. + /// + private static readonly HashSet Operators = + new(Core.Operator.All().Select(x => x.Value), StringComparer.OrdinalIgnoreCase); + + private TokenType(string name, int value) : base(name, value) + { + } + + /// + /// Represents an undefined or unrecognized token type encountered during lexical analysis of Logix neutral text. + /// Typically used as a placeholder when a token does not match any predefined token types. + /// + public static readonly TokenType Unknown = new(nameof(Unknown), 0); + + /// + /// Represents an identifier token such as instruction names (XIC, ADD), tag names (MyTag), or AOI names (My_AOI). + /// + public static readonly TokenType Identifier = new(nameof(Identifier), 1); + + /// + /// Represents a literal value token such as numeric literals (100, 16#FF) or string literals ('String'). + /// + public static readonly TokenType Literal = new(nameof(Literal), 2); + + /// + /// Represents an operator token such as arithmetic (+, -, *, /), assignment (:=), or logical (AND, OR) operators. + /// + public static readonly TokenType Operator = new(nameof(Operator), 3); + + /// + /// Represents an opening parenthesis token '(' used for instruction arguments and expression grouping. + /// + public static readonly TokenType OpenParen = new(nameof(OpenParen), 4); + + /// + /// Represents a closing parenthesis token ')' used for instruction arguments and expression grouping. + /// + public static readonly TokenType CloseParen = new(nameof(CloseParen), 5); + + /// + /// Represents an opening bracket token '[' used for array indexing and branch logic in rungs. + /// + public static readonly TokenType OpenBracket = new(nameof(OpenBracket), 6); + + /// + /// Represents a closing bracket token ']' used for array indexing and branch logic in rungs. + /// + public static readonly TokenType CloseBracket = new(nameof(CloseBracket), 7); + + /// + /// Represents a comma token ',' used to separate instruction arguments or array dimensions. + /// + public static readonly TokenType Comma = new(nameof(Comma), 8); + + /// + /// Represents a dot token '.' used for member access in tag names and data structures. + /// + public static readonly TokenType Dot = new(nameof(Dot), 9); + + /// + /// Represents a semicolon token ';' used to terminate instructions or rungs. + /// + public static readonly TokenType SemiColon = new(nameof(SemiColon), 10); + + /// + /// Represents the end-of-file token indicating the completion of input text parsing. + /// + public static readonly TokenType EOF = new(nameof(EOF), 11); + + /// + /// Determines the based on the given token string. + /// + /// The token string to be evaluated. + /// The determined of the provided token string. + public static TokenType FromToken(string token) + { + if (string.IsNullOrEmpty(token) || token.Length == 0) + return EOF; + + // Handle all known operator tokens first. These include math symbols and binary operators. + if (Operators.Contains(token)) + return Operator; + + return token[0] switch + { + // Special end-of-line character. + char.MinValue => EOF, + + // Known single character structure tokens + '(' when token.Length == 1 => OpenParen, + ')' when token.Length == 1 => CloseParen, + '[' when token.Length == 1 => OpenBracket, + ']' when token.Length == 1 => CloseBracket, + ',' when token.Length == 1 => Comma, + '.' when token.Length == 1 => Dot, + ';' when token.Length == 1 => SemiColon, + + // Verify balanced quotes for string literals + '\'' when token.Length >= 2 && token[token.Length - 1] == '\'' => Literal, + + // Literals start with a digit (100, 16#FF, 2#1011, etc.) + _ when char.IsDigit(token[0]) => Literal, + + // Identifiers start with a letter or underscore + _ when char.IsLetter(token[0]) || token[0] == '_' => Identifier, + + _ => Unknown + }; + } +} \ No newline at end of file diff --git a/src/L5Sharp.Core/L5Sharp.Core.csproj.DotSettings b/src/L5Sharp.Core/L5Sharp.Core.csproj.DotSettings index c7ddba64..b6a591df 100644 --- a/src/L5Sharp.Core/L5Sharp.Core.csproj.DotSettings +++ b/src/L5Sharp.Core/L5Sharp.Core.csproj.DotSettings @@ -6,6 +6,7 @@ True True True + True True True True diff --git a/src/L5Sharp.Core/LogixIndex.cs b/src/L5Sharp.Core/LogixIndex.cs index 7a4c3970..94470445 100644 --- a/src/L5Sharp.Core/LogixIndex.cs +++ b/src/L5Sharp.Core/LogixIndex.cs @@ -304,7 +304,7 @@ private void IndexCodeElement(XElement element) AddOrUpdateReference(instruction.Key, reference); - foreach (var tag in instruction.Arguments.Where(a => a.IsTag)) + foreach (var tag in instruction.Arguments.Where(a => a.IsReference)) AddOrUpdateReference(tag, reference); } } diff --git a/src/L5Sharp.sln.DotSettings b/src/L5Sharp.sln.DotSettings index f62aee42..1495ade5 100644 --- a/src/L5Sharp.sln.DotSettings +++ b/src/L5Sharp.sln.DotSettings @@ -21,6 +21,7 @@ EEO ELSE END + EOF FBD HMI HMIBC diff --git a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs index f78dc71c..c55ecbd8 100644 --- a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs @@ -1,10 +1,16 @@ -using FluentAssertions; +using FluentAssertions; namespace L5Sharp.Tests.Core.Common; [TestFixture] public class ArgumentTests { + [Test] + public void New_NullValue_ShouldThrowException() + { + FluentActions.Invoking(() => new Argument(null!)).Should().Throw(); + } + [Test] public void Empty_WhenCalled_ShouldHaveExpectedValue() { @@ -37,13 +43,23 @@ public void New_AtomicArgument_ShouldBeExpected() } [Test] - public void New_TagArgument_ShouldBeExpected() + public void New_SimpleNameArgument_ShouldBeExpected() + { + Argument argument = "MyComponent"; + + argument.Should().Be("MyComponent"); + argument.Type.Should().Be(ArgumentType.Reference); + argument.IsReference.Should().BeTrue(); + } + + [Test] + public void New_ComplexTagArgument_ShouldBeExpected() { Argument argument = "MyTagName.Member[1].Active.1"; argument.Should().Be("MyTagName.Member[1].Active.1"); - argument.Type.Should().Be(ArgumentType.Tag); - argument.IsTag.Should().BeTrue(); + argument.Type.Should().Be(ArgumentType.Reference); + argument.IsReference.Should().BeTrue(); } [Test] @@ -54,6 +70,7 @@ public void New_StringArgument_ShouldBeExpected() argument.Should().Be("'This is a test string'"); argument.Type.Should().Be(ArgumentType.String); argument.IsLiteral.Should().BeTrue(); + argument.IsString.Should().BeTrue(); } [Test] @@ -66,74 +83,43 @@ public void New_ExpressionArgument_ShouldBeExpected() argument.IsExpression.Should().BeTrue(); } - [Test] - public void New_NullValue_ShouldThrowException() - { - FluentActions.Invoking(() => new Argument(null!)).Should().Throw(); - } - - [Test] - public void New_EmptyValue_ShouldBeEmpty() - { - var argument = new Argument(string.Empty); - - argument.Should().Be(string.Empty); - argument.Type.Should().Be(ArgumentType.Empty); - } - - [Test] - public void New_UnknownValue_ShouldBeUnknown() - { - var argument = new Argument("?"); - - argument.Should().Be("?"); - argument.Type.Should().Be(ArgumentType.Unknown); - } - - [Test] - public void New_AtomicDecimalValue_ShouldHaveExpectedValueAndType() - { - var argument = new Argument("12345"); - - argument.Should().Be("12345"); - argument.Type.Should().Be(ArgumentType.Atomic); - } - - [Test] - public void New_AtomicBinaryValue_ShouldHaveExpectedValueAndType() - { - var argument = new Argument("2#0010_0110"); - - argument.Should().Be("2#0010_0110"); - argument.Type.Should().Be(ArgumentType.Atomic); - } - - [Test] - public void New_StringValue_ShouldHaveExpectedValueAndType() - { - var argument = new Argument("'Test String'"); - - argument.Should().Be("'Test String'"); - argument.Type.Should().Be(ArgumentType.String); - } - - - [Test] - public void New_TagNameValue_ShouldHaveExpectedValueAndType() - { - var argument = new Argument("MyTagName.Member[1].Active.1"); - - argument.Should().Be("MyTagName.Member[1].Active.1"); - argument.Type.Should().Be(ArgumentType.Tag); - } - - [Test] - public void New_ExpressionValue_ShouldHaveExpectedValueAndType() - { - var argument = new Argument("ABS(MyTagName) >= 1000"); - - argument.Should().Be("ABS(MyTagName) >= 1000"); - argument.Type.Should().Be(ArgumentType.Expression); + [TestCase("", "Empty")] + [TestCase("?", "Unknown")] + [TestCase(" ", "Unknown")] + [TestCase("!!", "Unknown")] + // Atomic (Numeric and Radix formats) + [TestCase("12345", "Atomic")] + [TestCase("2#0010_0110", "Atomic")] + [TestCase("8#77", "Atomic")] + [TestCase("16#ABCD", "Atomic")] + [TestCase("1.23", "Atomic")] + [TestCase("1.23e10", "Atomic")] + [TestCase("T#2h_30m", "Atomic")] + [TestCase("DT#2023-01-01-12:00:00.000000Z", "Atomic")] + // String Literals + [TestCase("'Test String'", "String")] + [TestCase("''", "String")] + [TestCase("'String with $P symbols'", "String")] + // Reference (Tags and System Components) + [TestCase("MyTagName.Member[1].Active.1", "Reference")] + [TestCase("Program:MainProgram.LocalTag", "Reference")] + [TestCase("MyArray[1,2,3]", "Reference")] + [TestCase("MyTag[NestedTag].MemberName", "Reference")] // Indirect addressing + [TestCase("Module:1:I.Data", "Reference")] // System/Module reference + [TestCase("FAULTLOG", "Reference")] // System component + // Expression + [TestCase("ABS(MyTagName) >= 1000", "Expression")] + [TestCase("(Value1 + Value2) * 10", "Expression")] + [TestCase("Value1 / 2", "Expression")] + [TestCase("Value1 < Value2", "Expression")] + [TestCase("Value1 = 1", "Expression")] + public void Type_WhenCalled_ShouldHaveExpectedValue(string value, string expected) + { + var argument = new Argument(value); + + var type = argument.Type; + + type.Should().Be(ArgumentType.Parse(expected)); } [Test] @@ -151,9 +137,9 @@ public void Arguments_ArgumentSingleAtomic_ShouldHaveExpectedCount() { Argument argument = 100; - var values = argument.Arguments; + var args = argument.Arguments; - values.Should().HaveCount(1); + args.Should().HaveCount(1); } [Test] @@ -161,11 +147,11 @@ public void Arguments_ExpressionWithSingleTagAndAtomic_ShouldHaveExpectedValue() { Argument argument = "MyTag > 100"; - var arguments = argument.Arguments; + var args = argument.Arguments; - arguments.Should().HaveCount(2); - arguments[0].Should().Be("MyTag"); - arguments[1].Should().Be("100"); + args.Should().HaveCount(2); + args[0].Should().Be("MyTag"); + args[1].Should().Be("100"); } [Test] @@ -173,13 +159,13 @@ public void Arguments_ExpressionWithMultipleTagsAndAtomics_ShouldHaveExpectedVal { Argument argument = "MyTag > 100 AND MyOtherTag < 16#ABCD"; - var arguments = argument.Arguments; + var args = argument.Arguments; - arguments.Should().HaveCount(4); - arguments[0].Should().Be("MyTag"); - arguments[1].Should().Be("100"); - arguments[2].Should().Be("MyOtherTag"); - arguments[3].Should().Be("16#ABCD"); + args.Should().HaveCount(4); + args[0].Should().Be("MyTag"); + args[1].Should().Be("100"); + args[2].Should().Be("MyOtherTag"); + args[3].Should().Be("16#ABCD"); } [Test] @@ -187,17 +173,19 @@ public void Arguments_ExpressionWithVariousAtomicFormats_ShouldExtractAll() { Argument argument = "16#1234 + 2#1010 + 8#77 + 1.23 + 123 + 1.#QNAN"; - var arguments = argument.Arguments; + var args = argument.Arguments; + + args.Should().HaveCount(6); + args.Select(v => v.ToString()).Should().BeEquivalentTo("16#1234", "2#1010", "8#77", "1.23", "123", "1.#QNAN"); + } + + [Test] + public void Argument_ExpressionWithNestedFunctions_ShouldReturnAllNestedArguments() + { + Argument argument = "(ABS(MyTag.Member) + Another[1,2,3]) / (10**(SomeConstant - SystemTag[IndexReference]))"; - arguments.Should().HaveCount(6); + var args = argument.Arguments; - arguments.Select(v => v.ToString()).Should().BeEquivalentTo([ - "16#1234", - "2#1010", - "8#77", - "1.23", - "123", - "1.#QNAN" - ]); + args.Should().HaveCount(6); } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs b/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs new file mode 100644 index 00000000..fe67980f --- /dev/null +++ b/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs @@ -0,0 +1,244 @@ +using FluentAssertions; + +namespace L5Sharp.Tests.Core.Common; + +[TestFixture] +public class NeutralTextTests +{ + [Test] + public void Constructor_NullText_ShouldThrowArgumentNullException() + { + Action act = () => _ = new NeutralText(null!); + + act.Should().Throw(); + } + + [Test] + public void Tokenize_SimpleIdentifier_ShouldReturnIdentifierAndEOF() + { + var text = new NeutralText("MyTag"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Identifier); + tokens[0].Value.Should().Be("MyTag"); + tokens[0].Index.Should().Be(0); + tokens[1].Type.Should().Be(TokenType.EOF); + tokens[1].Index.Should().Be(5); + } + + [Test] + public void Tokenize_Literal_ShouldReturnLiteralAndEOF() + { + var text = new NeutralText("123.4"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Literal); + tokens[0].Value.Should().Be("123.4"); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_Operator_ShouldReturnOperatorAndEOF() + { + var text = new NeutralText("+"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Operator); + tokens[0].Value.Should().Be("+"); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_MultiCharOperator_ShouldReturnOperatorAndEOF() + { + var text = new NeutralText(":="); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Operator); + tokens[0].Value.Should().Be(":="); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_Structural_ShouldReturnStructuralAndEOF() + { + var text = new NeutralText("("); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.OpenParen); + tokens[0].Value.Should().Be("("); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_StringLiteral_ShouldReturnLiteralAndEOF() + { + var text = new NeutralText("'Test String'"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Literal); + tokens[0].Value.Should().Be("'Test String'"); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_StringLiteralWithEscape_ShouldReturnLiteralAndEOF() + { + var text = new NeutralText("'String with $'quote$' inside'"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Literal); + tokens[0].Value.Should().Be("'String with $'quote$' inside'"); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_MixedText_ShouldReturnExpectedTokens() + { + var text = new NeutralText("XIC(MyTag) ADD(1, 2, Result);"); + + var tokens = text.Tokenize().ToList(); + + // XIC, (, MyTag, ), ADD, (, 1, ,, 2, ,, Result, ), ;, EOF + tokens.Should().HaveCount(14); + + tokens[0].Value.Should().Be("XIC"); + tokens[1].Value.Should().Be("("); + tokens[2].Value.Should().Be("MyTag"); + tokens[3].Value.Should().Be(")"); + tokens[4].Value.Should().Be("ADD"); + tokens[5].Value.Should().Be("("); + tokens[6].Value.Should().Be("1"); + tokens[7].Value.Should().Be(","); + tokens[8].Value.Should().Be("2"); + tokens[9].Value.Should().Be(","); + tokens[10].Value.Should().Be("Result"); + tokens[11].Value.Should().Be(")"); + tokens[12].Value.Should().Be(";"); + tokens[13].Type.Should().Be(TokenType.EOF); + } + + [Test] + [TestCase("MOD")] + [TestCase("AND")] + [TestCase("OR")] + [TestCase("XOR")] + [TestCase("NOT")] + public void Tokenize_KeywordOperators_ShouldReturnOperator(string op) + { + var text = new NeutralText(op); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Operator); + tokens[0].Value.Should().Be(op); + } + + [Test] + [TestCase("<=")] + [TestCase(">=")] + [TestCase("<>")] + [TestCase("**")] + public void Tokenize_OtherMultiCharOperators_ShouldReturnOperator(string op) + { + var text = new NeutralText(op); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Operator); + tokens[0].Value.Should().Be(op); + } + + [Test] + public void Tokenize_Whitespace_ShouldBeIgnored() + { + var text = new NeutralText(" MyTag "); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Value.Should().Be("MyTag"); + tokens[0].Index.Should().Be(2); + } + + [Test] + public void Tokenize_UnexpectedCharacter_ShouldThrowArgumentException() + { + var text = new NeutralText("MyTag #"); // # alone is not valid at start of token + + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + Action act = () => text.Tokenize().ToList(); + + act.Should().Throw().WithMessage("*Unexpected character '#'*"); + } + + [Test] + public void Equals_SameText_ShouldBeTrue() + { + var text1 = new NeutralText("MyTag"); + var text2 = new NeutralText("mytag"); + + text1.Equals(text2).Should().BeTrue(); + } + + [Test] + public void Equals_String_ShouldBeTrue() + { + var text = new NeutralText("MyTag"); + + // ReSharper disable once SuspiciousTypeConversion.Global + text.Equals("mytag").Should().BeTrue(); + } + + [Test] + public void ToString_WhenCalled_ShouldReturnOriginalText() + { + var text = new NeutralText("MyTag"); + + text.ToString().Should().Be("MyTag"); + } + + [Test] + public void GetHashCode_SameText_ShouldBeEqual() + { + var text1 = new NeutralText("MyTag"); + var text2 = new NeutralText("mytag"); + + text1.GetHashCode().Should().Be(text2.GetHashCode()); + } + + [Test] + public void ImplicitOperator_ToString_ShouldReturnExpectedValue() + { + NeutralText text = "MyTag"; + string value = text; + + value.Should().Be("MyTag"); + } + + [Test] + public void ImplicitOperator_FromString_ShouldReturnExpectedValue() + { + const string value = "MyTag"; + + NeutralText text = value; + + text.Should().Be(new NeutralText("MyTag")); + } +} \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs b/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs new file mode 100644 index 00000000..398b14a1 --- /dev/null +++ b/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using L5Sharp.Core; + +namespace L5Sharp.Tests.Core.Common; + +[TestFixture] +public class NeutralTokenTests +{ + [Test] + public void Constructor_ValidArguments_ShouldSetProperties() + { + var token = new NeutralToken(TokenType.Identifier, "MyTag", 10); + + token.Type.Should().Be(TokenType.Identifier); + token.Value.Should().Be("MyTag"); + token.Index.Should().Be(10); + } + + [Test] + public void Constructor_NullType_ShouldThrowArgumentNullException() + { + Action act = () => new NeutralToken(null!, "value", 0); + + act.Should().Throw().WithParameterName("type"); + } + + [Test] + public void Constructor_NullValue_ShouldThrowArgumentNullException() + { + Action act = () => new NeutralToken(TokenType.Identifier, null!, 0); + + act.Should().Throw().WithParameterName("value"); + } + + [Test] + public void Length_WhenCalled_ShouldReturnValueLength() + { + var token = new NeutralToken(TokenType.Literal, "123", 5); + + token.Length.Should().Be(3); + } + + [Test] + public void ToString_WhenCalled_ShouldReturnExpectedFormat() + { + var token = new NeutralToken(TokenType.Operator, "+", 15); + + var result = token.ToString(); + + result.Should().Be("[Operator] + (at 15)"); + } + + [Test] + public void EOF_DefaultIndex_ShouldHaveExpectedProperties() + { + var token = NeutralToken.EOF(); + + token.Type.Should().Be(TokenType.EOF); + token.Value.Should().BeEmpty(); + token.Index.Should().Be(-1); + } + + [Test] + public void EOF_CustomIndex_ShouldHaveExpectedIndex() + { + var token = NeutralToken.EOF(100); + + token.Type.Should().Be(TokenType.EOF); + token.Value.Should().BeEmpty(); + token.Index.Should().Be(100); + } +} \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs b/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs index dd2f49f1..18849a28 100644 --- a/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs +++ b/tests/L5Sharp.Tests.Core/Enums/ArgumentTypeTests.cs @@ -32,7 +32,7 @@ public void String_WhenCalled_ShouldNotBeNull() [Test] public void Tag_WhenCalled_ShouldNotBeNull() { - ArgumentType.Tag.Should().NotBeNull(); + ArgumentType.Reference.Should().NotBeNull(); } [Test] @@ -102,6 +102,6 @@ public void Of_ValidTagName_ShouldBeTag() { var type = ArgumentType.Of("MyTag.Member[0].1"); - type.Should().Be(ArgumentType.Tag); + type.Should().Be(ArgumentType.Reference); } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Enums/TokenTypeTests.cs b/tests/L5Sharp.Tests.Core/Enums/TokenTypeTests.cs new file mode 100644 index 00000000..7a9ad681 --- /dev/null +++ b/tests/L5Sharp.Tests.Core/Enums/TokenTypeTests.cs @@ -0,0 +1,205 @@ +using FluentAssertions; + +namespace L5Sharp.Tests.Core.Enums; + +[TestFixture] +public class TokenTypeTests +{ + [Test] + public void Unknown_WhenCalled_ShouldNotBeNull() + { + TokenType.Unknown.Should().NotBeNull(); + } + + [Test] + public void Identifier_WhenCalled_ShouldNotBeNull() + { + TokenType.Identifier.Should().NotBeNull(); + } + + [Test] + public void Literal_WhenCalled_ShouldNotBeNull() + { + TokenType.Literal.Should().NotBeNull(); + } + + [Test] + public void Operator_WhenCalled_ShouldNotBeNull() + { + TokenType.Operator.Should().NotBeNull(); + } + + [Test] + public void OpenParen_WhenCalled_ShouldNotBeNull() + { + TokenType.OpenParen.Should().NotBeNull(); + } + + [Test] + public void CloseParen_WhenCalled_ShouldNotBeNull() + { + TokenType.CloseParen.Should().NotBeNull(); + } + + [Test] + public void OpenBracket_WhenCalled_ShouldNotBeNull() + { + TokenType.OpenBracket.Should().NotBeNull(); + } + + [Test] + public void CloseBracket_WhenCalled_ShouldNotBeNull() + { + TokenType.CloseBracket.Should().NotBeNull(); + } + + [Test] + public void Comma_WhenCalled_ShouldNotBeNull() + { + TokenType.Comma.Should().NotBeNull(); + } + + [Test] + public void Dot_WhenCalled_ShouldNotBeNull() + { + TokenType.Dot.Should().NotBeNull(); + } + + [Test] + public void SemiColon_WhenCalled_ShouldNotBeNull() + { + TokenType.SemiColon.Should().NotBeNull(); + } + + [Test] + public void EOF_WhenCalled_ShouldNotBeNull() + { + TokenType.EOF.Should().NotBeNull(); + } + + [Test] + [TestCase(null)] + [TestCase("")] + public void FromToken_NullOrEmpty_ShouldBeEOF(string? token) + { + var result = TokenType.FromToken(token!); + + result.Should().Be(TokenType.EOF); + } + + [Test] + [TestCase("+")] + [TestCase("-")] + [TestCase("*")] + [TestCase("/")] + [TestCase("**")] + [TestCase("MOD")] + [TestCase("AND")] + [TestCase("OR")] + [TestCase("XOR")] + [TestCase("NOT")] + [TestCase(":=")] + [TestCase("=")] + [TestCase("<>")] + [TestCase(">")] + [TestCase(">=")] + [TestCase("<")] + [TestCase("<=")] + public void FromToken_OperatorValue_ShouldBeOperator(string token) + { + var result = TokenType.FromToken(token); + + result.Should().Be(TokenType.Operator); + } + + [Test] + public void FromToken_OpenParen_ShouldBeOpenParen() + { + var result = TokenType.FromToken("("); + + result.Should().Be(TokenType.OpenParen); + } + + [Test] + public void FromToken_CloseParen_ShouldBeCloseParen() + { + var result = TokenType.FromToken(")"); + + result.Should().Be(TokenType.CloseParen); + } + + [Test] + public void FromToken_OpenBracket_ShouldBeOpenBracket() + { + var result = TokenType.FromToken("["); + + result.Should().Be(TokenType.OpenBracket); + } + + [Test] + public void FromToken_CloseBracket_ShouldBeCloseBracket() + { + var result = TokenType.FromToken("]"); + + result.Should().Be(TokenType.CloseBracket); + } + + [Test] + public void FromToken_Comma_ShouldBeComma() + { + var result = TokenType.FromToken(","); + + result.Should().Be(TokenType.Comma); + } + + [Test] + public void FromToken_Dot_ShouldBeDot() + { + var result = TokenType.FromToken("."); + + result.Should().Be(TokenType.Dot); + } + + [Test] + public void FromToken_SemiColon_ShouldBeSemiColon() + { + var result = TokenType.FromToken(";"); + + result.Should().Be(TokenType.SemiColon); + } + + [Test] + [TestCase("123")] + [TestCase("16#FF")] + [TestCase("2#1011")] + [TestCase("'String'")] + [TestCase("'Another string'")] + public void FromToken_LiteralValue_ShouldBeLiteral(string token) + { + var result = TokenType.FromToken(token); + + result.Should().Be(TokenType.Literal); + } + + [Test] + [TestCase("MyTag")] + [TestCase("XIC")] + [TestCase("ADD")] + [TestCase("_MyTag")] + [TestCase("Tag_Name")] + [TestCase("tag123")] + public void FromToken_IdentifierValue_ShouldBeIdentifier(string token) + { + var result = TokenType.FromToken(token); + + result.Should().Be(TokenType.Identifier); + } + + [Test] + public void FromToken_UnknownValue_ShouldBeUnknown() + { + var result = TokenType.FromToken("!@#"); + + result.Should().Be(TokenType.Unknown); + } +} \ No newline at end of file From 24a1d2f6da1b20dca50671c3d7e47388aff34dae Mon Sep 17 00:00:00 2001 From: tnunnink Date: Fri, 22 May 2026 10:59:52 -0500 Subject: [PATCH 10/13] Refactored `NeutralStream` to utilize arrays for improved memory efficiency and added new methods for advanced token stream navigation. Enhanced `NeutralText` and `NeutralTextExtensions` with updated operator handling, additional tokenization features, and streamlined helper methods. Replaced `EOF` with `None` in `NeutralToken` for improved clarity. --- src/L5Sharp.Core/Common/NeutralStream.cs | 225 +++++++++++++++--- src/L5Sharp.Core/Common/NeutralText.cs | 50 ++-- .../Common/NeutralTextExtensions.cs | 12 + src/L5Sharp.Core/Common/NeutralToken.cs | 8 +- 4 files changed, 226 insertions(+), 69 deletions(-) diff --git a/src/L5Sharp.Core/Common/NeutralStream.cs b/src/L5Sharp.Core/Common/NeutralStream.cs index 73f1a982..04586302 100644 --- a/src/L5Sharp.Core/Common/NeutralStream.cs +++ b/src/L5Sharp.Core/Common/NeutralStream.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; namespace L5Sharp.Core; @@ -12,79 +13,243 @@ public class NeutralStream /// /// The internal collection of tokens to be processed by this stream. /// - private readonly List _tokens; + private readonly NeutralToken[] _tokens; /// /// The zero-based index of the current position in the token stream. /// private int _currentIndex; + /// + /// The zero-based index representing the last valid position in the internal token array. + /// Used to determine the boundary for processing and prevent out-of-range access during parsing. + /// + private readonly int _endIndex; + /// /// Initializes a new instance of the class with the specified token sequence. /// /// The sequence of tokens to process. The tokens are materialized into an internal list. public NeutralStream(IEnumerable tokens) { - _tokens = tokens.ToList(); + _tokens = tokens.ToArray(); + _endIndex = _tokens.Length > 0 ? _tokens.Length - 1 : 0; } /// - /// Consumes the current token from the stream and advances the position to the next token. + /// Initializes a new instance of the class that provides a view into an existing token array + /// starting at the specified index. This constructor enables memory-efficient stream slicing by reusing the underlying + /// token array rather than creating a copy, which is particularly useful when skipping a known prefix sequence. /// - /// The token at the current position before advancing. - public NeutralToken Consume() => ConsumeToken(); + /// The existing array of tokens to use as the underlying data source. + /// The zero-based index at which this stream should begin reading tokens from the array. + public NeutralStream(NeutralToken[] tokens, int startIndex) + { + _tokens = tokens; + _currentIndex = startIndex; + _endIndex = _tokens.Length > 0 ? _tokens.Length - 1 : 0; + } + + /// + /// Gets an empty instance of the class. + /// This static property provides a neutral stream initialized with no tokens. + /// + public static NeutralStream Empty => new([]); + + /// + /// Gets the total number of tokens available in the current stream. + /// + public int Length => _tokens.Length; + + /// + /// Attempts to read the next token from the stream. + /// + /// When this method returns, contains the token at the current position if the read was successful; + /// otherwise, the default value. + /// true if a token was successfully read and the stream is not at the end; otherwise, false. + /// + /// If the stream has reached the end, this method returns false and the + /// will be an EOF token. + /// + public bool Read(out NeutralToken token) + { + token = CurrentToken(); + + if (_currentIndex < _endIndex) + { + _currentIndex++; + return true; + } + + return false; + } /// /// Returns the current token without consuming it or advancing the stream position. - /// If the stream is at the end, returns an end-of-file token. + /// If the stream is at the end, it returns an end-of-file token. /// /// The token at the current position, or an end-of-file token if no more tokens are available. - public NeutralToken Peek() => GetToken(); + public NeutralToken Peek() => CurrentToken(); + + /// + /// Retrieves the next token from the stream without advancing the current position. + /// If no more tokens are available, an EOF token is returned. + /// + /// The next in the stream, or an EOF token if the end of the stream is reached. + public NeutralToken PeekNext() => NextToken(); + + /// + /// Retrieves the last processed token in the sequence relative to the current position. + /// If no tokens have been processed, returns a default or ending token. + /// + /// The last processed or a default token if none exists. + public NeutralToken PeekLast() => LastToken(); /// /// Checks if the current token matches the specified token type without consuming it. /// /// The token type to compare against the current token. /// true if the current token's type matches the specified type; otherwise, false. - public bool Match(TokenType type) => GetToken().Type == type; + public bool Match(TokenType type) => CurrentToken().Type == type; /// - /// Indicates whether the stream has reached the end of the token collection - /// or encountered an end-of-file (EOF) token. + /// Determines whether the next token in the stream matches any of the specified token types. /// - public bool Ended => _currentIndex >= _tokens.Count || GetToken().Type == TokenType.EOF; + /// An array of token types to compare against the type of the next token. + /// Returns true if the next token matches one of the specified token types; otherwise, false. + public bool HasNext(params TokenType[] types) => types.Contains(NextToken().Type); /// - /// Consumes the current token from the stream, advances the position to the next token, and returns the consumed token. - /// If the stream is at the end, it returns an end-of-file token. + /// Determines if the last token matches any of the specified token types. + /// + /// The array of token types to match against the last token. + /// True if the last token matches one of the specified types, otherwise false. + public bool HasLast(params TokenType[] types) => types.Contains(LastToken().Type); + + /// + /// Attempts to move the current position in the token stream by the specified number of tokens. + /// + /// + /// The number of tokens to move the position. Positive values move forward, and negative values move backward. + /// + /// + /// True if the operation successfully moved the position within the bounds of the token stream; + /// false if the position was clamped to the start or end of the stream. + /// + public bool Seek(int count = 1) => SeekToken(count); + + /// + /// Resets the stream's position to the beginning of the token sequence. /// - /// The token at the current position before advancing, or an end-of-file token if the stream is at the end. - private NeutralToken ConsumeToken() + /// + /// This operation enables reprocessing of tokens from the start of the stream. + /// + public NeutralStream SeekBegin() { - var token = GetToken(); + _currentIndex = 0; + return this; + } - if (_currentIndex < _tokens.Count) + /// + /// Moves the current position to the last token in the stream. + /// + /// + /// This operation enables consumption of tokens from the end of the stream. + /// + public NeutralStream SeekEnd() + { + _currentIndex = _endIndex; + return this; + } + + /// + /// Advances the stream forward by seeking the next token that matches the specified condition. + /// + /// + /// A function to evaluate each token in the stream. + /// The stream advances until a token satisfying the predicate is found. + /// + /// + /// Returns true if a matching token is found; + /// otherwise, false if the end of the stream is reached without a match. + /// + public bool SeekForward(Func predicate) + { + while (SeekToken(1)) { - _currentIndex++; + var token = CurrentToken(); + if (predicate(token)) return true; } - return token; + return false; } /// - /// Retrieves the token at the current stream position without advancing the position. - /// If the current position exceeds the number of available tokens, an end-of-file token is returned. + /// Advances the stream backward by seeking the previous token that matches the specified condition. /// - /// The token at the current position, or an end-of-file token if no more tokens are available. - private NeutralToken GetToken() + /// + /// A function to evaluate each token in the stream. + /// The stream advances backward until a token satisfying the predicate is found. + /// + /// + /// Returns true if a matching token is found; + /// otherwise, false if the beginning of the stream is reached without a match. + /// + public bool SeekBack(Func predicate) + { + while (SeekToken(-1)) + { + var token = CurrentToken(); + if (predicate(token)) return true; + } + + return false; + } + + /// + /// Moves the current position in the token stream by the specified number of tokens. + /// + /// + /// The number of tokens to move the position. Positive values move forward, and negative values move backward. + /// + /// + /// True if the position was successfully moved within the bounds of the token stream; + /// false if the position was clamped to the start or end of the stream. + /// + private bool SeekToken(int count) { - if (_currentIndex < _tokens.Count) - return _tokens[_currentIndex]; + var position = _currentIndex + count; - if (_tokens.Count == 0) - return NeutralToken.EOF(0); + if (position < 0) + { + _currentIndex = 0; + return false; + } - var last = _tokens[_tokens.Count - 1]; - return NeutralToken.EOF(last.Index + last.Length); + if (position >= _endIndex) + { + _currentIndex = _endIndex; + return false; + } + + _currentIndex = position; + return true; } + + /// + /// Retrieves the current token at the stream's current position. + /// + private NeutralToken CurrentToken() => _tokens[_currentIndex]; + + /// + /// Retrieves the next token in the stream without advancing the current position. + /// If the current position is at the end of the stream, returns . + /// + private NeutralToken NextToken() => _currentIndex + 1 <= _endIndex ? _tokens[_currentIndex + 1] : NeutralToken.None; + + /// + /// Retrieves the previous token from the token stream without advancing the current position. + /// If the beginning of the token stream is reached, returns . + /// + private NeutralToken LastToken() => _currentIndex - 1 >= 0 ? _tokens[_currentIndex - 1] : NeutralToken.None; } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralText.cs b/src/L5Sharp.Core/Common/NeutralText.cs index 86b1cf94..2536436d 100644 --- a/src/L5Sharp.Core/Common/NeutralText.cs +++ b/src/L5Sharp.Core/Common/NeutralText.cs @@ -47,25 +47,30 @@ public IEnumerable Tokenize() var token = current switch { + // Handle special case 2 character operators + ':' when PeekNext(_text, position) is '=' => Consume(_text, ref position, 2), + '<' or '>' when PeekNext(_text, position) is '=' => Consume(_text, ref position, 2), + '<' when PeekNext(_text, position) is '>' => Consume(_text, ref position, 2), + '*' when PeekNext(_text, position) is '*' => Consume(_text, ref position, 2), + // Handle all other single character operators + _ when IsOperator(current) => Consume(_text, ref position), _ when IsStructural(current) => Consume(_text, ref position), - _ when IsOperator(current) => Consume(_text, ref position, GetOperatorLength(position, _text)), - _ when IsStringQuote(current) => ConsumeString(_text, ref position), - //todo just not sure if we need to cover date times. I don't think we do, so this should work. + _ when IsString(current) => ConsumeString(_text, ref position), _ when char.IsDigit(current) => ConsumeWhile(_text, ref position, IsLiteral), _ when char.IsLetter(current) || current is '_' => ConsumeWhile(_text, ref position, IsIdentifier), _ => throw new ArgumentException( - $"Unexpected character '{current}' at position {position} of text {_text}") + $"Unexpected character '{current}' at position {position} of text: {_text}") }; yield return token; } - yield return NeutralToken.EOF(position); + yield return new NeutralToken(TokenType.EOF, string.Empty, position); yield break; - bool IsStructural(char c) => c is '(' or ')' or '[' or ']' or ',' or '.' or ';'; - bool IsOperator(char c) => c is '+' or '-' or '/' or '*' or '=' or '<' or '>' or ':'; - bool IsStringQuote(char c) => c is '\''; + bool IsOperator(char c) => c is '+' or '-' or '/' or '*' or '=' or '<' or '>'; + bool IsStructural(char c) => c is '(' or ')' or '[' or ']' or ',' or '.' or ':' or ';' or '?'; + bool IsString(char c) => c is '\''; bool IsLiteral(char c) => char.IsLetterOrDigit(c) || c is '#' or '.'; bool IsIdentifier(char c) => char.IsLetterOrDigit(c) || c is '_'; } @@ -149,31 +154,6 @@ private static NeutralToken ConsumeString(string text, ref int position) return new NeutralToken(TokenType.Literal, token, start); } - /// - /// Determines the length of an operator at the specified position in the given text. - /// - /// The current position in the text from which to check the operator. - /// The text in which the operator's length is to be determined. - /// The length of the operator at the specified position, typically 1 or 2 characters. - private static int GetOperatorLength(int position, string text) - { - if (position >= text.Length) - return 0; - - var current = text[position]; - - switch (current) - { - case ':' when Peek(position, text) is '=': - case '<' or '>' when Peek(position, text) is '=': - case '<' when Peek(position, text) is '>': - case '*' when Peek(position, text) is '*': - return 2; - default: - return 1; - } - } - /// /// Retrieves the character at the specified position plus one in the given text, /// or returns the minimum value of the type if the index is out of range. @@ -182,7 +162,7 @@ private static int GetOperatorLength(int position, string text) /// The string from which the character is to be retrieved. /// The character at the position + 1 in the given string, /// or if the position is out of the text's bounds. - private static char Peek(int index, string text) => index + 1 < text.Length ? text[index + 1] : char.MinValue; + private static char PeekNext(string text, int index) => index + 1 < text.Length ? text[index + 1] : char.MinValue; /// @@ -214,5 +194,5 @@ public override bool Equals(object? obj) /// /// The string value to convert. /// A new instance wrapping the provided string. - public static implicit operator NeutralText(string text) => new NeutralText(text); + public static implicit operator NeutralText(string text) => new(text); } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralTextExtensions.cs b/src/L5Sharp.Core/Common/NeutralTextExtensions.cs index ce46d021..cf528f9b 100644 --- a/src/L5Sharp.Core/Common/NeutralTextExtensions.cs +++ b/src/L5Sharp.Core/Common/NeutralTextExtensions.cs @@ -17,4 +17,16 @@ public static NeutralStream ToStream(this IEnumerable tokens) { return new NeutralStream(tokens); } + + /// + /// Converts an array of neutral tokens into a for sequential processing, + /// starting from the specified index within the token array. + /// + /// The array of instances to convert into a stream. + /// The starting index within the token array to begin the stream. Defaults to 0 if not provided. + /// A that wraps the specified tokens for sequential access and manipulation, starting at the given index. + public static NeutralStream ToStream(this NeutralToken[] tokens, int startIndex = 0) + { + return new NeutralStream(tokens, startIndex); + } } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralToken.cs b/src/L5Sharp.Core/Common/NeutralToken.cs index 3e977e10..ce0ff12f 100644 --- a/src/L5Sharp.Core/Common/NeutralToken.cs +++ b/src/L5Sharp.Core/Common/NeutralToken.cs @@ -58,9 +58,9 @@ public NeutralToken(TokenType type, string value, int index) public override string ToString() => $"[{Type.Name}] {Value} (at {Index})"; /// - /// Creates an end-of-file (EOF) NeutralToken with an optional position index. + /// Gets a predefined instance of representing the absence of a token or a null state. + /// This property is commonly used as a default or uninitialized value to indicate that no meaningful token + /// has been parsed or assigned in a specific context. /// - /// The zero-based position index where the EOF token is created. Default is -1. - /// A NeutralToken that represents the EOF, with an empty value and the specified index. - public static NeutralToken EOF(int index = -1) => new(TokenType.EOF, string.Empty, index); + public static NeutralToken None => new(TokenType.None, string.Empty, -1); } \ No newline at end of file From 206bd694ad6a11d01adbfd31da505e5541c1e92c Mon Sep 17 00:00:00 2001 From: tnunnink Date: Fri, 22 May 2026 13:13:46 -0500 Subject: [PATCH 11/13] Removed obsolete `NeutralTextBuilder` and `NeutralTextExtensions`. Refactored `NeutralStream` for iterator-based token processing, simplifying functionality and enhancing memory efficiency. Updated `TagName` with the new `Append` method and switched from concatenation to method chaining for creating hierarchical tag names throughout the codebase. Replaced unused logic in `Argument` with `NotImplementedException`. Expanded `NeutralText` test cases for broader tokenization coverage. --- .../NeutralText/INeutralTextBuilder.cs | 13 - src/L5Sharp.Core/Code/Block.cs | 6 +- src/L5Sharp.Core/Common/Argument.cs | 19 +- src/L5Sharp.Core/Common/NeutralStream.cs | 207 +++-------- .../Common/NeutralTextExtensions.cs | 32 -- src/L5Sharp.Core/Common/TagName.cs | 336 +++++++++++------- src/L5Sharp.Core/Components/Tag.cs | 2 +- src/L5Sharp.Core/Enums/TokenType.cs | 50 ++- .../Utilities/ElementExtensions.cs | 2 +- .../Common/NeutralTextTests.cs | 133 ++++++- .../Common/NeutralTokenTests.cs | 21 +- .../L5Sharp.Tests.Core/Common/TagNameTests.cs | 105 +++++- 12 files changed, 523 insertions(+), 403 deletions(-) delete mode 100644 src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs delete mode 100644 src/L5Sharp.Core/Common/NeutralTextExtensions.cs diff --git a/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs b/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs deleted file mode 100644 index 5f7c9251..00000000 --- a/src/L5Sharp.Core/Builders/NeutralText/INeutralTextBuilder.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace L5Sharp.Core; - -/// -/// -/// -public interface INeutralTextBuilder -{ - /// - /// - /// - /// - NeutralText Build(); -} \ No newline at end of file diff --git a/src/L5Sharp.Core/Code/Block.cs b/src/L5Sharp.Core/Code/Block.cs index 8c37b2d1..d7cd7b29 100644 --- a/src/L5Sharp.Core/Code/Block.cs +++ b/src/L5Sharp.Core/Code/Block.cs @@ -474,7 +474,7 @@ private static IEnumerable GetBlockTags(XElement element) return element.Attributes() .Where(a => PinNames.Contains(a.Name.LocalName)) .SelectMany(a => a.Value.Split(' ')) - .Select(t => TagName.Concat(operand, t)); + .Select(t => operand.ToTagName().Append(t)); } /// @@ -511,7 +511,7 @@ private static IEnumerable GetBlockInputs(XElement element) foreach (var wire in inputWires) { var operand = sheet.Elements().Single(e => e.Attribute(L5XName.ID)?.Value == wire.Id).GetBlockOperand(); - tagNames.Add(TagName.Concat(operand, wire.Param ?? TagName.Empty)); + tagNames.Add(operand.ToTagName().Append(wire.Param)); } return tagNames; @@ -543,7 +543,7 @@ private static IEnumerable GetBlockOutputs(XElement element) foreach (var wire in outputWires) { var operand = sheet.Elements().Single(e => e.Attribute(L5XName.ID)?.Value == wire.Id).GetBlockOperand(); - tagNames.Add(TagName.Concat(operand, wire.Param ?? TagName.Empty)); + tagNames.Add(operand.ToTagName().Append(wire.Param)); } return tagNames; diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index 4e7a71de..2fe982bf 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -281,23 +281,6 @@ private Argument[] ExtractArguments() // If this is a reference or literal, then just return itself. if (!IsExpression) return [this]; - // If it looks like a function call "NAME(ARGS)", extract the arguments inside the parentheses - // and return the nested argument. - var functionMatch = Regex.Match(_value, @"^[A-Z_]+\((.*)\)$", RegexOptions.IgnoreCase); - if (functionMatch.Success) - { - Argument function = functionMatch.Groups[1].Value; - return function.ExtractArguments(); - } - - // Split expression on known operators. Trim spaces and parenthesis since they are not important. - return _value - .Split(Operators, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Select(t => t.TrimStart('(')) - .Select(t => t.TrimEnd(')')) - .Select(x => new Argument(x)) - .SelectMany(a => a.ExtractArguments()) - .ToArray(); + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralStream.cs b/src/L5Sharp.Core/Common/NeutralStream.cs index 04586302..9d688c89 100644 --- a/src/L5Sharp.Core/Common/NeutralStream.cs +++ b/src/L5Sharp.Core/Common/NeutralStream.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; namespace L5Sharp.Core; @@ -8,59 +7,22 @@ namespace L5Sharp.Core; /// Provides a stream-based interface for sequentially processing tokens from neutral text. /// Maintains position state and supports lookahead operations for token-by-token parsing. /// -public class NeutralStream +public class NeutralStream : IDisposable { /// /// The internal collection of tokens to be processed by this stream. /// - private readonly NeutralToken[] _tokens; + private readonly IEnumerator _enumerator; /// - /// The zero-based index of the current position in the token stream. + /// Initializes a new instance of the class with the specified neutral text. /// - private int _currentIndex; - - /// - /// The zero-based index representing the last valid position in the internal token array. - /// Used to determine the boundary for processing and prevent out-of-range access during parsing. - /// - private readonly int _endIndex; - - /// - /// Initializes a new instance of the class with the specified token sequence. - /// - /// The sequence of tokens to process. The tokens are materialized into an internal list. - public NeutralStream(IEnumerable tokens) - { - _tokens = tokens.ToArray(); - _endIndex = _tokens.Length > 0 ? _tokens.Length - 1 : 0; - } - - /// - /// Initializes a new instance of the class that provides a view into an existing token array - /// starting at the specified index. This constructor enables memory-efficient stream slicing by reusing the underlying - /// token array rather than creating a copy, which is particularly useful when skipping a known prefix sequence. - /// - /// The existing array of tokens to use as the underlying data source. - /// The zero-based index at which this stream should begin reading tokens from the array. - public NeutralStream(NeutralToken[] tokens, int startIndex) + /// The neutral text to tokenize and stream. + public NeutralStream(NeutralText text) { - _tokens = tokens; - _currentIndex = startIndex; - _endIndex = _tokens.Length > 0 ? _tokens.Length - 1 : 0; + _enumerator = text.Tokenize().GetEnumerator(); } - /// - /// Gets an empty instance of the class. - /// This static property provides a neutral stream initialized with no tokens. - /// - public static NeutralStream Empty => new([]); - - /// - /// Gets the total number of tokens available in the current stream. - /// - public int Length => _tokens.Length; - /// /// Attempts to read the next token from the stream. /// @@ -73,14 +35,13 @@ public NeutralStream(NeutralToken[] tokens, int startIndex) /// public bool Read(out NeutralToken token) { - token = CurrentToken(); - - if (_currentIndex < _endIndex) + if (_enumerator.MoveNext()) { - _currentIndex++; + token = _enumerator.Current; return true; } + token = _enumerator.Current; return false; } @@ -89,42 +50,7 @@ public bool Read(out NeutralToken token) /// If the stream is at the end, it returns an end-of-file token. /// /// The token at the current position, or an end-of-file token if no more tokens are available. - public NeutralToken Peek() => CurrentToken(); - - /// - /// Retrieves the next token from the stream without advancing the current position. - /// If no more tokens are available, an EOF token is returned. - /// - /// The next in the stream, or an EOF token if the end of the stream is reached. - public NeutralToken PeekNext() => NextToken(); - - /// - /// Retrieves the last processed token in the sequence relative to the current position. - /// If no tokens have been processed, returns a default or ending token. - /// - /// The last processed or a default token if none exists. - public NeutralToken PeekLast() => LastToken(); - - /// - /// Checks if the current token matches the specified token type without consuming it. - /// - /// The token type to compare against the current token. - /// true if the current token's type matches the specified type; otherwise, false. - public bool Match(TokenType type) => CurrentToken().Type == type; - - /// - /// Determines whether the next token in the stream matches any of the specified token types. - /// - /// An array of token types to compare against the type of the next token. - /// Returns true if the next token matches one of the specified token types; otherwise, false. - public bool HasNext(params TokenType[] types) => types.Contains(NextToken().Type); - - /// - /// Determines if the last token matches any of the specified token types. - /// - /// The array of token types to match against the last token. - /// True if the last token matches one of the specified types, otherwise false. - public bool HasLast(params TokenType[] types) => types.Contains(LastToken().Type); + public NeutralToken Peek() => _enumerator.Current; /// /// Attempts to move the current position in the token stream by the specified number of tokens. @@ -136,120 +62,67 @@ public bool Read(out NeutralToken token) /// True if the operation successfully moved the position within the bounds of the token stream; /// false if the position was clamped to the start or end of the stream. /// - public bool Seek(int count = 1) => SeekToken(count); - - /// - /// Resets the stream's position to the beginning of the token sequence. - /// - /// - /// This operation enables reprocessing of tokens from the start of the stream. - /// - public NeutralStream SeekBegin() + public bool Advance(int count = 1) { - _currentIndex = 0; - return this; - } + if (count < 0) + throw new ArgumentException("Count cannot be negative. Use Reset() to move to the beginning of the stream.", + nameof(count)); - /// - /// Moves the current position to the last token in the stream. - /// - /// - /// This operation enables consumption of tokens from the end of the stream. - /// - public NeutralStream SeekEnd() - { - _currentIndex = _endIndex; - return this; - } + var index = 0; - /// - /// Advances the stream forward by seeking the next token that matches the specified condition. - /// - /// - /// A function to evaluate each token in the stream. - /// The stream advances until a token satisfying the predicate is found. - /// - /// - /// Returns true if a matching token is found; - /// otherwise, false if the end of the stream is reached without a match. - /// - public bool SeekForward(Func predicate) - { - while (SeekToken(1)) + while (index < count && _enumerator.MoveNext()) { - var token = CurrentToken(); - if (predicate(token)) return true; + index++; } - return false; + return index == count; } /// - /// Advances the stream backward by seeking the previous token that matches the specified condition. + /// Advances the stream forward by seeking the next token that matches the specified condition. /// /// /// A function to evaluate each token in the stream. - /// The stream advances backward until a token satisfying the predicate is found. + /// The stream advances until a token satisfying the predicate is found. /// /// /// Returns true if a matching token is found; - /// otherwise, false if the beginning of the stream is reached without a match. + /// otherwise, false if the end of the stream is reached without a match. /// - public bool SeekBack(Func predicate) + public bool Seek(Func predicate) { - while (SeekToken(-1)) + while (_enumerator.MoveNext()) { - var token = CurrentToken(); - if (predicate(token)) return true; + if (predicate(_enumerator.Current)) + return true; } return false; } /// - /// Moves the current position in the token stream by the specified number of tokens. + /// Resets the stream's position to the beginning of the token sequence. /// - /// - /// The number of tokens to move the position. Positive values move forward, and negative values move backward. - /// - /// - /// True if the position was successfully moved within the bounds of the token stream; - /// false if the position was clamped to the start or end of the stream. - /// - private bool SeekToken(int count) + /// + /// This operation enables reprocessing of tokens from the start of the stream. + /// + public void Reset() { - var position = _currentIndex + count; - - if (position < 0) - { - _currentIndex = 0; - return false; - } - - if (position >= _endIndex) - { - _currentIndex = _endIndex; - return false; - } - - _currentIndex = position; - return true; + _enumerator.Reset(); } /// - /// Retrieves the current token at the stream's current position. + /// Determines whether the current token matches the specified token type. /// - private NeutralToken CurrentToken() => _tokens[_currentIndex]; + /// The token type to match against the current token. + /// true if the current token matches the specified token type; otherwise, false. + public bool Match(TokenType type) => _enumerator.Current.Type == type; /// - /// Retrieves the next token in the stream without advancing the current position. - /// If the current position is at the end of the stream, returns . + /// Releases all resources used by the current instance of the class. /// - private NeutralToken NextToken() => _currentIndex + 1 <= _endIndex ? _tokens[_currentIndex + 1] : NeutralToken.None; - - /// - /// Retrieves the previous token from the token stream without advancing the current position. - /// If the beginning of the token stream is reached, returns . - /// - private NeutralToken LastToken() => _currentIndex - 1 >= 0 ? _tokens[_currentIndex - 1] : NeutralToken.None; + public void Dispose() + { + _enumerator.Dispose(); + } } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/NeutralTextExtensions.cs b/src/L5Sharp.Core/Common/NeutralTextExtensions.cs deleted file mode 100644 index cf528f9b..00000000 --- a/src/L5Sharp.Core/Common/NeutralTextExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; - -namespace L5Sharp.Core; - -/// -/// Provides extension methods for working with neutral text tokens and converting them -/// into streamable representations for processing and manipulation. -/// -public static class NeutralTextExtensions -{ - /// - /// Converts a collection of neutral tokens into a NeutralStream for sequential processing. - /// - /// The collection of instances to convert into a stream. - /// A that wraps the provided tokens for sequential access and manipulation. - public static NeutralStream ToStream(this IEnumerable tokens) - { - return new NeutralStream(tokens); - } - - /// - /// Converts an array of neutral tokens into a for sequential processing, - /// starting from the specified index within the token array. - /// - /// The array of instances to convert into a stream. - /// The starting index within the token array to begin the stream. Defaults to 0 if not provided. - /// A that wraps the specified tokens for sequential access and manipulation, starting at the given index. - public static NeutralStream ToStream(this NeutralToken[] tokens, int startIndex = 0) - { - return new NeutralStream(tokens, startIndex); - } -} \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index 1faf38d8..c87cbcb2 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -4,8 +4,6 @@ using System.Text; using System.Text.RegularExpressions; -// ReSharper disable ReplaceSubstringWithRangeIndexer we want to keep SubString for NET Standard - namespace L5Sharp.Core; /// @@ -23,23 +21,11 @@ public sealed class TagName : IComparable private const char ArrayClose = ']'; /// - /// The internal storage for the full tag path represented by the instance. - /// This string encapsulates the hierarchical representation of a Logix tag, including its members, - /// elements, and indices if applicable. Used for operations and methods related to tag analysis, - /// validation, and manipulation. - /// - private readonly string _path; - - /// - /// A regular expression pattern used for validating and analyzing Logix tag names. - /// This pattern ensures compliance with specific naming conventions, including formats for - /// base names, indexed elements, members, and operands within the hierarchical structure - /// of Logix tag definitions. + /// The underlying string representation of a Logix tag name encapsulated within the TagName class. + /// This private field is used to store and manage the full path or name of a tag, ensuring that + /// tag operations such as parsing, validation, and formatting are performed on a consistent data structure. /// - private static readonly Regex TagNamePattern = new( - @"(?!\w*\()[A-Za-z_][\w+:]{1,39}(?:(?:\[\d+\]|\[\d+,\d+\]|\[\d+,\d+,\d+\])?(?:\.[A-Za-z_]\w{1,39})?)+(?:\.[0-9][0-9]?)?", - RegexOptions.Compiled - ); + private readonly string _tagName; /// /// A regular expression pattern used to validate the base name of a Logix tag. @@ -85,7 +71,7 @@ public sealed class TagName : IComparable /// tagName is null. public TagName(string name) { - _path = name ?? throw new ArgumentNullException(nameof(name)); + _tagName = name ?? throw new ArgumentNullException(nameof(name)); } /// @@ -94,7 +80,7 @@ public TagName(string name) /// equality and value comparison methods. /// // ReSharper disable once ConvertToAutoPropertyWhenPossible - public string FullPath => _path; + public string FullPath => _tagName; /// /// Gets the local portion of the tag name, excluding any program scope prefix. @@ -105,16 +91,17 @@ public TagName(string name) /// tags, this property returns the same value as . /// For example, "Program:MyProgram.MyTag" would return "MyTag". /// - public string LocalPath => GetLocalTagName(_path); + public string LocalPath => GetLocalTagName(); /// /// Represents the hierarchical portion of the tag name that excludes the base tag name and is stripped of the leading separator. - /// This property returns the remaining portion of the tag name, starting from the first member or array notation, - /// if applicable, after the base tag. It is derived from the property by removing the leading separator. - /// Commonly used for accessing specific levels of a tag's hierarchy within complex or structured tag definitions. - /// Returns null if the tag has no member path. /// - public string? MemberPath => GetRelativePath(_path)?.TrimStart(Separator); + /// + /// This property returns the remaining portion of the tag name, starting from the first member or array notation, + /// if applicable, after the base tag. Commonly used for accessing specific levels of a tag's hierarchy + /// within complex or structured tag definitions. Returns null if the tag has no member path. + /// + public string? MemberPath => GetMemberPath(); /// /// Represents the portion of the tag name that follows the base tag name, containing all members, @@ -122,7 +109,7 @@ public TagName(string name) /// The relative path provides a hierarchical breakdown of the tag's structure beyond its base name. /// Returns null if the tag has no relative path. /// - public string? RelativePath => GetRelativePath(_path); + public string? RelativePath => GetRelativePath(); /// /// The base name of the tag represented by the instance. @@ -130,7 +117,7 @@ public TagName(string name) /// indices, or hierarchy information. It is derived from the full path and is used /// in scenarios where only the top-level tag name is required. /// - public string BaseName => GetBase(_path); + public string BaseName => GetBaseName(); /// /// Gets the immediate member name of the tag represented by the current instance. @@ -138,7 +125,7 @@ public TagName(string name) /// the final component following any hierarchical path or indexing. /// If the tag does not include any hierarchical or member path, the value will be null. /// - public string? MemberName => GetMember(_path); + public string? MemberName => GetMemberName(); /// /// A zero-based number representing the depth of the tag name. In other words, the number of members @@ -150,7 +137,7 @@ public TagName(string name) /// indices are also considered a member name. For example, 'MyTag[1].Value' has a depth of 2 since '[1]' and 'Value' /// are descendent member names of the root tag 'MyTag' member. /// - public int Depth => GetDepth(_path); + public int Depth => GetDepth(); /// /// Retrieves the scope level and container information of the tag name represented by this instance. @@ -160,17 +147,25 @@ public TagName(string name) /// the scope is identified as program-scoped, and the container is set to the program name. Otherwise, it is identified /// as controller-scoped with an empty container. /// - public Scope Scope => GetScope(_path); + public Scope Scope => GetScope(); /// /// Gets a value indicating whether the current value is empty. /// - public bool IsEmpty => _path.IsEmpty(); + public bool IsEmpty => _tagName.IsEmpty(); /// /// Gets a value indicating whether the current is a valid representation of a tag name. /// - public bool IsQualified => IsQualifiedTagName(_path); + public bool IsQualified => IsQualifiedTagName(); + + /// + /// Indicates whether the tag name is relative. + /// A tag name is considered relative if it begins with a separator or array-opening character, + /// suggesting it is not a fully qualified path but instead references a member or element relative + /// to a parent context. + /// + public bool IsRelative => _tagName.IndexOfAny([Separator, ArrayOpen]) == 0; /// /// Gets the static empty value. @@ -189,7 +184,7 @@ public TagName(string name) /// for analyzing tag structure and hierarchy. For example, "MyTag[1].Value.12" would return /// ["MyTag", "[1]", "Value", "12"]. /// - public IEnumerable Members() => GetMembers(_path); + public IEnumerable Members() => GetMembers(); /// /// Retrieves member components of the tag name path up to a specified depth. @@ -205,7 +200,7 @@ public TagName(string name) /// For instance, calling Members(2) on "MyTag[1].Value.12" would return ["MyTag", "[1]"]. /// The depth parameter allows for efficient filtering of tag descendants without processing the entire path. /// - public IEnumerable Members(int count) => GetMembers(_path, count); + public IEnumerable Members(int count) => GetMembers(count); /// /// Determines whether the current tag name is a direct member (child) of the specified parent tag name. @@ -285,7 +280,7 @@ public bool Contains(TagName tagName) if (tagName is null) throw new ArgumentNullException(nameof(tagName)); - return _path.IndexOf(tagName._path, StringComparison.OrdinalIgnoreCase) >= 0; + return _tagName.IndexOf(tagName._tagName, StringComparison.OrdinalIgnoreCase) >= 0; } /// @@ -300,14 +295,38 @@ public bool Contains(TagName tagName) /// the structural path intact. For example, renaming "OldTag.Member[1].Value" with the base name "NewTag" /// would result in "NewTag.Member[1].Value". /// - public TagName Rename(string baseName) => Combine(baseName, RelativePath); + public TagName Rename(string baseName) + { + return baseName.ToTagName().Append(MemberPath); + } + + /// + /// Appends a specified member to the current and returns a new updated instance. + /// + /// The member string to append. This can be null or empty, in which case the current is returned unchanged. + /// A new instance with the specified member appended to the current tag name. + /// + /// This method automatically inserts a dot (.) separator before the member name unless the member already begins + /// with a separator character (either '.' for member access or '[' for array indexing). For example, appending + /// "Value" to "MyTag" results in "MyTag.Value", while appending "[0]" results in "MyTag[0]" without an extra separator. + /// + public TagName Append(string? member) + { + if (member is null || member.IsEmpty()) + return new TagName(_tagName); + + if (member[0] is '[' or '.') + return new TagName(_tagName + member); + + return new TagName(_tagName + '.' + member); + } /// public int CompareTo(TagName? other) { return ReferenceEquals(this, other) ? 0 : ReferenceEquals(null, other) ? 1 - : StringComparer.OrdinalIgnoreCase.Compare(_path, other._path); + : StringComparer.OrdinalIgnoreCase.Compare(_tagName, other._tagName); } /// @@ -315,60 +334,24 @@ public override bool Equals(object? obj) { return obj switch { - TagName other => StringComparer.OrdinalIgnoreCase.Equals(_path, other._path), - string other => StringComparer.OrdinalIgnoreCase.Equals(_path, other), + TagName other => StringComparer.OrdinalIgnoreCase.Equals(_tagName, other._tagName), + string other => StringComparer.OrdinalIgnoreCase.Equals(_tagName, other), _ => false }; } /// - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(_path); + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(_tagName); /// - public override string ToString() => _path; - - /// - /// Extracts all instances from the specified text based on the predefined tag name pattern. - /// - /// - /// The text from which tag names are to be extracted. This is typically an expression or - /// rung of neutral text in which multiple tag names are embedded. - /// - /// A collection of objects representing the tags found within the input text. - public static IEnumerable ExtractAll(string text) - { - return TagNamePattern.Matches(text).Cast().Select(m => new TagName(m.Value)); - } + public override string ToString() => _tagName; /// /// Determines if the provided string value is a valid tag name. /// /// The to test. /// true if the value is a valid and qualified tag name; Otherwise, false. - public static bool IsTag(string value) => IsQualifiedTagName(value); - - /// - /// Concatenates two strings to produce a new value. This method will also insert the '.' - /// member separator character if not found at the beginning of right. - /// - /// The first or left side of the tag name to concatenate. - /// The second or right side of the tag name to concatenate. - /// A representing the combination of left and right. - /// - /// This method would be more performant than , assuming there are just - /// two strings to join together, as it does not iterate a collection or use a string builder class. - /// This method simply concatenates to strings. - /// - public static TagName Concat(string left, string? right) - { - if (right is null || right.IsEmpty()) - return left; - - if (right[0] == ArrayOpen || right[0] == Separator) - return new TagName(left + right); - - return new TagName(left + Separator + right); - } + public static bool IsTag(string? value) => value is not null && value.ToTagName().IsQualified; /// /// Combines a series of strings into a single value, inserting member separator @@ -387,6 +370,65 @@ public static TagName Concat(string left, string? right) /// If a provided name does not match the member pattern format. public static TagName Combine(IEnumerable members) => new(ConcatenateMembers(members)); + /// + /// Parses a object into a collection of objects based on its tokenized content. + /// + /// The object to parse into tag names. + /// A collection of objects extracted from the given . + /// Thrown when the provided is null. + public static IEnumerable Parse(NeutralText text) + { + if (text is null) + throw new ArgumentNullException(nameof(text)); + + using var stream = new NeutralStream(text); + var tagNames = new List(); + var tagCache = new Dictionary(); + var depth = 0; + + while (stream.Read(out var token)) + { + // This is the termination point. When we hit a non-tag token, we can add the constructed tag names and reset. + // Advance until we find the beginning of a potentially new tag name string. + if (!IsTagToken(token.Type)) + { + // We need to skip any identifier that precedes an open parenthesis because this + // represents an instruction or a function and not a tag name. + if (tagCache.Count > 0 && token.Type != TokenType.OpenParen) + tagNames.AddRange(tagCache.Values); + + tagCache.Clear(); + depth = 0; + continue; + } + + // Handle array bracket depth to track nested tag name. + // We also need to ignore any array brackets while not "inside" of a tag name, + // as these likely represent rung branch tokens. + if (tagCache.Count > 0 && token.Type == TokenType.OpenBracket) depth++; + if (tagCache.Count > 0 && token.Type == TokenType.CloseBracket) depth--; + + // Build up a tag name path for each level/depth in the stream. + for (var i = 0; i <= depth; i++) + { + if (tagCache.TryGetValue(i, out var current)) + { + tagCache[i] = current + token.Value; + continue; + } + + // Only add nested tags if the token is an identifier (nested tag name). + // We don't care about literal index or commas. + if (token.Type == TokenType.Identifier) + { + tagCache[i] = token.Value; + } + } + } + + return tagNames; + } + /// /// Determines if the provided objects are equal. /// @@ -408,58 +450,76 @@ public static TagName Concat(string left, string? right) /// /// The value to convert. /// A new value representing the value of the tag name. - public static implicit operator string(TagName tagName) => tagName._path; + public static implicit operator string(TagName tagName) => tagName._tagName; /// /// Converts a to a value. /// /// The value to convert. /// A new value representing the value of the tag name. - public static implicit operator TagName(string tagName) => new(tagName); + public static implicit operator TagName(string? tagName) => tagName is null ? Empty : new TagName(tagName); /// - /// Retrieves the base portion of a tag name from the specified path. - /// Start by extracting the localized tag name value. - /// Returns an empty string if the tag name is empty or starts with a separator. - /// Otherwise, returns the portion of the tag name up to the first separator. + /// Retrieves the relative path portion of a tag name by parsing the associated token stream. /// - private static string GetBase(string path) + /// + /// A string representing the relative path of the tag name, or null if no relative path is found. + /// + private string? GetRelativePath() { - var tagName = GetLocalTagName(path); + var tagName = GetLocalTagName(); + var separator = tagName.IndexOfAny([Separator, ArrayOpen]); + return separator >= 0 ? tagName.Substring(separator) : null; + } - if (tagName.IsEmpty() || tagName.StartsWith(Separator)) - return string.Empty; + /// + /// Retrieves the member path portion of a tag name, excluding the base name and any leading separator. + /// This represents the hierarchical structure after the base tag, including members, array indices, and elements. + /// + /// + /// A string representing the member path of the tag name without the leading separator, or null if no member path exists. + /// + private string? GetMemberPath() + { + var tagName = GetLocalTagName(); - var end = tagName.StartsWith(ArrayOpen) - ? tagName.IndexOf(ArrayClose) + 1 - : tagName.IndexOfAny([Separator, ArrayOpen]); + var separator = tagName.IndexOfAny([Separator, ArrayOpen]); + if (separator < 0) return null; - return end > 0 ? tagName.Substring(0, end) : tagName; + var startIndex = tagName[separator] is Separator ? separator + 1 : separator; + return tagName.Substring(startIndex); } /// - /// Retrieves the operand portion of a tag name from the provided path string. + /// Retrieves the base portion of a tag name from the specified path. + /// Start by extracting the localized tag name value. + /// Returns an empty string if the tag name is empty or starts with a separator. + /// Otherwise, returns the portion of the tag name up to the first separator. /// - private static string? GetRelativePath(string path) + private string GetBaseName() { - var tagName = GetLocalTagName(path); - var separator = tagName.IndexOfAny([Separator, ArrayOpen]); - return separator >= 0 ? tagName.Substring(separator) : null; + var tagName = GetLocalTagName(); + + if (tagName.IsEmpty() || tagName.StartsWith(Separator) || tagName.StartsWith(ArrayOpen)) + return string.Empty; + + var end = tagName.IndexOfAny([Separator, ArrayOpen]); + return end > 0 ? tagName.Substring(0, end) : tagName; } /// /// Gets the last member of the tag name path, or the portion of the string from the last member separator to the /// end of the string. We are calling this the element. /// - private static string? GetMember(string path) + private string? GetMemberName() { - var tagName = GetLocalTagName(path); + var tagName = GetLocalTagName(); var lastSeparator = tagName.LastIndexOfAny([Separator, ArrayOpen]); if (lastSeparator < 0) return null; - var length = tagName.Length - lastSeparator; - return tagName.Substring(lastSeparator, length).TrimStart(Separator); + var startIndex = tagName[lastSeparator] is Separator ? lastSeparator + 1 : lastSeparator; + return tagName.Substring(startIndex); } /// @@ -467,10 +527,9 @@ private static string GetBase(string path) /// We are no longer using regex to make this as efficient as possible since there could realistically be millions /// of tag names this can get called on. /// - private static IEnumerable GetMembers(string path, int count = 0) + private IEnumerable GetMembers(int count = 0) { - // Only parse the local tag name string. - var tagName = GetLocalTagName(path); + var tagName = GetLocalTagName(); var start = 0; var depth = 0; @@ -500,13 +559,12 @@ private static IEnumerable GetMembers(string path, int count = 0) } /// - /// Gets the zero-based depth or number of members between this member and the root. - /// We are no longer using regex to make this as efficient as possible since there could realistically be millions - /// of tag names this can get called on. + /// Calculates the depth of the tag hierarchy represented by this instance. /// - private static int GetDepth(string path) + /// The number of hierarchical levels (or depth) in the tag name. + private int GetDepth() { - var tagName = GetLocalTagName(path); + var tagName = GetLocalTagName(); if (tagName.IsEmpty()) return 0; @@ -520,14 +578,14 @@ private static int GetDepth(string path) /// object to identify the scope of the tag name. If no program prefix is present, we always assume /// a controller-scoped tag name. /// - private static Scope GetScope(string path) + private Scope GetScope() { - if (!path.StartsWith(ProgramPrefix, StringComparison.OrdinalIgnoreCase)) + if (!_tagName.StartsWith(ProgramPrefix, StringComparison.OrdinalIgnoreCase)) return Scope.Controller; - var memberIndex = path.IndexOf(Separator); - var endIndex = memberIndex > 0 ? memberIndex : path.Length; - var programName = path.Substring(ProgramPrefix.Length, endIndex - ProgramPrefix.Length); + var memberIndex = _tagName.IndexOf(Separator); + var endIndex = memberIndex > 0 ? memberIndex : _tagName.Length; + var programName = _tagName.Substring(ProgramPrefix.Length, endIndex - ProgramPrefix.Length); return Scope.Program(programName); } @@ -535,17 +593,17 @@ private static Scope GetScope(string path) /// Gets the portion of the tag name without the leading program prefix if present. This is needed to analyze /// the remaining portion of the actual localized tag name value. /// - private static string GetLocalTagName(string path) + private string GetLocalTagName() { - if (!path.StartsWith(ProgramPrefix, StringComparison.OrdinalIgnoreCase)) - return path; + if (!_tagName.StartsWith(ProgramPrefix, StringComparison.OrdinalIgnoreCase)) + return _tagName; - var memberIndex = path.IndexOf(Separator); + var memberIndex = _tagName.IndexOf(Separator); - if (memberIndex == -1 || memberIndex == path.Length - 1) + if (memberIndex == -1 || memberIndex == _tagName.Length - 1) return string.Empty; - return path.Substring(memberIndex + 1); + return _tagName.Substring(memberIndex + 1); } /// @@ -562,8 +620,8 @@ private static string ConcatenateMembers(IEnumerable members) { if (member is null) continue; - if (!(member.StartsWith(ArrayOpen) || member.StartsWith(Separator)) && builder.Length > 1) - builder.Append(Separator); + if (!(member.StartsWith('[') || member.StartsWith('.')) && builder.Length > 1) + builder.Append('.'); builder.Append(member); } @@ -571,16 +629,11 @@ private static string ConcatenateMembers(IEnumerable members) return builder.ToString(); } - /// - /// Determines whether the specified string value represents a valid qualified tag name. - /// - /// The string value to validate as a qualified tag name. - /// True if the value is a qualified tag name; otherwise, false. - private static bool IsQualifiedTagName(string value) - { - if (value.IsEmpty()) return false; - var members = GetMembers(value).ToArray(); + private bool IsQualifiedTagName() + { + if (IsEmpty) return false; + var members = GetMembers().ToArray(); if (members.Length == 0) return false; for (var i = 0; i < members.Length; i++) @@ -613,6 +666,25 @@ bool IsValidIndex(string member) => bool IsValidBitNumber(string member) => int.TryParse(member, out var bit) && bit is >= 0 and <= 63; } + + /// + /// Determines whether the specified token type represents a tag token. + /// + /// The to evaluate. + /// + /// true if the token type is one of the defined tag-related tokens such as Identifier, Dot, OpenBracket, CloseBracket, + /// Comma, or Colon; otherwise, false. + /// + private static bool IsTagToken(TokenType type) + { + return type == TokenType.Identifier + || type == TokenType.Literal + || type == TokenType.Dot + || type == TokenType.OpenBracket + || type == TokenType.CloseBracket + || type == TokenType.Comma + || type == TokenType.Colon; + } } /// diff --git a/src/L5Sharp.Core/Components/Tag.cs b/src/L5Sharp.Core/Components/Tag.cs index 68549213..8964b382 100644 --- a/src/L5Sharp.Core/Components/Tag.cs +++ b/src/L5Sharp.Core/Components/Tag.cs @@ -743,7 +743,7 @@ public static ITagBuilder Named(TagName tagName) private TagName GetTagName() { if (Parent is not null) - return TagName.Concat(Parent.TagName, Name); + return Parent.TagName.Append(Name); if (Scope.IsProgram) return new TagName($"Program:{Scope.Container}.{Name}"); diff --git a/src/L5Sharp.Core/Enums/TokenType.cs b/src/L5Sharp.Core/Enums/TokenType.cs index 81cfe867..31ff9919 100644 --- a/src/L5Sharp.Core/Enums/TokenType.cs +++ b/src/L5Sharp.Core/Enums/TokenType.cs @@ -8,7 +8,7 @@ namespace L5Sharp.Core; /// Represents the type of token identified during lexical analysis of Logix neutral text. /// Used by the internal lexer to parse Logix code structures such as instructions, tag names, and expressions. /// -public class TokenType : LogixEnum +public class TokenType : LogixEnum { /// /// Represents a collection of token types corresponding to various operators @@ -18,70 +18,88 @@ public class TokenType : LogixEnum private static readonly HashSet Operators = new(Core.Operator.All().Select(x => x.Value), StringComparer.OrdinalIgnoreCase); - private TokenType(string name, int value) : base(name, value) + private TokenType(string name, string value) : base(name, value) { } + /// + /// Represents a token type that indicates the absence of a token or a null state. + /// This token type can be used as a default or uninitialized value in scenarios + /// where no specific token type is applicable or has been defined. + /// + public static readonly TokenType None = new(nameof(None), nameof(None)); + /// /// Represents an undefined or unrecognized token type encountered during lexical analysis of Logix neutral text. /// Typically used as a placeholder when a token does not match any predefined token types. /// - public static readonly TokenType Unknown = new(nameof(Unknown), 0); + public static readonly TokenType Unknown = new(nameof(Unknown), nameof(Unknown)); /// /// Represents an identifier token such as instruction names (XIC, ADD), tag names (MyTag), or AOI names (My_AOI). /// - public static readonly TokenType Identifier = new(nameof(Identifier), 1); + public static readonly TokenType Identifier = new(nameof(Identifier), nameof(Identifier)); /// /// Represents a literal value token such as numeric literals (100, 16#FF) or string literals ('String'). /// - public static readonly TokenType Literal = new(nameof(Literal), 2); + public static readonly TokenType Literal = new(nameof(Literal), nameof(Literal)); /// /// Represents an operator token such as arithmetic (+, -, *, /), assignment (:=), or logical (AND, OR) operators. /// - public static readonly TokenType Operator = new(nameof(Operator), 3); + public static readonly TokenType Operator = new(nameof(Operator), nameof(Operator)); /// /// Represents an opening parenthesis token '(' used for instruction arguments and expression grouping. /// - public static readonly TokenType OpenParen = new(nameof(OpenParen), 4); + public static readonly TokenType OpenParen = new(nameof(OpenParen), nameof(OpenParen)); /// /// Represents a closing parenthesis token ')' used for instruction arguments and expression grouping. /// - public static readonly TokenType CloseParen = new(nameof(CloseParen), 5); + public static readonly TokenType CloseParen = new(nameof(CloseParen), nameof(CloseParen)); /// /// Represents an opening bracket token '[' used for array indexing and branch logic in rungs. /// - public static readonly TokenType OpenBracket = new(nameof(OpenBracket), 6); + public static readonly TokenType OpenBracket = new(nameof(OpenBracket), nameof(OpenBracket)); /// /// Represents a closing bracket token ']' used for array indexing and branch logic in rungs. /// - public static readonly TokenType CloseBracket = new(nameof(CloseBracket), 7); + public static readonly TokenType CloseBracket = new(nameof(CloseBracket), nameof(CloseBracket)); /// /// Represents a comma token ',' used to separate instruction arguments or array dimensions. /// - public static readonly TokenType Comma = new(nameof(Comma), 8); + public static readonly TokenType Comma = new(nameof(Comma), nameof(Comma)); /// /// Represents a dot token '.' used for member access in tag names and data structures. /// - public static readonly TokenType Dot = new(nameof(Dot), 9); + public static readonly TokenType Dot = new(nameof(Dot), nameof(Dot)); + + /// + /// Represents a token type corresponding to a colon (":") character in the Logix neutral text. + /// + public static readonly TokenType Colon = new(nameof(Colon), nameof(Colon)); /// /// Represents a semicolon token ';' used to terminate instructions or rungs. /// - public static readonly TokenType SemiColon = new(nameof(SemiColon), 10); + public static readonly TokenType SemiColon = new(nameof(SemiColon), nameof(SemiColon)); + + /// + /// Represents a token type that corresponds to a question mark symbol ('?'). This symbol is found in some + /// instruction text like timers or counters for unspecified arguments. + /// + public static readonly TokenType QuestionMark = new(nameof(QuestionMark), nameof(QuestionMark)); /// /// Represents the end-of-file token indicating the completion of input text parsing. /// - public static readonly TokenType EOF = new(nameof(EOF), 11); + public static readonly TokenType EOF = new(nameof(EOF), nameof(EOF)); /// /// Determines the based on the given token string. @@ -109,12 +127,14 @@ public static TokenType FromToken(string token) ']' when token.Length == 1 => CloseBracket, ',' when token.Length == 1 => Comma, '.' when token.Length == 1 => Dot, + ':' when token.Length == 1 => Colon, ';' when token.Length == 1 => SemiColon, + '?' when token.Length == 1 => QuestionMark, // Verify balanced quotes for string literals '\'' when token.Length >= 2 && token[token.Length - 1] == '\'' => Literal, - // Literals start with a digit (100, 16#FF, 2#1011, etc.) + // Literals start with a digit (100, 0.123, 16#FF, 2#1011, etc.) _ when char.IsDigit(token[0]) => Literal, // Identifiers start with a letter or underscore diff --git a/src/L5Sharp.Core/Utilities/ElementExtensions.cs b/src/L5Sharp.Core/Utilities/ElementExtensions.cs index d24f1fa0..548ad1be 100644 --- a/src/L5Sharp.Core/Utilities/ElementExtensions.cs +++ b/src/L5Sharp.Core/Utilities/ElementExtensions.cs @@ -143,7 +143,7 @@ public static TagName GetTagNamePath(this XElement element) var memberName = current.MemberName(); if (!memberName.IsEmpty()) - tagName = TagName.Concat(memberName, tagName); + tagName = memberName.ToTagName().Append(tagName); if (current.Name.LocalName is L5XName.Tag) break; current = current.Parent; diff --git a/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs b/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs index fe67980f..70a4fcbd 100644 --- a/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/NeutralTextTests.cs @@ -13,6 +13,15 @@ public void Constructor_NullText_ShouldThrowArgumentNullException() act.Should().Throw(); } + [Test] + public void Constructor_ValidText_ShouldHaveExpectedValue() + { + var text = new NeutralText("Test"); + + text.Should().NotBeNull(); + text.ToString().Should().Be("Test"); + } + [Test] public void Tokenize_SimpleIdentifier_ShouldReturnIdentifierAndEOF() { @@ -25,9 +34,131 @@ public void Tokenize_SimpleIdentifier_ShouldReturnIdentifierAndEOF() tokens[0].Value.Should().Be("MyTag"); tokens[0].Index.Should().Be(0); tokens[1].Type.Should().Be(TokenType.EOF); + tokens[1].Value.Should().BeEmpty(); tokens[1].Index.Should().Be(5); } + [Test] + public void Tokenize_Colon_ShouldReturnColonAndEOF() + { + var text = new NeutralText(":"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Colon); + tokens[0].Value.Should().Be(":"); + tokens[1].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_IOTagName_ShouldReturnExpectedTokens() + { + var text = new NeutralText("Local:1:I.Data[0].0"); + + var tokens = text.Tokenize().ToList(); + + // Local, :, 1, :, I, ., Data, [, 0, ], ., 0, EOF + tokens.Should().HaveCount(13); + tokens[0].Value.Should().Be("Local"); + tokens[1].Type.Should().Be(TokenType.Colon); + tokens[2].Value.Should().Be("1"); + tokens[3].Type.Should().Be(TokenType.Colon); + tokens[4].Value.Should().Be("I"); + tokens[5].Type.Should().Be(TokenType.Dot); + tokens[6].Value.Should().Be("Data"); + tokens[7].Type.Should().Be(TokenType.OpenBracket); + tokens[8].Value.Should().Be("0"); + tokens[9].Type.Should().Be(TokenType.CloseBracket); + tokens[10].Type.Should().Be(TokenType.Dot); + tokens[11].Value.Should().Be("0"); + tokens[12].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_ProgramScopedTagName_ShouldReturnExpectedTokens() + { + var text = new NeutralText("Program:MainProgram.MyTag"); + + var tokens = text.Tokenize().ToList(); + + // Program, :, MainProgram, ., MyTag, EOF + tokens.Should().HaveCount(6); + tokens[0].Value.Should().Be("Program"); + tokens[1].Type.Should().Be(TokenType.Colon); + tokens[2].Value.Should().Be("MainProgram"); + tokens[3].Type.Should().Be(TokenType.Dot); + tokens[4].Value.Should().Be("MyTag"); + } + + [Test] + public void Tokenize_Assignment_ShouldReturnOperatorAndEOF() + { + var text = new NeutralText("MyTag := 10;"); + + var tokens = text.Tokenize().ToList(); + + // MyTag, :=, 10, ;, EOF + tokens.Should().HaveCount(5); + tokens[0].Value.Should().Be("MyTag"); + tokens[1].Type.Should().Be(TokenType.Operator); + tokens[1].Value.Should().Be(":="); + tokens[2].Value.Should().Be("10"); + tokens[3].Type.Should().Be(TokenType.SemiColon); + } + + [Test] + public void Tokenize_ConsecutiveColons_ShouldReturnMultipleColons() + { + var text = new NeutralText(":::"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(4); + tokens[0].Type.Should().Be(TokenType.Colon); + tokens[1].Type.Should().Be(TokenType.Colon); + tokens[2].Type.Should().Be(TokenType.Colon); + tokens[3].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_ColonNextToOtherOperators_ShouldReturnCorrectTokens() + { + var text = new NeutralText(":+:-:"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(6); + tokens[0].Type.Should().Be(TokenType.Colon); + tokens[1].Type.Should().Be(TokenType.Operator); + tokens[2].Type.Should().Be(TokenType.Colon); + tokens[3].Type.Should().Be(TokenType.Operator); + tokens[4].Type.Should().Be(TokenType.Colon); + tokens[5].Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Tokenize_ComplexTagName_ShouldReturnExpectedTokens() + { + var text = new NeutralText("MyTag.SomeMember[1,2,3].Value.14"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(15); + } + + [Test] + public void Tokenize_ComplexTagNameWithReferenceIndex_ShouldReturnExpectedTokens() + { + var text = new NeutralText("MyTag.SomeMember[IndexTag].Value.14"); + + var tokens = text.Tokenize().ToList(); + + tokens.Should().HaveCount(11); + tokens[4].Type.Should().Be(TokenType.Identifier); + tokens[4].Value.Should().Be("IndexTag"); + } + [Test] public void Tokenize_Literal_ShouldReturnLiteralAndEOF() { @@ -115,7 +246,7 @@ public void Tokenize_MixedText_ShouldReturnExpectedTokens() // XIC, (, MyTag, ), ADD, (, 1, ,, 2, ,, Result, ), ;, EOF tokens.Should().HaveCount(14); - + tokens[0].Value.Should().Be("XIC"); tokens[1].Value.Should().Be("("); tokens[2].Value.Should().Be("MyTag"); diff --git a/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs b/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs index 398b14a1..f818e1ee 100644 --- a/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/NeutralTokenTests.cs @@ -1,5 +1,4 @@ using FluentAssertions; -using L5Sharp.Core; namespace L5Sharp.Tests.Core.Common; @@ -19,7 +18,7 @@ public void Constructor_ValidArguments_ShouldSetProperties() [Test] public void Constructor_NullType_ShouldThrowArgumentNullException() { - Action act = () => new NeutralToken(null!, "value", 0); + Action act = () => _ = new NeutralToken(null!, "value", 0); act.Should().Throw().WithParameterName("type"); } @@ -27,7 +26,7 @@ public void Constructor_NullType_ShouldThrowArgumentNullException() [Test] public void Constructor_NullValue_ShouldThrowArgumentNullException() { - Action act = () => new NeutralToken(TokenType.Identifier, null!, 0); + Action act = () => _ = new NeutralToken(TokenType.Identifier, null!, 0); act.Should().Throw().WithParameterName("value"); } @@ -51,22 +50,12 @@ public void ToString_WhenCalled_ShouldReturnExpectedFormat() } [Test] - public void EOF_DefaultIndex_ShouldHaveExpectedProperties() + public void None_WhenCalled_ShouldHaveExpectedProperties() { - var token = NeutralToken.EOF(); + var token = NeutralToken.None; - token.Type.Should().Be(TokenType.EOF); + token.Type.Should().Be(TokenType.None); token.Value.Should().BeEmpty(); token.Index.Should().Be(-1); } - - [Test] - public void EOF_CustomIndex_ShouldHaveExpectedIndex() - { - var token = NeutralToken.EOF(100); - - token.Type.Should().Be(TokenType.EOF); - token.Value.Should().BeEmpty(); - token.Index.Should().Be(100); - } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index 239961e5..d24c50f7 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -120,8 +120,10 @@ public void Scope_NoProgramPrefix_ShouldBeControllerAndEmptyContainer() { var tagName = new TagName("MyTag"); - tagName.Scope.Level.Should().Be(ScopeLevel.Controller); - tagName.Scope.Container.Should().BeEmpty(); + var scope = tagName.Scope; + + scope.Level.Should().Be(ScopeLevel.Controller); + scope.Container.Should().BeEmpty(); } [Test] @@ -167,8 +169,9 @@ public void Scope_WithProgramPrefixAndMemberTag_ShouldHaveExpectedProperties() [TestCase("MyTag[1].SomeMember.1", "MyTag")] [TestCase("Module:1:I.Data[1].SubTag.Value.12", "Module:1:I")] [TestCase(".Member[32].Value", "")] - [TestCase("Member[32].Value", "Member")] - [TestCase("[32].Value", "[32]")] + [TestCase("[32].Value", "")] + [TestCase("Program:MyProgram.MyTag[32].Value", "MyTag")] + [TestCase("Program:MyProgram.[32].Value", "")] public void BaseName_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); @@ -452,6 +455,100 @@ public void ImplicitOperator_ValidName_ShouldBeExpected() tagName.Should().Be("MyTagName"); } + [Test] + public void Parse_SimpleBaseTagName_ShouldReturnSingleBaseTag() + { + NeutralText text = "MyTag"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(1); + tags[0].Should().Be("MyTag"); + } + + [Test] + public void Parse_ComplexTagNamePath_ShouldReturnSingleTagName() + { + NeutralText text = "MyTag.SubMember[1].Value.32"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(1); + tags[0].Should().Be("MyTag.SubMember[1].Value.32"); + } + + [Test] + public void Parse_ComplexTagNameWithNestedIndexReference_ShouldReturnBothTagNames() + { + NeutralText text = "MyTag.SubMember[IndexTag].Value.32"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(2); + tags[0].Should().Be("MyTag.SubMember[IndexTag].Value.32"); + tags[1].Should().Be("IndexTag"); + } + + [Test] + public void Parse_TagNameWithProgramSpecifier_ShouldReturnFullPath() + { + NeutralText text = "Program:MyProgramName.MyTag.Member.Value"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(1); + tags[0].Should().Be("Program:MyProgramName.MyTag.Member.Value"); + } + + [Test] + public void Parse_MultipleTagsInInstructionText_ShouldReturnOnlyTagNames() + { + NeutralText text = "XIC(MySimpleTag) XIO(MyArrayTag[1]) OTE(MyComplexTag.Member.12);"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(3); + tags[0].Should().Be("MySimpleTag"); + tags[1].Should().Be("MyArrayTag[1]"); + tags[2].Should().Be("MyComplexTag.Member.12"); + } + + [Test] + public void Parse_MultipleTagsInInstructionTextWithNestedIndexName_ShouldReturnExpectedTagNames() + { + NeutralText text = "XIC(MySimpleTag) XIO(MyArrayTag[IndexTagName]) OTE(MyComplexTag.Member.12);"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(4); + tags[0].Should().Be("MySimpleTag"); + tags[1].Should().Be("MyArrayTag[IndexTagName]"); + tags[2].Should().Be("IndexTagName"); + tags[3].Should().Be("MyComplexTag.Member.12"); + } + + [Test] + public void Parse_MultipleTagsInRungTextWithBranchTokens_ShouldReturnExpectedTagNames() + { + NeutralText text = "[XIC(MySimpleTag) XIO(MyArrayTag[IndexTagName])] OTE(MyComplexTag.Member.12);"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(4); + tags[0].Should().Be("MySimpleTag"); + tags[1].Should().Be("MyArrayTag[IndexTagName]"); + tags[2].Should().Be("IndexTagName"); + tags[3].Should().Be("MyComplexTag.Member.12"); + } + + [Test] + public void ImplicitOperator_Null_ShouldBeEmpty() + { + TagName tagName = (string)null!; + + tagName.Should().Be(TagName.Empty); + } + [Test] public void ImplicitOperator_ScanRate_ShouldBeExpected() { From ae7e68371ce547a3dc5d19ebf4eb72b571f1dde3 Mon Sep 17 00:00:00 2001 From: tnunnink Date: Sat, 23 May 2026 20:51:31 -0500 Subject: [PATCH 12/13] Refactored `Argument` and `Instruction` by removing legacy parsing logic and unused methods, simplifying the implementation. Improved `Instruction` tokenization and processing with enhanced `NeutralStream` integration. Added comprehensive tests for `NeutralStream` and updated test cases around `Instruction` and `TagName`. --- src/L5Sharp.Core/Code/Block.cs | 8 +- src/L5Sharp.Core/Code/Line.cs | 6 +- src/L5Sharp.Core/Code/Rung.cs | 7 +- src/L5Sharp.Core/Code/Sheet.cs | 7 +- src/L5Sharp.Core/Common/Argument.cs | 76 ++------- src/L5Sharp.Core/Common/Instruction.cs | 148 ++++++------------ src/L5Sharp.Core/Common/NeutralStream.cs | 41 ++--- src/L5Sharp.Core/Common/Reference.cs | 3 +- src/L5Sharp.Core/Common/TagName.cs | 32 +++- tests/L5Sharp.Tests.Core/Code/RungTests.cs | 2 +- .../Common/ArgumentTests.cs | 66 +++----- .../Common/InstructionTests.cs | 101 ++++++------ .../Common/NeutralStreamTests.cs | 136 ++++++++++++++++ .../L5Sharp.Tests.Core/Common/TagNameTests.cs | 18 ++- tests/L5Sharp.Tests.Core/Examples.cs | 2 +- 15 files changed, 352 insertions(+), 301 deletions(-) create mode 100644 tests/L5Sharp.Tests.Core/Common/NeutralStreamTests.cs diff --git a/src/L5Sharp.Core/Code/Block.cs b/src/L5Sharp.Core/Code/Block.cs index d7cd7b29..3d6e54b7 100644 --- a/src/L5Sharp.Core/Code/Block.cs +++ b/src/L5Sharp.Core/Code/Block.cs @@ -210,7 +210,7 @@ public Block WireTo(TagName target, TagName? from = null) throw new ArgumentException("Can not wire block with null or empty target tag name."); var operand = target.BaseName; - var pin = target.MemberPath; + var pin = target.MemberPath ?? TagName.Empty; var to = Element.Parent?.Elements().FirstOrDefault(e => e.GetBlockOperand() == operand @@ -257,7 +257,7 @@ public Block WireFrom(TagName source, TagName? to = null) throw new ArgumentException("Can not wire block with null or empty target tag name."); var operand = source.BaseName; - var pin = source.MemberPath; + var pin = source.MemberPath ?? TagName.Empty; var from = Element.Parent?.Elements().FirstOrDefault(e => e.GetBlockOperand() == operand @@ -377,7 +377,7 @@ public Instruction ToInstruction() builder.Append(")"); - return Instruction.Parse(builder.ToString()); + return new Instruction(builder.ToString()); } /// @@ -469,7 +469,7 @@ private static IEnumerable GetBlockTags(XElement element) { var operand = element.GetBlockOperand(); - if (operand.IsInvalid) return []; + if (operand.IsValid) return []; return element.Attributes() .Where(a => PinNames.Contains(a.Name.LocalName)) diff --git a/src/L5Sharp.Core/Code/Line.cs b/src/L5Sharp.Core/Code/Line.cs index dd294778..1099434e 100644 --- a/src/L5Sharp.Core/Code/Line.cs +++ b/src/L5Sharp.Core/Code/Line.cs @@ -39,13 +39,15 @@ public Line(string text) : base(L5XName.Line) /// public override IEnumerable Instructions() { - return Instruction.Split(Element.Value); + return Instruction.Parse(Element.Value); } /// public override IEnumerable Tags() { - return Instruction.Split(Element.Value).SelectMany(x => x.Tags); + return Instruction.Parse(Element.Value).SelectMany(x => + x.Arguments.Where(a => a.IsReference).Select(a => a.ToTagName()) + ); } /// diff --git a/src/L5Sharp.Core/Code/Rung.cs b/src/L5Sharp.Core/Code/Rung.cs index 81a7d728..010fb1f7 100644 --- a/src/L5Sharp.Core/Code/Rung.cs +++ b/src/L5Sharp.Core/Code/Rung.cs @@ -81,13 +81,16 @@ public string? Comment /// public override IEnumerable Instructions() { - return Instruction.Split(Text); + return Instruction.Parse(Text); } /// public override IEnumerable Tags() { - return Instruction.Split(Text).SelectMany(x => x.Tags); + + return Instruction.Parse(Text).SelectMany(x => + x.Arguments.Where(a => a.IsReference).Select(a => a.ToTagName()) + ); } /// diff --git a/src/L5Sharp.Core/Code/Sheet.cs b/src/L5Sharp.Core/Code/Sheet.cs index b1337371..af06af94 100644 --- a/src/L5Sharp.Core/Code/Sheet.cs +++ b/src/L5Sharp.Core/Code/Sheet.cs @@ -87,7 +87,9 @@ public override IEnumerable Instructions() /// public override IEnumerable Tags() { - return Blocks().Select(b => b.ToInstruction()).SelectMany(i => i.Tags); + return Blocks().Select(b => b.ToInstruction()).SelectMany(x => + x.Arguments.Where(a => a.IsReference).Select(a => a.ToTagName()) + ); } /// @@ -291,7 +293,8 @@ public Sheet Connect(TagName from, TagName to) if (from is null) throw new ArgumentNullException(nameof(from)); if (to is null) throw new ArgumentNullException(nameof(to)); - var source = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == from.BaseName)?.Deserialize(); + var source = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == from.BaseName) + ?.Deserialize(); var target = Element.Elements().SingleOrDefault(e => e.GetBlockOperand() == to.BaseName)?.Deserialize(); if (source is null) diff --git a/src/L5Sharp.Core/Common/Argument.cs b/src/L5Sharp.Core/Common/Argument.cs index 2fe982bf..27357855 100644 --- a/src/L5Sharp.Core/Common/Argument.cs +++ b/src/L5Sharp.Core/Common/Argument.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Text.RegularExpressions; namespace L5Sharp.Core; @@ -13,12 +10,6 @@ namespace L5Sharp.Core; /// public class Argument { - /// - /// A cached array of all known Logix operator symbols used for splitting and parsing expression arguments. - /// - private static readonly string[] Operators = - Operator.All().Select(x => x.Value).OrderByDescending(x => x.Length).ToArray(); - /// /// The value typically found in Studio for undefined argument values in certain instructions. /// @@ -51,7 +42,7 @@ public Argument(string value) /// /// Gets a value indicating whether this argument is invalid (either empty or unknown). /// - public bool IsInvalid => Type == ArgumentType.Empty || Type == ArgumentType.Unknown; + public bool IsValid => Type != ArgumentType.Empty && Type != ArgumentType.Unknown; /// /// Gets a value indicating whether this argument represents an immediate value (atomic or string). @@ -78,18 +69,6 @@ public Argument(string value) /// public bool IsExpression => Type == ArgumentType.Expression; - /// - /// Retrieves a read-only collection of arguments derived from the current argument string value. - /// Useful for parsing and analyzing composite argument structures within expressions. - /// - /// - /// If the argument type is , this property returns a collection of - /// individual component arguments extracted by splitting the expression on known Logix operators. - /// If the argument is not an expression (e.g., a tag name, atomic value, or string literal), - /// this property returns a single-item collection containing the argument itself. - /// - public IReadOnlyList Arguments => ExtractArguments(); - /// /// Represents an unknown argument that can be found in certain instruction text. /// @@ -109,41 +88,23 @@ public Argument(string value) /// /// A representing the tag reference in this argument. /// Thrown when the argument type is not . - public TagName ToTagName() - { - if (Type != ArgumentType.Reference) - throw new InvalidOperationException( - $"Cannot convert argument '{_value}' to TagName. The argument type is {Type}, but expected {ArgumentType.Reference}."); - - return new TagName(_value); - } + public TagName ToTagName() => new(_value); /// /// Converts this argument to an instance by parsing its immediate atomic value. /// /// An representing the parsed atomic value from this argument. /// Thrown when the argument type is not . - public AtomicData ToAtomic() - { - if (Type != ArgumentType.Atomic) - throw new InvalidOperationException( - $"Cannot convert argument '{_value}' to AtomicData. The argument type is {Type}, but expected {ArgumentType.Atomic}."); - - return AtomicData.Parse(_value); - } + public AtomicData ToAtomic() => AtomicData.Parse(_value); - #region Equality + /// + /// Converts the current value to a representation. + /// + /// A instance containing the converted value of the current . + public NeutralText ToNeutralText() => new(_value); /// - public override bool Equals(object? obj) - { - return obj switch - { - Argument other => _value.Equals(other._value), - string value => _value.Equals(value), - _ => false - }; - } + public override bool Equals(object? obj) => _value.Equals(obj?.ToString()); /// public override int GetHashCode() => _value.GetHashCode(); @@ -167,10 +128,6 @@ public override bool Equals(object? obj) /// true if the left Argument is not equal to the right Argument; otherwise, false. public static bool operator !=(Argument left, Argument right) => Equals(left, right); - #endregion - - #region Operators - /// /// Implicitly converts the provided to an . /// @@ -268,19 +225,4 @@ public override bool Equals(object? obj) /// The object to convert. /// A object representing the value of the argument. public static implicit operator string(Argument argument) => argument._value; - - #endregion - - /// - /// Extracts individual component arguments from an expression by splitting on known Logix operators. - /// - /// An array of objects representing each component of the expression, - /// or an empty array if not an expression. - private Argument[] ExtractArguments() - { - // If this is a reference or literal, then just return itself. - if (!IsExpression) return [this]; - - throw new NotImplementedException(); - } } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/Instruction.cs b/src/L5Sharp.Core/Common/Instruction.cs index d833835e..bcae6f5f 100644 --- a/src/L5Sharp.Core/Common/Instruction.cs +++ b/src/L5Sharp.Core/Common/Instruction.cs @@ -37,13 +37,6 @@ public sealed class Instruction /// private const char Close = ')'; - /// - /// Pattern for identifying any instruction and the contents of its signature. This expression should - /// capture everything enclosed or between the instruction parentheses. This includes nested parenthesis. - /// This works on the assumption that the text has balanced opening/closing parentheses. - /// - private const string Pattern = @"[A-Za-z_]\w{1,39}\((?>\((?)|[^()]+|\)(?<-c>))*(?(c)(?!))\)"; - /// /// A regex pattern that finds all commas not contained in array brackets so that we can split the arguments /// of an instruction signature into separate parsable values. @@ -96,15 +89,11 @@ public Instruction(string key, params Argument[] arguments) /// Creates a new from the provided neutral text value. /// /// The neutral text that represents the instruction value. - private Instruction(string text) + public Instruction(string text) { if (string.IsNullOrEmpty(text)) throw new ArgumentException("Instruction text can not be null or empty.", nameof(text)); - //Open parenthesis must not be the first character and string must end with close parenthesis. - if (text != "UNK" && (text.IndexOf(Open) < 1 || !text.EndsWith(Close))) - throw new ArgumentException($"Instruction text '{text}' is not a valid neutral text instruction."); - _text = text; } @@ -124,19 +113,12 @@ private Instruction(string text) /// A collection of value objects. /// These could represent literal values, tag names, or nested expressions. /// - public Argument[] Arguments => ParseArguments(); - - /// - /// Retrieves all referenced tag name values found in this instruction. - /// - /// A collection of values cotnained by the instruction. - public TagName[] Tags => ParseTags(); + public IEnumerable Arguments => ParseArguments(); /// - /// Retrieves all immediate atomic type values found in this instruction. + /// /// - /// A collection of values cotnained by the instruction. - public AtomicData[] Values => ParseValues(); + public bool IsValid => ValidateText(_text); /// /// Indicates whether the instruction is a native Rockwell built-in instruction. @@ -173,56 +155,45 @@ private Instruction(string text) public static Instruction Unkown => new("UNK"); /// - /// Parses the provided string neutral text into a instance. - /// - /// The neutral text to parse. - /// A object representing the parsed text. - /// text is null, empty, or open/close parenthesis is not in valid - /// locations in the provided text. - public static Instruction Parse(string text) - { - if (string.IsNullOrEmpty(text)) - throw new ArgumentException("Instruction text can not be null or empty.", nameof(text)); - - text = text.Trim(';'); - - return new Instruction(text); - } - - /// - /// Attempts to parse the specified text into an object. + /// Parses the provided neutral text representation to extract and construct a sequence of objects. /// - /// The string representation of the instruction to parse. Null or empty values are invalid. - /// When the method returns, contains the parsed object if the parsing is successful; otherwise, null. - /// True if the parsing succeeds and an object is created; otherwise, false. - public static bool TryParse(string? text, out Instruction parsed) - { - parsed = null!; - - //Rung line terminators may be present, and we should trim those to get just the instruction text. - text = text?.TrimEnd(';') ?? string.Empty; - - if (text.IsEmpty()) return false; - if (text.IndexOf(Open) < 1 || !text.EndsWith(Close)) return false; - - parsed = new Instruction(text); - return true; - } - - /// - /// Splits the provided text into an array of objects found in the provided neutral text string. - /// - /// The neutral text value to be parsed and split into instructions. + /// + /// The containing the instruction code to parse. This represents the textual format of + /// instructions as they appear in Rockwell Automation's Logix neutral text format (structured text or ladder logic). + /// /// - /// An array of objects parsed from the input text. - /// Returns an empty array if the input string is null or empty. + /// An containing all instructions successfully parsed from the provided text. /// - public static Instruction[] Split(string text) + /// + /// This method tokenizes the input text using a and identifies instruction boundaries + /// by tracking parentheses depth. Nested parentheses (from nested expressions or array indices) are handled + /// by maintaining a depth counter. The parsing process is designed to work with both simple + /// instructions (e.g., "XIC(Tag)") up to full rungs with many instrcutions. + /// + public static IEnumerable Parse(NeutralText text) { - if (string.IsNullOrEmpty(text)) - return []; + using var stream = new NeutralStream(text); + var start = 0; + var depth = 0; - return Regex.Matches(text, Pattern).Cast().Select(m => Parse(m.Value)).ToArray(); + while (stream.Read(out var token)) + { + if (token.Type == TokenType.Identifier && stream.Match(TokenType.OpenParen) && depth == 0) + { + start = token.Index; + continue; + } + + if (token.Type == TokenType.CloseParen) + { + depth--; + if (depth > 0) continue; + yield return new Instruction(text.ToString().Substring(start, token.Index + 1 - start)); + } + + if (token.Type == TokenType.OpenParen) + depth++; + } } /// @@ -270,18 +241,19 @@ public bool Supports(ReferenceType type) /// public override bool Equals(object? obj) { - if (ReferenceEquals(this, obj)) return true; + if (ReferenceEquals(this, obj)) + return true; return obj switch { - Instruction other => Equals(_text, other._text), + Instruction other => _text.IsEquivalent(other._text), string text => _text.IsEquivalent(text), _ => false }; } /// - public override int GetHashCode() => _text.GetHashCode(); + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(_text); /// public override string ToString() => _text; @@ -1812,35 +1784,14 @@ private Argument[] ParseArguments() } /// - /// Find all tag name arguments in the current instruction and returns them as a flat list of . + /// /// - private TagName[] ParseTags() + /// + /// + /// + private static bool ValidateText(string text) { - //Ingore task calling instructions since they don't refer to a tag name. - if (IsTaskCall) return []; - - //For GSV and SSV instruction only the last argument represents an actual tag name reference. - if (IsSystemCall) return [Arguments.Last().ToString()]; - - //Skip the first argument of a routine instruction as it does not refer to a tag name. - var arguments = IsRoutineCall ? Arguments.Skip(1) : Arguments; - - //And then anything else return all tag arguments. - return arguments.SelectMany(a => a.Arguments.Where(x => x.IsReference).Select(t => t.ToTagName())).ToArray(); - } - - /// - /// Find all atomic value arguments in the current instruction and returns them as a flat list of . - /// - private AtomicData[] ParseValues() - { - //Ingore any system or task calling instructions since they don't refer to a tag name. - if (IsSystemCall || IsTaskCall) return []; - - //Skip the first argument of a routine instruction as it does not refer to a tag name. - var arguments = IsRoutineCall ? Arguments.Skip(1) : Arguments; - - return arguments.SelectMany(a => a.Arguments.Where(x => x.IsAtomic).Select(t => t.ToAtomic())).ToArray(); + throw new NotImplementedException(); } /// @@ -1864,7 +1815,8 @@ or nameof(LIM) or nameof(LIMIT) /// private static IEnumerable>> Factories() { - var methods = typeof(Instruction).GetMethods(BindingFlags.Public | BindingFlags.Static) + var methods = typeof(Instruction) + .GetMethods(BindingFlags.Public | BindingFlags.Static) .Where(m => m.ReturnType == typeof(Instruction) && m.Name.All(char.IsUpper)); foreach (var method in methods) diff --git a/src/L5Sharp.Core/Common/NeutralStream.cs b/src/L5Sharp.Core/Common/NeutralStream.cs index 9d688c89..02bf5707 100644 --- a/src/L5Sharp.Core/Common/NeutralStream.cs +++ b/src/L5Sharp.Core/Common/NeutralStream.cs @@ -14,13 +14,25 @@ public class NeutralStream : IDisposable /// private readonly IEnumerator _enumerator; + /// + /// Indicates whether the stream has reached the end of the input tokens. + /// + private bool _atEnd; + /// /// Initializes a new instance of the class with the specified neutral text. /// /// The neutral text to tokenize and stream. public NeutralStream(NeutralText text) { + if (text is null) + throw new ArgumentNullException(nameof(text)); + _enumerator = text.Tokenize().GetEnumerator(); + + // Prime the enumerator to the first token. + // If none exists, flag the end of the stream immediately. + _atEnd = !_enumerator.MoveNext(); } /// @@ -35,14 +47,10 @@ public NeutralStream(NeutralText text) /// public bool Read(out NeutralToken token) { - if (_enumerator.MoveNext()) - { - token = _enumerator.Current; - return true; - } - token = _enumerator.Current; - return false; + if (_atEnd) return false; + _atEnd = !_enumerator.MoveNext(); + return true; } /// @@ -65,8 +73,7 @@ public bool Read(out NeutralToken token) public bool Advance(int count = 1) { if (count < 0) - throw new ArgumentException("Count cannot be negative. Use Reset() to move to the beginning of the stream.", - nameof(count)); + throw new ArgumentException("Count cannot be negative.", nameof(count)); var index = 0; @@ -100,17 +107,6 @@ public bool Seek(Func predicate) return false; } - /// - /// Resets the stream's position to the beginning of the token sequence. - /// - /// - /// This operation enables reprocessing of tokens from the start of the stream. - /// - public void Reset() - { - _enumerator.Reset(); - } - /// /// Determines whether the current token matches the specified token type. /// @@ -121,8 +117,5 @@ public void Reset() /// /// Releases all resources used by the current instance of the class. /// - public void Dispose() - { - _enumerator.Dispose(); - } + public void Dispose() => _enumerator.Dispose(); } \ No newline at end of file diff --git a/src/L5Sharp.Core/Common/Reference.cs b/src/L5Sharp.Core/Common/Reference.cs index f53edf7e..e9753458 100644 --- a/src/L5Sharp.Core/Common/Reference.cs +++ b/src/L5Sharp.Core/Common/Reference.cs @@ -120,8 +120,9 @@ public Reference At(string fragment) /// True if the contains a valid logic instruction; otherwise, false. public bool HasLogic(out Instruction logic) { - if (Type.IsLogic && Instruction.TryParse(Fragment, out logic)) + if (Type.IsLogic && Fragment is not null && !Fragment.IsEmpty()) { + logic = new Instruction(Fragment); return true; } diff --git a/src/L5Sharp.Core/Common/TagName.cs b/src/L5Sharp.Core/Common/TagName.cs index c87cbcb2..5dff2c17 100644 --- a/src/L5Sharp.Core/Common/TagName.cs +++ b/src/L5Sharp.Core/Common/TagName.cs @@ -371,11 +371,23 @@ public override bool Equals(object? obj) public static TagName Combine(IEnumerable members) => new(ConcatenateMembers(members)); /// - /// Parses a object into a collection of objects based on its tokenized content. + /// Parses a stream and extracts all valid tag names found within the text. /// - /// The object to parse into tag names. - /// A collection of objects extracted from the given . - /// Thrown when the provided is null. + /// The containing the neutral text code to parse for tag names. + /// + /// An enumerable collection of instances representing all valid tag names discovered + /// during the parsing process. Tag names are extracted by analyzing token sequences and building hierarchical + /// paths based on identifiers, array indices, and member accessors found in the neutral text stream. + /// + /// is null. + /// + /// This method tokenizes the provided neutral text and identifies tag name patterns by tracking identifiers, + /// member separators (dots), and array indexing (brackets). It constructs tag names at multiple depth levels + /// to capture nested references within arrays and complex data structures. The method automatically handles + /// array bracket depth tracking and filters out instruction names by ignoring identifiers that precede + /// open parentheses. Tag names are collected and returned once a non-tag token is encountered, ensuring + /// complete tag paths are captured before moving to the next potential tag sequence. + /// public static IEnumerable Parse(NeutralText text) { if (text is null) @@ -500,11 +512,17 @@ private string GetBaseName() { var tagName = GetLocalTagName(); - if (tagName.IsEmpty() || tagName.StartsWith(Separator) || tagName.StartsWith(ArrayOpen)) + if (tagName.IsEmpty() || tagName.StartsWith(Separator)) return string.Empty; + + if (tagName.StartsWith(ArrayOpen)) + { + var endBracket = tagName.IndexOf(ArrayClose); + return endBracket > 0 ? tagName.Substring(0, endBracket + 1) : tagName; + } - var end = tagName.IndexOfAny([Separator, ArrayOpen]); - return end > 0 ? tagName.Substring(0, end) : tagName; + var endSeparator = tagName.IndexOfAny([Separator, ArrayOpen]); + return endSeparator > 0 ? tagName.Substring(0, endSeparator) : tagName; } /// diff --git a/tests/L5Sharp.Tests.Core/Code/RungTests.cs b/tests/L5Sharp.Tests.Core/Code/RungTests.cs index 1c2f3a5c..71725967 100644 --- a/tests/L5Sharp.Tests.Core/Code/RungTests.cs +++ b/tests/L5Sharp.Tests.Core/Code/RungTests.cs @@ -117,7 +117,7 @@ public void Instructions_BitReferenceIndexTag_ShouldReturnExpectedInstruction() var instructions = rung.Instructions().ToList(); instructions.Should().HaveCount(1); - instructions[0].Tags.Should().Contain("DintTest.[Offset]"); + instructions[0].Arguments.Should().Contain("DintTest.[Offset]"); } [Test] diff --git a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs index c55ecbd8..19eabd3e 100644 --- a/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/ArgumentTests.cs @@ -18,7 +18,7 @@ public void Empty_WhenCalled_ShouldHaveExpectedValue() argument.Should().Be(string.Empty); argument.Type.Should().Be(ArgumentType.Empty); - argument.IsInvalid.Should().BeTrue(); + argument.IsValid.Should().BeFalse(); } [Test] @@ -28,7 +28,7 @@ public void Unknown_WhenCalled_ShouldHaveExpectedValue() argument.Should().Be("?"); argument.Type.Should().Be(ArgumentType.Unknown); - argument.IsInvalid.Should().BeTrue(); + argument.IsValid.Should().BeFalse(); } [Test] @@ -123,69 +123,43 @@ public void Type_WhenCalled_ShouldHaveExpectedValue(string value, string expecte } [Test] - public void Arguments_ArgumentWithSingleTag_ShouldHaveExpectedCount() + public void ToAtomic_ValidAtomicValue_ShouldBeExpected() { - var argument = new Argument("MyTagName.Member[1].Active.1"); + Argument argument = "123"; - var args = argument.Arguments.ToArray(); + var atomic = argument.ToAtomic(); - args.Should().HaveCount(1); + atomic.Should().NotBeNull(); + atomic.ToString().Should().Be("123"); } [Test] - public void Arguments_ArgumentSingleAtomic_ShouldHaveExpectedCount() + public void ToAtomic_RealValue_ShouldBeExpected() { - Argument argument = 100; - - var args = argument.Arguments; - - args.Should().HaveCount(1); - } - - [Test] - public void Arguments_ExpressionWithSingleTagAndAtomic_ShouldHaveExpectedValue() - { - Argument argument = "MyTag > 100"; + Argument argument = "1.23"; - var args = argument.Arguments; + var atomic = argument.ToAtomic(); - args.Should().HaveCount(2); - args[0].Should().Be("MyTag"); - args[1].Should().Be("100"); + atomic.Should().NotBeNull(); + atomic.ToString().Should().Be("1.23"); } [Test] - public void Arguments_ExpressionWithMultipleTagsAndAtomics_ShouldHaveExpectedValues() + public void ToAtomic_NonAtomicValue_ShouldThrowException() { - Argument argument = "MyTag > 100 AND MyOtherTag < 16#ABCD"; - - var args = argument.Arguments; - - args.Should().HaveCount(4); - args[0].Should().Be("MyTag"); - args[1].Should().Be("100"); - args[2].Should().Be("MyOtherTag"); - args[3].Should().Be("16#ABCD"); - } - - [Test] - public void Arguments_ExpressionWithVariousAtomicFormats_ShouldExtractAll() - { - Argument argument = "16#1234 + 2#1010 + 8#77 + 1.23 + 123 + 1.#QNAN"; - - var args = argument.Arguments; + Argument argument = "TagName"; - args.Should().HaveCount(6); - args.Select(v => v.ToString()).Should().BeEquivalentTo("16#1234", "2#1010", "8#77", "1.23", "123", "1.#QNAN"); + FluentActions.Invoking(argument.ToAtomic).Should().Throw(); } [Test] - public void Argument_ExpressionWithNestedFunctions_ShouldReturnAllNestedArguments() + public void ToNeutralText_WhenCalled_ShouldBeExpected() { - Argument argument = "(ABS(MyTag.Member) + Another[1,2,3]) / (10**(SomeConstant - SystemTag[IndexReference]))"; + Argument argument = "SomeTag > 100"; - var args = argument.Arguments; + var text = argument.ToNeutralText(); - args.Should().HaveCount(6); + text.Should().NotBeNull(); + text.ToString().Should().Be("SomeTag > 100"); } } \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs b/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs index ccbaece9..8a93424f 100644 --- a/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs @@ -44,6 +44,34 @@ public void New_NativeInstruction_ShouldHaveExpectedValues() instruction.IsNative.Should().BeTrue(); } + [Test] + [TestCase("XIC(MyTagKey);", "XIC", 1)] + [TestCase("TON(SomeTimer,5000,0);", "TON", 3)] + [TestCase("TON(SomeTimer,?,?);", "TON", 3)] + [TestCase("CMP(ATN(_Test) > 1.0);", "CMP", 1)] + [TestCase("JSR(Routine,2,in1,in2,out1,out2,out3);", "JSR", 7)] + [TestCase("SBR(Routine,in1,in2);", "SBR", 3)] + [TestCase("RET(Routine,out1,out2);", "RET", 3)] + public void New_ValidFormats_ShouldHaveExpectedKeyAndArgumentCount(string text, string key, int args) + { + var instruction = new Instruction(text); + + instruction.Should().NotBeNull(); + instruction.Key.Should().Be(key); + instruction.Arguments.Should().HaveCount(args); + } + + [Test] + [TestCase("")] + [TestCase("Test")] + [TestCase("()")] + [TestCase("(SomeTag > 1.0)/100")] + [TestCase("ABS(SomeTag) / 100 > 1")] + public void New_InvalidFormats_ShouldThrowException(string text) + { + FluentActions.Invoking(() => _ = new Instruction(text)).Should().Throw(); + } + [Test] public void Unknown_WhenCalled_ShouldBeExpected() { @@ -119,48 +147,49 @@ public void EVENT_WhenCalled_ShouldBeExpectedProperties() } [Test] - public void Tags_SimpleInstruction_ShouldHaveExpected() + public void Arguments_SimpleInstruction_ShouldHaveExpected() { var instruction = Instruction.MOVE("MyTagValue", "SomeTagName.Member.Value"); - var tags = instruction.Tags; + var args = instruction.Arguments.ToArray(); - tags.Should().HaveCount(2); - tags[0].Should().Be("MyTagValue"); - tags[1].Should().Be("SomeTagName.Member.Value"); + args.Should().HaveCount(2); + args[0].Should().Be("MyTagValue"); + args[1].Should().Be("SomeTagName.Member.Value"); } [Test] - public void Tags_TaskReference_ShouldBeExpected() + public void Arguments_TaskReference_ShouldBeExpected() { var instruction = Instruction.EVENT("MyTask"); - var tags = instruction.Tags; + var args = instruction.Arguments.ToArray(); - tags.Should().BeEmpty(); + args.Should().HaveCount(1); } [Test] - public void Tags_RoutineReference_ShouldBeExpected() + public void Arguments_RoutineReference_ShouldBeExpected() { var instruction = Instruction.JSR("Routine", 1, "In1", "Out1"); - var tags = instruction.Tags; + var args = instruction.Arguments.ToArray(); - tags.Should().HaveCount(2); - tags[0].Should().Be("In1"); - tags[1].Should().Be("Out1"); + args.Should().HaveCount(4); + args[0].Should().Be("Routine"); + args[1].Should().Be(1); + args[2].Should().Be("In1"); + args[3].Should().Be("Out1"); } [Test] - public void Tags_SystemInstruction_ShouldBeExpected() + public void Arguments_SystemInstruction_ShouldBeExpected() { var instruction = Instruction.GSV("Program", "MyProgram", "LastScanTime", "MyTagName"); - var tags = instruction.Tags; + var args = instruction.Arguments.ToArray(); - tags.Should().HaveCount(1); - tags[0].Should().Be("MyTagName"); + args.Should().HaveCount(4); } [Test] @@ -184,7 +213,7 @@ public void Append_ValidArgument_ShouldBeExpected() result.Arguments.Should().HaveCount(1); result.Arguments.Should().Contain("arg1"); } - + [Test] public void Append_MultipleArgs_ShouldBeExpected() { @@ -198,31 +227,15 @@ public void Append_MultipleArgs_ShouldBeExpected() } [Test] - [TestCase("XIC(MyTagKey);", "XIC", 1)] - [TestCase("TON(SomeTimer,5000,0);", "TON", 3)] - [TestCase("TON(SomeTimer,?,?);", "TON", 3)] - [TestCase("CMP(ATN(_Test) > 1.0);", "CMP", 1)] - [TestCase("JSR(Routine,2,in1,in2,out1,out2,out3);", "JSR", 7)] - [TestCase("SBR(Routine,in1,in2);", "SBR", 3)] - [TestCase("RET(Routine,out1,out2);", "RET", 3)] - public void Parse_ValidFormats_ShouldHaveExpectedKeyAndArgumentCount(string text, string key, int args) + [TestCase("XIC(MyTagKey);", 1)] + [TestCase("TON(SomeTimer,5000,0);", 1)] + [TestCase("JSR(Routine,2,in1,in2,out1,out2,out3);", 1)] + [TestCase("XIC(MySimpleTag) XIO(MyArrayTag[1]) OTE(MyComplexTag.Member.12);", 3)] + public void Read_ValidText_ShouldHaveExpectedCount(string text, int count) { - var instruction = Instruction.Parse(text); - - instruction.Should().NotBeNull(); - instruction.Key.Should().Be(key); - instruction.Arguments.Should().HaveCount(args); - } + var instructions = Instruction.Parse(text).ToArray(); - [Test] - [TestCase("")] - [TestCase("Test")] - [TestCase("()")] - [TestCase("(SomeTag > 1.0)/100")] - [TestCase("ABS(SomeTag) / 100 > 1")] - public void Parse_InvalidFormats_ShouldThrowException(string text) - { - FluentActions.Invoking(() => Instruction.Parse(text)).Should().Throw(); + instructions.Should().HaveCount(count); } [Test] @@ -230,7 +243,7 @@ public void GetArgument_ValidOperand_ShouldNotBeNull() { var instruction = Instruction.ADD(1, 1, "Test"); - var argument = instruction.Arguments[1]; + var argument = instruction.Arguments.ToArray()[1]; argument.Should().Be("1"); } @@ -302,13 +315,13 @@ public void NotEqualsOperator_EqualInstances_ShouldBeFalse() } [Test] - public void GetHasCode_WhenCalled_ReturnsHashOfInstructionText() + public void GetHashCode_WhenCalled_ReturnsHashOfInstructionText() { var instruction = Instruction.XIC("MyTag"); var result = instruction.GetHashCode(); - result.Should().Be("XIC(MyTag)".GetHashCode()); + result.Should().Be(StringComparer.OrdinalIgnoreCase.GetHashCode("XIC(MyTag)")); } [Test] diff --git a/tests/L5Sharp.Tests.Core/Common/NeutralStreamTests.cs b/tests/L5Sharp.Tests.Core/Common/NeutralStreamTests.cs new file mode 100644 index 00000000..fe100106 --- /dev/null +++ b/tests/L5Sharp.Tests.Core/Common/NeutralStreamTests.cs @@ -0,0 +1,136 @@ +using FluentAssertions; + +namespace L5Sharp.Tests.Core.Common; + +[TestFixture] +public class NeutralStreamTests +{ + [Test] + public void Constructor_ValidText_ShouldNotBeNull() + { + var stream = new NeutralStream("Test"); + stream.Should().NotBeNull(); + } + + [Test] + public void Read_WhenCalled_ShouldReturnTrueAndFirstToken() + { + var stream = new NeutralStream("Tag1"); + + var result = stream.Read(out var token); + + result.Should().BeTrue(); + token.Value.Should().Be("Tag1"); + token.Type.Should().Be(TokenType.Identifier); + } + + [Test] + public void Read_WhenAtEOF_ShouldReturnFalseAndEOFToken() + { + var stream = new NeutralStream("Tag1"); + stream.Read(out _); // Consume Tag1 + stream.Read(out _); // Consume EOF + + var result = stream.Read(out var token); + + result.Should().BeFalse(); + token.Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Peek_WhenCalled_ShouldReturnCurrentToken() + { + var stream = new NeutralStream("Tag1"); + + var token = stream.Peek(); + + token.Value.Should().Be("Tag1"); + } + + [Test] + public void Advance_PositiveCount_ShouldMovePosition() + { + var stream = new NeutralStream("Tag1.Tag2.Tag3"); + + var result = stream.Advance(2); + + result.Should().BeTrue(); + stream.Read(out var token); + token.Value.Should().Be("Tag2"); // Tag1, ., Tag2 -> Advance(2) moves past Tag1 and . + } + + [Test] + public void Advance_NegativeCount_ShouldThrowArgumentException() + { + var stream = new NeutralStream("Tag1"); + + Action act = () => stream.Advance(-1); + + act.Should().Throw(); + } + + [Test] + public void Seek_ConditionMet_ShouldReturnTrueAndAdvance() + { + var stream = new NeutralStream("Tag1.Tag2"); + + var result = stream.Seek(t => t.Value == "Tag2"); + + result.Should().BeTrue(); + stream.Peek().Value.Should().Be("Tag2"); + } + + [Test] + public void Seek_ConditionNotMet_ShouldReturnFalse() + { + var stream = new NeutralStream("Tag1.Tag2"); + + var result = stream.Seek(t => t.Value == "NonExistent"); + + result.Should().BeFalse(); + stream.Peek().Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Match_TypeMatches_ShouldReturnTrue() + { + var stream = new NeutralStream("Tag1"); + stream.Read(out _); + + var result = stream.Match(TokenType.EOF); + + result.Should().BeTrue(); + } + + [Test] + public void Match_TypeDoesNotMatch_ShouldReturnFalse() + { + var stream = new NeutralStream("Tag1"); + stream.Read(out _); + + var result = stream.Match(TokenType.Colon); + + result.Should().BeFalse(); + } + + [Test] + public void Advance_CountExceedsLength_ShouldReturnFalseAndBeAtEOF() + { + var stream = new NeutralStream("Tag1"); + + var result = stream.Advance(5); + + result.Should().BeFalse(); + stream.Peek().Type.Should().Be(TokenType.EOF); + } + + [Test] + public void Dispose_WhenCalled_ShouldNotThrow() + { + var stream = new NeutralStream("Tag1"); + + var act = stream.Dispose; + + act.Should().NotThrow(); + } +} \ No newline at end of file diff --git a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs index d24c50f7..a65a8d57 100644 --- a/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/TagNameTests.cs @@ -169,9 +169,8 @@ public void Scope_WithProgramPrefixAndMemberTag_ShouldHaveExpectedProperties() [TestCase("MyTag[1].SomeMember.1", "MyTag")] [TestCase("Module:1:I.Data[1].SubTag.Value.12", "Module:1:I")] [TestCase(".Member[32].Value", "")] - [TestCase("[32].Value", "")] + [TestCase("[32].Value", "[32]")] [TestCase("Program:MyProgram.MyTag[32].Value", "MyTag")] - [TestCase("Program:MyProgram.[32].Value", "")] public void BaseName_WhenCalled_ShouldBeExpected(string value, string expected) { var tagName = new TagName(value); @@ -540,6 +539,21 @@ public void Parse_MultipleTagsInRungTextWithBranchTokens_ShouldReturnExpectedTag tags[2].Should().Be("IndexTagName"); tags[3].Should().Be("MyComplexTag.Member.12"); } + + [Test] + public void Parse_NestedExpressionArgument_ShouldReturnExpectedTagNames() + { + NeutralText text = "(ABS(MyTag.Member) + Another[1,2,3]) / (10**(SomeConstant - SystemTag[IndexTag]))"; + + var tags = TagName.Parse(text).ToList(); + + tags.Should().HaveCount(5); + tags[0].Should().Be("MyTag.Member"); + tags[1].Should().Be("Another[1,2,3]"); + tags[2].Should().Be("SomeConstant"); + tags[3].Should().Be("SystemTag[IndexTag]"); + tags[4].Should().Be("IndexTag"); + } [Test] public void ImplicitOperator_Null_ShouldBeEmpty() diff --git a/tests/L5Sharp.Tests.Core/Examples.cs b/tests/L5Sharp.Tests.Core/Examples.cs index d3a12992..aa1c2957 100644 --- a/tests/L5Sharp.Tests.Core/Examples.cs +++ b/tests/L5Sharp.Tests.Core/Examples.cs @@ -119,7 +119,7 @@ public void QueryAllRungsAndGetTagsInMovInstruction() var content = TestContent.Test; var results = content.Query() - .SelectMany(r => r.Instructions().Where(i => i.Key == "MOVE").SelectMany(x => x.Tags)) + .SelectMany(r => r.Instructions().Where(i => i.Key == "MOVE").SelectMany(x => x.Arguments)) .ToList(); results.Should().NotBeEmpty(); From 698c0ec129b51b4cdb0629b0e5c766209fdb15ba Mon Sep 17 00:00:00 2001 From: tnunnink Date: Sat, 23 May 2026 21:05:11 -0500 Subject: [PATCH 13/13] Added validation logic to `Instruction` with `IsValid` property and updated associated tests to improve format checking and error handling. --- src/L5Sharp.Core/Common/Instruction.cs | 45 ++++++++++++++----- .../Common/InstructionTests.cs | 21 +++++++-- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/L5Sharp.Core/Common/Instruction.cs b/src/L5Sharp.Core/Common/Instruction.cs index bcae6f5f..070dca8f 100644 --- a/src/L5Sharp.Core/Common/Instruction.cs +++ b/src/L5Sharp.Core/Common/Instruction.cs @@ -107,18 +107,36 @@ public Instruction(string text) public string Key => ParseKey(); /// - /// The collection of values the instruction contains. + /// Gets the collection of arguments that make up the instruction signature. /// /// - /// A collection of value objects. - /// These could represent literal values, tag names, or nested expressions. + /// An containing all arguments parsed from the instruction text. + /// Arguments are extracted from the parentheses-enclosed signature portion of the instruction. + /// For example, in "XIC(Tag1)", this returns a single argument "Tag1". + /// Returns an empty collection if the instruction has no arguments (e.g., "NOP()"). /// public IEnumerable Arguments => ParseArguments(); /// - /// + /// Gets a value indicating whether the instruction has a valid text format. /// - public bool IsValid => ValidateText(_text); + /// + /// true if the instruction text is well-formed with a valid key, proper parentheses, + /// and valid arguments; otherwise, false. + /// + /// + /// This property performs structural validation, including: + /// + /// The text is not null or empty. + /// The key is present and non-empty. + /// Opening and closing parentheses are correctly positioned. + /// All arguments have valid types (no empty arguments). + /// + /// Note that this does not validate whether the instruction key is a known Rockwell instruction + /// or whether the argument count matches the expected signature. Use to + /// determine if the instruction is a known built-in instruction. + /// + public bool IsValid => ValidateText(); /// /// Indicates whether the instruction is a native Rockwell built-in instruction. @@ -1784,14 +1802,19 @@ private Argument[] ParseArguments() } /// - /// + /// Validates the syntax and structure of the provided text according to the predefined format. + /// Ensures the text is neither null nor empty, contains the required opening and closing characters, + /// has a valid key, and does not include arguments with an empty type. /// - /// - /// - /// - private static bool ValidateText(string text) + /// True if the text is valid; otherwise, false. + private bool ValidateText() { - throw new NotImplementedException(); + if (string.IsNullOrWhiteSpace(_text)) return false; + if (_text.IndexOf(Open) <= 0) return false; + if (!_text.EndsWith(Close)) return false; + if (Key.IsEmpty()) return false; + if (Arguments.Any(a => a.Type == ArgumentType.Empty)) return false; + return true; } /// diff --git a/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs b/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs index 8a93424f..c9dba231 100644 --- a/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs +++ b/tests/L5Sharp.Tests.Core/Common/InstructionTests.cs @@ -5,10 +5,22 @@ namespace L5Sharp.Tests.Core.Common [TestFixture] public class InstructionTests { + [Test] + public void New_Null_ShouldThrowException() + { + FluentActions.Invoking(() => _ = new Instruction(null!)).Should().Throw(); + } + + [Test] + public void New_EmptyString_ShouldThrowException() + { + FluentActions.Invoking(() => _ = new Instruction(string.Empty)).Should().Throw(); + } + [Test] public void New_KeyNoArgs_ShouldHaveExpectedValues() { - var instruction = new Instruction("Test"); + var instruction = new Instruction("Test", []); instruction.Should().Be("Test()"); instruction.Key.Should().Be("Test"); @@ -62,14 +74,15 @@ public void New_ValidFormats_ShouldHaveExpectedKeyAndArgumentCount(string text, } [Test] - [TestCase("")] [TestCase("Test")] [TestCase("()")] [TestCase("(SomeTag > 1.0)/100")] [TestCase("ABS(SomeTag) / 100 > 1")] - public void New_InvalidFormats_ShouldThrowException(string text) + public void IsValid_InvalidFormats_ShouldBeFalse(string text) { - FluentActions.Invoking(() => _ = new Instruction(text)).Should().Throw(); + var instruction = new Instruction(text); + + instruction.IsValid.Should().BeFalse(); } [Test]