|
| 1 | +// FR-023 §4.3 — the scope-pattern grammar: whether a fully-qualified node name falls |
| 2 | +// inside a consumer's declared `include`/`exclude` scope. |
| 3 | +// |
| 4 | +// A pure, no-I/O port of server/typescript/packages/metadata/src/scope.ts |
| 5 | +// (compilePattern / compileScope / matchesScope). The shared corpus |
| 6 | +// fixtures/scope-conformance/cases.json pins the semantics, so behavior must match the |
| 7 | +// reference exactly: |
| 8 | +// |
| 9 | +// `*` inside a segment matches any run of characters but never crosses a `::`. |
| 10 | +// A segment that is exactly `**` matches one or more whole segments. |
| 11 | +// Every other character is literal, regex metacharacters included. |
| 12 | +// An absent or empty `include` means everything; `exclude` is applied after it. |
| 13 | +// Matching is case-sensitive. |
| 14 | +using System.Text; |
| 15 | +using System.Text.RegularExpressions; |
| 16 | + |
| 17 | +namespace MetaObjects.Config; |
| 18 | + |
| 19 | +/// <summary>A compiled scope. Every pattern is already anchored to the whole name.</summary> |
| 20 | +public sealed record CompiledScope(IReadOnlyList<Regex> Include, IReadOnlyList<Regex> Exclude); |
| 21 | + |
| 22 | +public static class Scope |
| 23 | +{ |
| 24 | + /// <summary>One package segment: any run of characters containing no separator char.</summary> |
| 25 | + private const string Segment = "[^:]+"; |
| 26 | + |
| 27 | + /// <summary>One or more segments, separator-joined — the <c>**</c> expansion.</summary> |
| 28 | + private const string Segments = Segment + "(?:" + PACKAGE_SEPARATOR + Segment + ")*"; |
| 29 | + |
| 30 | + /// <summary>Regex metacharacters escaped for literal matching; mirrors the reference's class.</summary> |
| 31 | + private const string Metacharacters = ".*+?^${}()|[]\\"; |
| 32 | + |
| 33 | + private static string EscapeLiteral(string text) |
| 34 | + { |
| 35 | + var sb = new StringBuilder(text.Length); |
| 36 | + foreach (var c in text) |
| 37 | + { |
| 38 | + if (Metacharacters.Contains(c)) sb.Append('\\'); |
| 39 | + sb.Append(c); |
| 40 | + } |
| 41 | + return sb.ToString(); |
| 42 | + } |
| 43 | + |
| 44 | + private static MetaModelException Invalid(string message) => |
| 45 | + new(message, ErrorCode.ERR_SCOPE_PATTERN_INVALID); |
| 46 | + |
| 47 | + /// <summary>Compile one segment. <c>**</c> spans segments; <c>*</c> never crosses a separator.</summary> |
| 48 | + private static string CompileSegment(string segment, string pattern) |
| 49 | + { |
| 50 | + if (segment.Length == 0) |
| 51 | + throw Invalid($"empty segment in scope pattern \"{pattern}\""); |
| 52 | + // A segment surviving the split on "::" can still hold a lone ":" when the pattern |
| 53 | + // has an odd colon run ("acme:::Order" splits to "acme", ":Order"). Treating it as |
| 54 | + // literal would compile a pattern no legal name can match, so it is refused loudly |
| 55 | + // instead of silently matching nothing. |
| 56 | + if (segment.Contains(':')) |
| 57 | + throw Invalid( |
| 58 | + $"scope pattern \"{pattern}\" has a malformed separator (an odd run of \":\")" + |
| 59 | + " — segments are joined by \"::\", never a single \":\""); |
| 60 | + if (segment == "**") return $"(?:{Segments})"; |
| 61 | + // `*` inside a segment matches any characters except the separator char. |
| 62 | + return string.Join("[^:]*", segment.Split('*').Select(EscapeLiteral)); |
| 63 | + } |
| 64 | + |
| 65 | + public static Regex CompilePattern(string pattern) |
| 66 | + { |
| 67 | + if (pattern.Length == 0) throw Invalid("scope pattern must not be empty"); |
| 68 | + var body = string.Join( |
| 69 | + PACKAGE_SEPARATOR, |
| 70 | + pattern.Split(PACKAGE_SEPARATOR).Select(s => CompileSegment(s, pattern))); |
| 71 | + // \A ... \z, not ^ ... $: .NET's `$` also matches before a trailing newline, which |
| 72 | + // the reference's anchored regex never does. |
| 73 | + return new Regex($@"\A{body}\z", RegexOptions.CultureInvariant); |
| 74 | + } |
| 75 | + |
| 76 | + /// <summary>Absent (<c>null</c>) or empty <c>include</c> means "everything".</summary> |
| 77 | + public static CompiledScope CompileScope( |
| 78 | + IEnumerable<string>? include = null, IEnumerable<string>? exclude = null) => |
| 79 | + new( |
| 80 | + (include ?? []).Select(CompilePattern).ToList(), |
| 81 | + (exclude ?? []).Select(CompilePattern).ToList()); |
| 82 | + |
| 83 | + /// <summary>True when <paramref name="fqn"/> is inside the scope. An empty <c>include</c> means everything.</summary> |
| 84 | + public static bool MatchesScope(string fqn, CompiledScope compiled) |
| 85 | + { |
| 86 | + var included = compiled.Include.Count == 0 || compiled.Include.Any(re => re.IsMatch(fqn)); |
| 87 | + if (!included) return false; |
| 88 | + return !compiled.Exclude.Any(re => re.IsMatch(fqn)); |
| 89 | + } |
| 90 | +} |
0 commit comments