Auto: abcip-2.1 — L5K parser + ingest
Pure-text parser for Studio 5000 L5K controller exports. Recognises TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks, strips (* ... *) comments, and tolerates multi-line entries + unknown sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag, L5kDataType, L5kMember — feed L5kIngest which converts to AbCipTagDefinition + AbCipStructureMember. Alias tags and ExternalAccess=None tags are skipped per Kepware precedent. AbCipDriverOptions gains an L5kImports collection (AbCipL5kImportOptions records — file path or inline text + per-import device + name prefix). InitializeAsync merges the imports into the declared Tags map, with declared tags winning on Name conflicts so operators can override import results without editing the L5K source. Tests cover controller-scope TAG, program-scope TAG, alias-tag flag, DATATYPE with member array dims, comment stripping, unknown-section skipping, multi-line entries, and the full ingest path including ExternalAccess=None / ReadOnly / UDT-typed tag fanout. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
380
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
380
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
|
||||
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
|
||||
/// the common shapes:
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
|
||||
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
|
||||
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
|
||||
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
|
||||
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
|
||||
/// </list>
|
||||
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
|
||||
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
|
||||
/// is what owns the storage — surfacing both creates duplicate writes/reads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
|
||||
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
|
||||
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
|
||||
/// produced by RSLogix exports that vary across versions.
|
||||
/// </remarks>
|
||||
public static class L5kParser
|
||||
{
|
||||
public static L5kDocument Parse(IL5kSource source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var raw = source.ReadAll();
|
||||
var stripped = StripBlockComments(raw);
|
||||
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
|
||||
|
||||
var tags = new List<L5kTag>();
|
||||
var datatypes = new List<L5kDataType>();
|
||||
string? currentProgram = null;
|
||||
var i = 0;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
|
||||
if (StartsWithKeyword(line, "PROGRAM"))
|
||||
{
|
||||
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (StartsWithKeyword(line, "END_PROGRAM"))
|
||||
{
|
||||
currentProgram = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// TAG block — collects 1..N tag entries until END_TAG.
|
||||
if (StartsWithKeyword(line, "TAG"))
|
||||
{
|
||||
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// DATATYPE block.
|
||||
if (StartsWithKeyword(line, "DATATYPE"))
|
||||
{
|
||||
var consumed = ParseDataTypeBlock(lines, i, datatypes);
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return new L5kDocument(tags, datatypes);
|
||||
}
|
||||
|
||||
// ---- TAG block ---------------------------------------------------------
|
||||
|
||||
// Each TAG block contains 1..N entries of the form:
|
||||
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
|
||||
// until END_TAG. Entries can span multiple lines, terminated by ';'.
|
||||
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
|
||||
{
|
||||
var i = start + 1;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
var sb = new System.Text.StringBuilder(line);
|
||||
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||
{
|
||||
var peek = lines[i + 1].Trim();
|
||||
if (StartsWithKeyword(peek, "END_TAG")) break;
|
||||
i++;
|
||||
sb.Append(' ').Append(peek);
|
||||
}
|
||||
i++;
|
||||
|
||||
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||
var tag = ParseTagEntry(entry, program);
|
||||
if (tag is not null) into.Add(tag);
|
||||
}
|
||||
return i - start;
|
||||
}
|
||||
|
||||
private static L5kTag? ParseTagEntry(string entry, string? program)
|
||||
{
|
||||
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
|
||||
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
|
||||
var colonIdx = FindBareColon(entry);
|
||||
if (colonIdx < 0) return null;
|
||||
|
||||
var name = entry.Substring(0, colonIdx).Trim();
|
||||
if (name.Length == 0) return null;
|
||||
|
||||
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||
// The attribute parens themselves contain ':=' assignments, so locate the top-level
|
||||
// assignment (depth-0 ':=') that introduces the initial value before stripping.
|
||||
var assignIdx = FindTopLevelAssign(rest);
|
||||
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
|
||||
|
||||
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
|
||||
string dataType;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var openParen = head.IndexOf('(');
|
||||
if (openParen >= 0)
|
||||
{
|
||||
dataType = head.Substring(0, openParen).Trim();
|
||||
var closeParen = head.LastIndexOf(')');
|
||||
if (closeParen > openParen)
|
||||
{
|
||||
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
|
||||
ParseAttributeList(attrBody, attributes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dataType = head.Trim();
|
||||
}
|
||||
|
||||
if (dataType.Length == 0) return null;
|
||||
|
||||
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
|
||||
|
||||
return new L5kTag(
|
||||
Name: name,
|
||||
DataType: dataType,
|
||||
ProgramScope: program,
|
||||
ExternalAccess: externalAccess,
|
||||
Description: description,
|
||||
AliasFor: aliasFor);
|
||||
}
|
||||
|
||||
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
|
||||
private static int FindTopLevelAssign(string entry)
|
||||
{
|
||||
var depth = 0;
|
||||
var inQuote = false;
|
||||
for (var k = 0; k < entry.Length - 1; k++)
|
||||
{
|
||||
var c = entry[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c == '(' || c == '[' || c == '{') depth++;
|
||||
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
|
||||
private static int FindBareColon(string entry)
|
||||
{
|
||||
var inQuote = false;
|
||||
for (var k = 0; k < entry.Length; k++)
|
||||
{
|
||||
var c = entry[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c != ':') continue;
|
||||
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
|
||||
return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void ParseAttributeList(string body, Dictionary<string, string> into)
|
||||
{
|
||||
foreach (var part in SplitTopLevelCommas(body))
|
||||
{
|
||||
var assign = part.IndexOf(":=", StringComparison.Ordinal);
|
||||
if (assign < 0) continue;
|
||||
var key = part.Substring(0, assign).Trim();
|
||||
var val = part.Substring(assign + 2).Trim();
|
||||
if (key.Length > 0) into[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitTopLevelCommas(string body)
|
||||
{
|
||||
var depth = 0;
|
||||
var inQuote = false;
|
||||
var start = 0;
|
||||
for (var k = 0; k < body.Length; k++)
|
||||
{
|
||||
var c = body[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c == '(' || c == '[' || c == '{') depth++;
|
||||
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||
else if (c == ',' && depth == 0)
|
||||
{
|
||||
yield return body.Substring(start, k - start);
|
||||
start = k + 1;
|
||||
}
|
||||
}
|
||||
if (start < body.Length) yield return body.Substring(start);
|
||||
}
|
||||
|
||||
// ---- DATATYPE block ----------------------------------------------------
|
||||
|
||||
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
|
||||
{
|
||||
var first = lines[start].Trim();
|
||||
var head = first.Substring("DATATYPE".Length).Trim();
|
||||
var name = ExtractFirstQuotedOrToken(head);
|
||||
var members = new List<L5kMember>();
|
||||
var i = start + 1;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (StartsWithKeyword(line, "END_DATATYPE"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start + 1;
|
||||
}
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
if (StartsWithKeyword(line, "MEMBER"))
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(line);
|
||||
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||
{
|
||||
var peek = lines[i + 1].Trim();
|
||||
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
|
||||
i++;
|
||||
sb.Append(' ').Append(peek);
|
||||
}
|
||||
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||
entry = entry.Substring("MEMBER".Length).Trim();
|
||||
var member = ParseMemberEntry(entry);
|
||||
if (member is not null) members.Add(member);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start;
|
||||
}
|
||||
|
||||
private static L5kMember? ParseMemberEntry(string entry)
|
||||
{
|
||||
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
|
||||
var colonIdx = FindBareColon(entry);
|
||||
if (colonIdx < 0) return null;
|
||||
var name = entry.Substring(0, colonIdx).Trim();
|
||||
if (name.Length == 0) return null;
|
||||
|
||||
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||
var assignIdx = FindTopLevelAssign(rest);
|
||||
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
|
||||
|
||||
int? arrayDim = null;
|
||||
var bracketOpen = rest.IndexOf('[');
|
||||
if (bracketOpen >= 0)
|
||||
{
|
||||
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
|
||||
if (bracketClose > bracketOpen)
|
||||
{
|
||||
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
|
||||
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
|
||||
arrayDim = dim;
|
||||
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
string typePart;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var openParen = rest.IndexOf('(');
|
||||
if (openParen >= 0)
|
||||
{
|
||||
typePart = rest.Substring(0, openParen).Trim();
|
||||
var closeParen = rest.LastIndexOf(')');
|
||||
if (closeParen > openParen)
|
||||
{
|
||||
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
|
||||
ParseAttributeList(attrBody, attributes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
typePart = rest.Trim();
|
||||
}
|
||||
|
||||
if (typePart.Length == 0) return null;
|
||||
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||
return new L5kMember(name, typePart, arrayDim, externalAccess);
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static bool StartsWithKeyword(string line, string keyword)
|
||||
{
|
||||
if (line.Length < keyword.Length) return false;
|
||||
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (line.Length == keyword.Length) return true;
|
||||
var next = line[keyword.Length];
|
||||
return !char.IsLetterOrDigit(next) && next != '_';
|
||||
}
|
||||
|
||||
private static string ExtractFirstQuotedOrToken(string fragment)
|
||||
{
|
||||
var trimmed = fragment.TrimStart();
|
||||
if (trimmed.Length == 0) return string.Empty;
|
||||
if (trimmed[0] == '"' || trimmed[0] == '\'')
|
||||
{
|
||||
var quote = trimmed[0];
|
||||
var end = trimmed.IndexOf(quote, 1);
|
||||
if (end > 0) return trimmed.Substring(1, end - 1);
|
||||
}
|
||||
var k = 0;
|
||||
while (k < trimmed.Length)
|
||||
{
|
||||
var c = trimmed[k];
|
||||
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
|
||||
k++;
|
||||
}
|
||||
return trimmed.Substring(0, k);
|
||||
}
|
||||
|
||||
private static string Unquote(string s)
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
|
||||
return s.Substring(1, s.Length - 2);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string StripBlockComments(string text)
|
||||
{
|
||||
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
|
||||
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
|
||||
return pattern.Replace(text, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
|
||||
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
|
||||
|
||||
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
|
||||
public sealed record L5kTag(
|
||||
string Name,
|
||||
string DataType,
|
||||
string? ProgramScope,
|
||||
string? ExternalAccess,
|
||||
string? Description,
|
||||
string? AliasFor);
|
||||
|
||||
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
||||
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
||||
|
||||
/// <summary>One member line inside a UDT definition.</summary>
|
||||
public sealed record L5kMember(string Name, string DataType, int? ArrayDim, string? ExternalAccess);
|
||||
Reference in New Issue
Block a user