/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Text; namespace Opc.Ua.Client.FileSystem { /// /// -equivalent helpers for the OPC UA /// FileSystem client. Paths use the forward slash '/' as /// separator and each segment is parsed via /// , so the /// "<ns>:<name>" form is supported (with the /// <ns>: prefix omitted when the namespace index is /// zero). /// /// /// /// The empty string and "/" both denote the root directory. /// Leading and trailing separators are tolerated and trimmed during /// parsing. Internal duplicate separators ("a//b") collapse /// into a single one and an empty middle segment is rejected. /// /// /// Path comparisons are namespace aware: siblings /// "1:foo" and "2:foo" are different paths and produce /// different cache keys, even though their /// is identical. /// /// public static class UaPath { /// /// The path separator character used by . /// public const char Separator = '/'; /// /// Returns the canonical string form of the root path /// (a single '/'). /// public const string Root = "/"; /// /// Splits into its /// segments. The empty string and "/" both yield an empty /// array. Leading and trailing slashes are tolerated; embedded /// empty segments throw . /// /// A forward-slash separated path. /// The parsed path segments. /// is null. /// /// contains an empty middle segment /// (e.g. "a//b") or a segment whose namespace prefix is /// not a valid . /// public static QualifiedName[] Parse(string path) { if (path == null) { throw new ArgumentNullException(nameof(path)); } ReadOnlySpan remaining = path.AsSpan(); // Trim a single leading / trailing slash; multiple are illegal. if (remaining.Length > 0 && remaining[0] == Separator) { remaining = remaining[1..]; } if (remaining.Length > 0 && remaining[^1] == Separator) { remaining = remaining[..^1]; } if (remaining.Length == 0) { return []; } var segments = new List(); int start = 0; for (int i = 0; i <= remaining.Length; i++) { if (i == remaining.Length || remaining[i] == Separator) { if (i == start) { throw new ArgumentException( $"Invalid path '{path}': empty segment.", nameof(path)); } string segment = remaining[start..i].ToString(); segments.Add(ParseSegment(segment, path)); start = i + 1; } } return [.. segments]; } /// /// Returns the canonical string form of . /// Always begins with '/'. The root produces /// "/". Each segment is rendered via /// so the namespace /// index is always preserved. /// /// The path segments. /// A canonical, namespace-aware string form. public static string Format(IReadOnlyList segments) { if (segments == null || segments.Count == 0) { return Root; } var sb = new StringBuilder(); for (int i = 0; i < segments.Count; i++) { sb.Append(Separator).Append(FormatSegment(segments[i])); } return sb.ToString(); } /// /// Returns the canonical string form of a single segment as it /// appears in a path (e.g. "1:foo" for a non-zero /// namespace index, "foo" for namespace zero). /// /// The segment. /// The formatted segment. /// /// has a null or empty /// . /// public static string FormatSegment(QualifiedName segment) { if (string.IsNullOrEmpty(segment.Name)) { throw new ArgumentException( "Path segment must have a non-empty Name.", nameof(segment)); } if (segment.NamespaceIndex == 0) { return segment.Name!; } return $"{segment.NamespaceIndex}:{segment.Name}"; } /// /// Combines two paths in the manner of /// : if /// begins with '/' it is /// returned unchanged; otherwise the two are joined by exactly /// one separator. /// /// The base path; may be null or empty /// to mean the root. /// The relative or absolute path. /// The combined path. /// /// is null. /// public static string Combine(string left, string right) { if (right == null) { throw new ArgumentNullException(nameof(right)); } if (right.Length > 0 && right[0] == Separator) { return Normalize(right); } QualifiedName[] leftSegments = Parse(left ?? string.Empty); QualifiedName[] rightSegments = Parse(right); if (rightSegments.Length == 0) { return Format(leftSegments); } var combined = new List(leftSegments.Length + rightSegments.Length); combined.AddRange(leftSegments); combined.AddRange(rightSegments); return Format(combined); } /// /// Returns the parent directory of , or /// null if is the root or empty. /// /// The path. /// The directory portion in canonical form, or /// null. public static string? GetDirectoryName(string path) { QualifiedName[] segments = Parse(path); if (segments.Length == 0) { return null; } if (segments.Length == 1) { return Root; } var parent = new QualifiedName[segments.Length - 1]; Array.Copy(segments, parent, parent.Length); return Format(parent); } /// /// Returns the leaf segment of as a /// , or /// when the path is the root. /// /// The path. /// The leaf segment. public static QualifiedName GetFileName(string path) { QualifiedName[] segments = Parse(path); if (segments.Length == 0) { return QualifiedName.Null; } return segments[^1]; } /// /// Returns the canonical string form of . /// /// The path. /// The canonicalised path string. public static string Normalize(string path) { return Format(Parse(path)); } private static QualifiedName ParseSegment(string segment, string fullPath) { int colon = segment.IndexOf(':', StringComparison.Ordinal); if (colon < 0) { return new QualifiedName(segment); } if (colon == 0 || colon == segment.Length - 1) { throw new ArgumentException( $"Invalid path '{fullPath}': segment '{segment}' has an empty namespace prefix or empty name.", nameof(fullPath)); } string nsToken = segment[..colon]; if (!ushort.TryParse(nsToken, out ushort ns)) { throw new ArgumentException( $"Invalid path '{fullPath}': segment '{segment}' has a non-numeric namespace prefix.", nameof(fullPath)); } return new QualifiedName(segment[(colon + 1)..], ns); } } }