diff --git a/src/NATS.Server/Subscriptions/SubjectMatch.cs b/src/NATS.Server/Subscriptions/SubjectMatch.cs
index 9bb8153..55310f2 100644
--- a/src/NATS.Server/Subscriptions/SubjectMatch.cs
+++ b/src/NATS.Server/Subscriptions/SubjectMatch.cs
@@ -113,4 +113,112 @@ public static class SubjectMatch
return li >= literal.Length; // both exhausted
}
+
+ /// Count dot-delimited tokens. Empty string returns 0.
+ public static int NumTokens(string subject)
+ {
+ if (string.IsNullOrEmpty(subject))
+ return 0;
+ int count = 1;
+ for (int i = 0; i < subject.Length; i++)
+ {
+ if (subject[i] == Sep)
+ count++;
+ }
+ return count;
+ }
+
+ /// Return the 0-based nth token as a span. Returns empty if out of range.
+ public static ReadOnlySpan TokenAt(string subject, int index)
+ {
+ if (string.IsNullOrEmpty(subject))
+ return default;
+
+ var span = subject.AsSpan();
+ int current = 0;
+ int start = 0;
+ for (int i = 0; i < span.Length; i++)
+ {
+ if (span[i] == Sep)
+ {
+ if (current == index)
+ return span[start..i];
+ start = i + 1;
+ current++;
+ }
+ }
+ if (current == index)
+ return span[start..];
+ return default;
+ }
+
+ ///
+ /// Determines if two subject patterns (possibly containing wildcards) can both
+ /// match the same literal subject. Reference: Go sublist.go SubjectsCollide.
+ ///
+ public static bool SubjectsCollide(string subj1, string subj2)
+ {
+ if (subj1 == subj2)
+ return true;
+
+ bool lit1 = IsLiteral(subj1);
+ bool lit2 = IsLiteral(subj2);
+
+ if (lit1 && lit2)
+ return false;
+
+ if (lit1 && !lit2)
+ return MatchLiteral(subj1, subj2);
+ if (lit2 && !lit1)
+ return MatchLiteral(subj2, subj1);
+
+ // Both have wildcards
+ int n1 = NumTokens(subj1);
+ int n2 = NumTokens(subj2);
+ bool hasFwc1 = subj1.Contains('>');
+ bool hasFwc2 = subj2.Contains('>');
+
+ if (!hasFwc1 && !hasFwc2 && n1 != n2)
+ return false;
+ if (n1 < n2 && !hasFwc1)
+ return false;
+ if (n2 < n1 && !hasFwc2)
+ return false;
+
+ int stop = Math.Min(n1, n2);
+ for (int i = 0; i < stop; i++)
+ {
+ var t1 = TokenAt(subj1, i);
+ var t2 = TokenAt(subj2, i);
+ if (!TokensCanMatch(t1, t2))
+ return false;
+ }
+ return true;
+ }
+
+ private static bool TokensCanMatch(ReadOnlySpan t1, ReadOnlySpan t2)
+ {
+ if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc))
+ return true;
+ if (t2.Length == 1 && (t2[0] == Pwc || t2[0] == Fwc))
+ return true;
+ return t1.SequenceEqual(t2);
+ }
+
+ ///
+ /// Validates subject. When checkRunes is true, also rejects null bytes.
+ ///
+ public static bool IsValidSubject(string subject, bool checkRunes)
+ {
+ if (!IsValidSubject(subject))
+ return false;
+ if (!checkRunes)
+ return true;
+ for (int i = 0; i < subject.Length; i++)
+ {
+ if (subject[i] == '\0')
+ return false;
+ }
+ return true;
+ }
}
diff --git a/tests/NATS.Server.Tests/SubjectMatchTests.cs b/tests/NATS.Server.Tests/SubjectMatchTests.cs
index a48994f..3c87352 100644
--- a/tests/NATS.Server.Tests/SubjectMatchTests.cs
+++ b/tests/NATS.Server.Tests/SubjectMatchTests.cs
@@ -54,4 +54,50 @@ public class SubjectMatchTests
{
SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
}
+
+ [Theory]
+ [InlineData("foo.bar.baz", 3)]
+ [InlineData("foo", 1)]
+ [InlineData("a.b.c.d.e", 5)]
+ [InlineData("", 0)]
+ public void NumTokens(string subject, int expected)
+ {
+ SubjectMatch.NumTokens(subject).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("foo.bar.baz", 0, "foo")]
+ [InlineData("foo.bar.baz", 1, "bar")]
+ [InlineData("foo.bar.baz", 2, "baz")]
+ [InlineData("foo", 0, "foo")]
+ [InlineData("foo.bar.baz", 5, "")]
+ public void TokenAt(string subject, int index, string expected)
+ {
+ SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("foo.bar", "foo.bar", true)]
+ [InlineData("foo.bar", "foo.baz", false)]
+ [InlineData("foo.*", "foo.bar", true)]
+ [InlineData("foo.*", "foo.>", true)]
+ [InlineData("foo.>", "foo.bar.baz", true)]
+ [InlineData(">", "foo.bar", true)]
+ [InlineData("foo.*", "bar.*", false)]
+ [InlineData("foo.*.baz", "foo.bar.*", true)]
+ [InlineData("*.bar", "foo.*", true)]
+ [InlineData("foo.*", "bar.>", false)]
+ public void SubjectsCollide(string subj1, string subj2, bool expected)
+ {
+ SubjectMatch.SubjectsCollide(subj1, subj2).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("foo\0bar", true, false)]
+ [InlineData("foo\0bar", false, true)]
+ [InlineData("foo.bar", true, true)]
+ public void IsValidSubject_checkRunes(string subject, bool checkRunes, bool expected)
+ {
+ SubjectMatch.IsValidSubject(subject, checkRunes).ShouldBe(expected);
+ }
}