using System.Globalization; using System.Text.RegularExpressions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; /// /// 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: /// /// Controller-scope TAG ... END_TAG with Name, DataType, /// optional ExternalAccess, optional Description. /// Program-scope tags inside PROGRAM ... END_PROGRAM. /// UDT definitions via DATATYPE ... END_DATATYPE with MEMBER lines. /// Alias tags (AliasFor) — recognised + flagged so callers can skip them. /// /// 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. /// /// /// This is a permissive line-oriented parser, not a full L5K grammar. Comments /// ((* ... *)) 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. /// 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(); var datatypes = new List(); 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; } // PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage // attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's // L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do. if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION")) { var consumed = ParseAoiDefinitionBlock(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 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(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 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 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 into) { var first = lines[start].Trim(); var head = first.Substring("DATATYPE".Length).Trim(); var name = ExtractFirstQuotedOrToken(head); var members = new List(); 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(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; var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null; // PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT // members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local // in the ingest layer. var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null; return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage); } // ---- AOI block --------------------------------------------------------- /// /// PR abcip-2.6 — parse ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION /// blocks. Body is structured around PARAMETER entries (each carrying a Usage /// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as /// rows + leave routines alone — only the surface API matters for /// tag-discovery fan-out. The L5K format encloses parameters either inside a /// PARAMETERS ... END_PARAMETERS block or as bare PARAMETER ... ; lines at /// the AOI top level depending on Studio 5000 export options; this parser accepts both. /// private static int ParseAoiDefinitionBlock(string[] lines, int start, List into) { var first = lines[start].Trim(); var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim(); var name = ExtractFirstQuotedOrToken(head); var members = new List(); var i = start + 1; var inLocalsBlock = false; var inRoutineBlock = false; while (i < lines.Length) { var line = lines[i].Trim(); if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION")) { if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members)); return i - start + 1; } if (line.Length == 0) { i++; continue; } // Skip routine bodies — they hold ladder / ST / FBD code we don't care about for // tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out. if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; } if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; } if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; } if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; } if (inRoutineBlock || inLocalsBlock) { i++; continue; } // PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing. if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; } if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; } if (StartsWithKeyword(line, "PARAMETER")) { 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_ADD_ON_INSTRUCTION_DEFINITION")) break; i++; sb.Append(' ').Append(peek); } var entry = sb.ToString().TrimEnd(';').Trim(); entry = entry.Substring("PARAMETER".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; } // ---- 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); } } /// Output of . public sealed record L5kDocument(IReadOnlyList Tags, IReadOnlyList DataTypes); /// One L5K tag entry (controller- or program-scope). public sealed record L5kTag( string Name, string DataType, string? ProgramScope, string? ExternalAccess, string? Description, string? AliasFor); /// One UDT definition extracted from a DATATYPE ... END_DATATYPE block. public sealed record L5kDataType(string Name, IReadOnlyList Members); /// One member line inside a UDT definition or AOI parameter list. /// /// PR abcip-2.6 — carries the AOI Usage attribute (Input / /// Output / InOut) raw text. Plain UDT members + L5K AOI LOCAL_TAGS leave /// it null; the ingest layer maps null → . /// public sealed record L5kMember( string Name, string DataType, int? ArrayDim, string? ExternalAccess, string? Description = null, string? Usage = null);