diff --git a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs index 8a2b66ac7..b12e1864a 100644 --- a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs +++ b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs @@ -146,7 +146,6 @@ public PlantUmlFileBuilder WithDependenciesFrom( { _sliceList = slices.Distinct().ToList(); AssertDistinctNames(); - RemovePatternInappropriateSlices(); AssignCommonNamespaces(); if (generationOptions == null) @@ -340,7 +339,6 @@ public PlantUmlFileBuilder WithDependenciesFromFocusOn( package = package.Remove(package.Length - 1); } - RemovePatternInappropriateSlices(); AssignCommonNamespaces(); var focusedSlices = new HashSet( @@ -448,28 +446,6 @@ GenerationOptions generationOptions ); } - private void RemovePatternInappropriateSlices() - { - if (_sliceList.Any(slice => slice.CountOfAsteriskInPattern == null)) - { - return; - } - - for (var i = _sliceList.Count - 1; i >= 0; i--) - { - var dots = 0; - - // The description is never qualified, so its dots are those below the namespace - // the pattern matched before its capture group. - dots += _sliceList[i].Description.Count(c => c == '.'); - - if (dots >= _sliceList[i].CountOfAsteriskInPattern) - { - _sliceList.RemoveAt(i); - } - } - } - private void RemoveDuplicatedArrowsIfExist() { for (var i = _dependencies.Count - 1; i >= 0; i--) diff --git a/ArchUnitNET/Domain/Slice.cs b/ArchUnitNET/Domain/Slice.cs index 0c1bb482b..9db857c3e 100644 --- a/ArchUnitNET/Domain/Slice.cs +++ b/ArchUnitNET/Domain/Slice.cs @@ -26,8 +26,6 @@ public Slice(SliceIdentifier identifier, IEnumerable types) public string Description => Identifier.Description; - public int? CountOfAsteriskInPattern => Identifier.CountOfAsteriskInPattern; - /// /// Whether diagrams nest this slice in packages even if /// is not set, which diff --git a/ArchUnitNET/Domain/SliceIdentifier.cs b/ArchUnitNET/Domain/SliceIdentifier.cs index 375e69aaf..ce2f2b1ed 100644 --- a/ArchUnitNET/Domain/SliceIdentifier.cs +++ b/ArchUnitNET/Domain/SliceIdentifier.cs @@ -7,28 +7,32 @@ namespace ArchUnitNET.Domain /// /// Identifies a slice by its parts: for a slice pattern, the values its capture groups /// captured, in group order. Two identifiers are the same slice exactly when their parts are - /// equal and both are ignored or both are not. + /// equal and both are ignored or both are not. See + /// . /// public class SliceIdentifier : IHasDescription { + /// + /// Groups identifiers by slice membership; see . + /// public static readonly SliceIdentifierComparer Comparer = new SliceIdentifierComparer(); + + /// + /// Whether the type that produced this identifier failed to match the pattern and is + /// therefore left out of the rule. + /// public readonly bool Ignored; - private SliceIdentifier( - IReadOnlyList parts, - bool ignored, - int? countOfAsteriskInPattern = null - ) + private SliceIdentifier(IReadOnlyList parts, bool ignored) { Parts = parts; Ignored = ignored; - CountOfAsteriskInPattern = countOfAsteriskInPattern; } /// /// What identifies the slice: for a slice pattern, the values its capture groups - /// captured, in group order. "App.(**)" over "App.Orders.Web" gives - /// [Orders.Web]. + /// captured, in group order. "App.(*).(*)" over "App.Orders.Http" gives + /// [Orders, Http], and so does "App.(*)..(*)" over "App.Orders.Web.Http". /// public IReadOnlyList Parts { get; } @@ -39,8 +43,6 @@ private SliceIdentifier( /// public string Description => "Slice " + string.Join(" - ", Parts); - public readonly int? CountOfAsteriskInPattern; - /// /// Creates an identifier for a slice with a single part, the same slice as /// Of(new[] { part }). @@ -49,12 +51,9 @@ private SliceIdentifier( /// The slice's only part: a custom SliceAssignment that returns identifiers with the /// same part for two types puts them into the same slice. /// - /// - /// The number of "(*)" groups in the pattern, if the slice comes from one. - /// - public static SliceIdentifier Of(string part, int? countOfAsteriskInPattern = null) + public static SliceIdentifier Of(string part) { - return new SliceIdentifier(CheckParts(new[] { part }), false, countOfAsteriskInPattern); + return Of(new[] { part }); } /// @@ -68,6 +67,10 @@ public static SliceIdentifier Of(IEnumerable parts) return new SliceIdentifier(CheckParts(parts), false); } + /// + /// Creates the identifier for a type that did not match the pattern. All such types + /// share one slice, which the rule then leaves out. + /// public static SliceIdentifier Ignore() { return new SliceIdentifier(new[] { "Ignored" }, true); diff --git a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs index 00b67c11f..c7201eb18 100644 --- a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs +++ b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs @@ -1,24 +1,62 @@ -using System; +// Adapted from https://github.com/TNG/ArchUnit/blob/main/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using ArchUnitNET.Domain; namespace ArchUnitNET.Fluent.Slices { + /// + /// Assigns types to slices by matching their namespaces against a pattern. + /// + /// + /// A pattern is a namespace with these tokens in it: + /// + /// (*)captures exactly one segment + /// (**)captures one or more segments + /// (A|B)captures one of the alternatives + /// *matches one segment without capturing it + /// ..matches zero or more segments + /// [A|B]matches one of the alternatives + /// + /// + /// A pattern must contain at least one capture group, may not mix (*) and (**), and may + /// use (**) at most once. Types whose namespace does not match are left out of the rule. + /// + /// + /// A slice is identified by what the capture groups captured, in group order + /// (): types that captured the same values share a + /// slice. "App.(*).(*)", "App.(*).Service.(*)" and "App.(*)..(*)" all put the types they + /// match in "Orders" and "Http" into the slice [Orders, Http], while "App.(**)" + /// over "App.Orders.Http" captures the single value [Orders.Http]. + /// + /// + /// The slice is named after its parts, e.g. "Slice Orders - Http"; see + /// . + /// + /// public class SliceRuleInitializer { private readonly SliceRuleCreator _ruleCreator; + /// + /// Initializes a new instance with the given rule creator. + /// + /// The rule creator that accumulates the rule definition. public SliceRuleInitializer(SliceRuleCreator ruleCreator) { _ruleCreator = ruleCreator; } /// + /// Groups types into slices by what the pattern captured. /// /// - /// check https://www.archunit.org/userguide/html/000_Index.html#_slices for examples for pattern - /// usage + /// The namespace pattern; see the remarks on . /// - /// + /// The pattern is malformed. public GivenSlices Matching(string pattern) { return Matching(pattern, false); @@ -30,6 +68,7 @@ public GivenSlices Matching(string pattern) /// . /// /// The namespace pattern, as for . + /// The pattern is malformed. [Obsolete( "Use Matching(pattern) and draw the slices with " + "new GenerationOptions { NestSlicesInPackages = true }. MatchingWithPackages " @@ -42,13 +81,10 @@ public GivenSlices MatchingWithPackages(string pattern) private GivenSlices Matching(string pattern, bool nestByDefault) { + var regex = ConvertPatternToRegex(pattern); _ruleCreator.SetSliceAssignment( new SliceAssignment( - t => - { - var parseResult = Parse(pattern); - return AssignFunc(t, parseResult.pattern, parseResult.asterisk); - }, + type => AssignFunc(type, regex), "matching \"" + pattern + "\"", nestByDefault ) @@ -56,118 +92,191 @@ private GivenSlices Matching(string pattern, bool nestByDefault) return new GivenSlices(_ruleCreator); } - private static (string pattern, int? asterisk) Parse(string pattern) + private const string TwoStarCaptureLiteral = "(**)"; + private const string TwoStarRegexMarker = "#%#%#"; + + /// Captures one or more namespace segments, separated by dots. + private const string TwoStarCaptureRegex = @"(\w+(?:\.\w+)*)"; + + /// Captures exactly one namespace segment. + private const string SingleStarCaptureRegex = @"(\w+)"; + + /// Matches an escaped ".." in an already escaped pattern. + private static readonly Regex EscapedTwoDots = new Regex( + @"\\\.\\\.", + RegexOptions.Compiled + ); + + internal static Regex ConvertPatternToRegex(string pattern) { - var indexOfAsteriskInPattern = pattern.IndexOf("(*", StringComparison.Ordinal); - var containsSingleAsterisk = pattern.Contains("(*)"); - var containsDoubleAsterisk = pattern.Contains("(**)"); + AssertPatternIsValid(pattern); + var escaped = Regex + .Replace(pattern, @"\[(.*?)]", "(?:$1)") + .Replace(TwoStarCaptureLiteral, TwoStarRegexMarker) + .Replace("(*)", SingleStarCaptureRegex) + .Replace("*", @"\w+") + .Replace(".", @"\."); + var result = EscapedTwoDots + .Replace(escaped, match => TwoDotsRegex(match, escaped)) + .Replace(TwoStarRegexMarker, TwoStarCaptureRegex); + return new Regex($"^{result}$", RegexOptions.Compiled); + } - if (!containsSingleAsterisk && !containsDoubleAsterisk) + /// + /// Expands one ".." into a regex matching zero or more whole namespace segments, + /// together with the dot that separates them from what surrounds the "..". + /// + /// + /// Consuming the boundary dot unconditionally is what keeps ".." on segment boundaries. + /// An expression that is optional as a whole and brings its own dots also matches the + /// empty string in the middle of a segment: "App.(*)..(*)" then splits "App.Slice3" into + /// "Slice" and "3", and "App.(*)..Service" matches "App.MyService" as if "MyService" + /// were two segments. + /// + private static string TwoDotsRegex(Match match, string escapedPattern) + { + if (match.Index == 0) { - throw new ArgumentException("Patterns for Slices have to contain (*) or (**)."); + return @"(?:\w+\.)*"; } - if (containsDoubleAsterisk && containsSingleAsterisk) + return match.Index + match.Length == escapedPattern.Length + ? @"(?:\.\w+)*" + : @"\.(?:\w+\.)*"; + } + + private static readonly Regex IllegalAlternation = new Regex( + @"\[[^|]*\]", + RegexOptions.Compiled + ); + + private static readonly Regex IllegalNestedGroup = new Regex( + @"\([^)]*\(|\([^)]*\[|\[[^\]]*\(|\[[^\]]*\[", + RegexOptions.Compiled + ); + + /// + /// Every "(...)" in the pattern is a capture group: "(*)", "(**)" and an alternation such + /// as "(A|B)". Nesting is rejected up front, so no group contains another. + /// + private static readonly Regex CaptureGroup = new Regex( + @"\([^()]*\)", + RegexOptions.Compiled + ); + + private static void AssertPatternIsValid(string pattern) + { + if (pattern.Contains("...")) { - throw new ArgumentException("Patterns for Slices can't contain both (*) and (**)."); + throw new ArgumentException( + "Pattern may not contain more than two '.' in a row", + nameof(pattern) + ); } - if ( - pattern.IndexOf("(**", StringComparison.Ordinal) - != pattern.LastIndexOf("(**", StringComparison.Ordinal) - ) + if (pattern.Replace("(**)", "").Contains("**")) { - throw new ArgumentException("Patterns for Slices can contain (**) only once."); + throw new ArgumentException( + "Pattern may not contain more than one '*' in a row", + nameof(pattern) + ); } - if (containsDoubleAsterisk) + if (pattern.Contains("(..)")) { - return (pattern, null); + throw new ArgumentException( + "Pattern does not support capturing via (..), use (**) instead", + nameof(pattern) + ); } - var countOfSingleAsterisk = - pattern.Split(new[] { "(*)" }, StringSplitOptions.None).Length - 1; - pattern = pattern.Remove(indexOfAsteriskInPattern) + "(**)."; - return (pattern, countOfSingleAsterisk); - } - - private static SliceIdentifier AssignFunc( - IType type, - string pattern, - int? countOfSingleAsterisk - ) - { - var indexOfAsteriskInPattern = pattern.IndexOf("(*", StringComparison.Ordinal); - - var namespc = type.Namespace.FullName; - var slicePrefix = pattern.Remove(indexOfAsteriskInPattern); - var slicePostfix = pattern.Substring( - pattern.IndexOf("*)", StringComparison.Ordinal) + 2 - ); - - if (slicePrefix.StartsWith(".")) + if (IllegalAlternation.IsMatch(pattern)) { - slicePrefix = slicePrefix.Substring(1); - if (!namespc.Contains(slicePrefix)) - { - return SliceIdentifier.Ignore(); - } + throw new ArgumentException( + "Pattern does not allow alternation brackets '[]' without specifying any alternative via '|' inside", + nameof(pattern) + ); } - else if (!namespc.StartsWith(slicePrefix)) + + if (ContainsToplevelAlternation(pattern)) { - return SliceIdentifier.Ignore(); + throw new ArgumentException( + "Pattern only supports '|' inside of '[]' or '()'", + nameof(pattern) + ); } - if (slicePostfix.EndsWith(".")) + if (IllegalNestedGroup.IsMatch(pattern)) { - slicePostfix = slicePostfix.Remove(slicePostfix.Length - 1); - if ( - !namespc - .Substring( - namespc.IndexOf(slicePrefix, StringComparison.Ordinal) - + slicePrefix.Length - ) - .Contains(slicePostfix) - ) - { - return SliceIdentifier.Ignore(); - } + throw new ArgumentException( + "Namespace identifier does not support nesting '()' or '[]' within other '()' or '[]'", + nameof(pattern) + ); } - else if (!namespc.EndsWith(slicePostfix)) + + if (pattern.Contains("(*)") && pattern.Contains("(**)")) { - return SliceIdentifier.Ignore(); + throw new ArgumentException("Patterns for Slices can't contain both (*) and (**)."); } - var sliceString = namespc; + if ( + pattern.IndexOf("(**)", StringComparison.Ordinal) + != pattern.LastIndexOf("(**)", StringComparison.Ordinal) + ) + { + throw new ArgumentException("Patterns for Slices can contain (**) only once."); + } - if (slicePrefix != "") + // Checked last on purpose: a pattern that is malformed *and* has no capture group + // should report the specific problem rather than this catch-all. + if (!CaptureGroup.IsMatch(pattern)) { - sliceString = namespc - .Substring( - namespc.IndexOf(slicePrefix, StringComparison.Ordinal) + slicePrefix.Length - ) - .TrimStart('.'); + throw new ArgumentException("Patterns for Slices have to contain (*) or (**)."); } + } - if (!sliceString.Contains(slicePostfix)) + private static bool ContainsToplevelAlternation(string pattern) + { + var depth = 0; + foreach (var c in pattern) { - throw new ArgumentException( - "\"" - + type.FullName - + "\" is not clearly assignable to a slice with the pattern: \"" - + pattern - + "\"" - ); + switch (c) + { + case '(': + case '[': + depth++; + break; + case ')': + case ']': + depth--; + break; + case '|' when depth == 0: + return true; + } } - if (slicePostfix != "") + return false; + } + + private static SliceIdentifier AssignFunc(IType type, Regex regex) + { + var namespc = type.Namespace.FullName; + var match = regex.Match(namespc); + if (!match.Success) { - sliceString = sliceString.Remove( - sliceString.IndexOf(slicePostfix, StringComparison.Ordinal) - ); + return SliceIdentifier.Ignore(); } - return SliceIdentifier.Of(sliceString, countOfSingleAsterisk); + return SliceIdentifier.Of(Captures(match)); + } + + /// + /// What the pattern's capture groups captured, in group order. + /// + internal static IReadOnlyList Captures(Match match) + { + // Group 0 is the whole match; the rest are the pattern's capture groups in order. + return match.Groups.Cast().Skip(1).Select(group => group.Value).ToList(); } } } diff --git a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json index 93b5d4c34..77b8bbcb3 100644 --- a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json +++ b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json @@ -29,6 +29,12 @@ "Slice Slice1.Service" ] }, + { + "ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(*).(*)\" should not depend on each other", + "Violations": [ + "Slice Slice1 - Service" + ] + }, { "ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(*)..\" should not depend on each other", "Violations": [] diff --git a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml index 785f1107e..e7260ef90 100644 --- a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml +++ b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml @@ -19,4 +19,7 @@ Slice Slice1 Slice Slice1.Service + + Slice Slice1 - Service + \ No newline at end of file diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs index d88b9e718..dbc341b24 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs @@ -26,23 +26,10 @@ public class PlantUmlSliceDiagramTests private static GenerationOptions Nested => new GenerationOptions { NestSlicesInPackages = true }; - // --- The asterisk count decides which slices the exporter renders --------------- + // --- Each pattern shape groups MultipleSubnamespaces differently ---------------- // - // Every pattern below matches the same nine slices -- SlicesTests.MatchingTest and - // Matching_SingleAsterisk_CapturesEveryDepth assert that. What differs is how many - // of them survive PlantUmlFileBuilder.RemovePatternInappropriateSlices, which drops - // a slice once its depth (dots in its Description) reaches - // Slice.CountOfAsteriskInPattern: - // - // (*) count 1 -> 3 of 9 slices rendered - // (*).(*) count 2 -> 7 of 9 slices rendered - // (**) count null -> 9 of 9 slices rendered - // - // CountOfAsteriskInPattern has no other consumer in the library: the slice domain - // carries it solely so the exporter can perform that deletion. So these snapshots - // are the only record that asking for "(*)" hands the diagram two-thirds fewer - // nodes than GetObjects returned, and a reimplementation is free to drop the field - // from SliceIdentifier only if it also changes what these files say. + // SlicesTests pins which slices each pattern produces; these snapshots pin how the + // exporter draws them. [Fact] public Task BuildUmlBySlices_SingleAsterisk() @@ -56,17 +43,6 @@ public Task BuildUmlBySlices_DoubleAsterisk() return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); } - // The matcher discards everything after the first "(*", so "(*).(*)" and "(*)..(*)" - // are indistinguishable to it and the next two snapshots are byte-identical -- as are - // their nested counterparts below. That identity is the point: it is - // what a reimplementation giving the second capture group any meaning would have to - // change. SlicesTests.DotDot_* cover the shapes that would tell the two patterns - // apart. - // - // Identical snapshots do not make these tests redundant with "(*)", though. The - // second group is dead to the matcher but still raises CountOfAsteriskInPattern to - // 2, which moves the exporter's cutoff -- hence seven nodes here against three for - // "(*)". [Fact] public Task BuildUmlBySlices_MultipleCaptureGroups() { @@ -103,6 +79,36 @@ public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups() return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), Nested); } + [Fact] + public Task BuildUmlBySlicesNested_LiteralBetweenCaptureGroups() + { + // Only the types decide where a slice sits: "Slice Slice3 - Inner" sits at + // Slice3.Group1.Inner here, but at Slice3 for "(*).*.(*)" below. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*).Group1.(*)"), + Nested + ); + } + + [Fact] + public Task BuildUmlBySlicesNested_WildcardBetweenCaptureGroups() + { + // "Slice Slice3 - Inner" holds the types of both Group1.Inner and Group2.Inner, so it + // sits at Slice3, the namespace they share. + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*).*.(*)"), Nested); + } + + [Fact] + public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies() + { + // No name has a dot, but "Slice Slice3 - Inner" sits at depth 3 and the others at + // depth 4, so only "Slice Slice1 - Service" -> "Slice Slice2 - Service" stays. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), + new GenerationOptions { NestSlicesInPackages = true, LimitDependencies = true } + ); + } + [Fact] public Task BuildUmlBySlicesNested_CaptureGroupAtStart() { @@ -140,6 +146,15 @@ public Task BuildUmlBySlices_C4Style() ); } + [Fact] + public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style() + { + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), + new GenerationOptions { NestSlicesInPackages = true, C4Style = true } + ); + } + [Fact] public Task BuildUmlBySlicesFocusOn() { @@ -227,14 +242,25 @@ public Task BuildUmlBySlices_LimitDependencies_Compact() return VerifyUml(uml); } + [Fact] + public Task BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes() + { + // Group1, Service and Slice3 have no dots, but lie at depths 4, 2 and 3, so of the + // arrows between them only Slice1 -> Slice2 -> Slice3 -> Slice1 stays. + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + ".(*)")); + var uml = new PlantUmlFileBuilder() + .WithDependenciesFrom(slices, new GenerationOptions { LimitDependencies = true }) + .AsString(); + return VerifyUml(uml); + } + // --- IncludeNodesWithoutDependencies = false removes dependency-less slices ---- [Fact] public Task BuildUmlBySlices_ExcludeNodesWithoutDependencies() { - // A single-asterisk pattern would already collapse away the dependency-less slices - // via RemovePatternInappropriateSlices, so use "(**)" to keep them in the slice list - // and let IncludeNodesWithoutDependencies do the removal instead. + // "(**)" keeps every namespace as a slice of its own, including ones without any + // dependency, so there is something for IncludeNodesWithoutDependencies to remove. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFrom( @@ -250,8 +276,8 @@ public Task BuildUmlBySlices_ExcludeNodesWithoutDependencies() [Fact] public Task BuildUmlBySlices_WithDependencyFilter() { - // "(*)" would already collapse Slice3.Group1 away via RemovePatternInappropriateSlices, - // leaving no dependency for the filter to remove, so use "(**)" instead. + // "(**)" gives Slice3.Group1 a slice of its own, so there is a dependency for the + // filter to remove. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFrom( @@ -354,8 +380,8 @@ public void BuildUmlBySlicesFocusOn_TrailingDotPackage_IsTrimmed() [Fact] public Task BuildUmlBySlicesFocusOn_SingleAsteriskPattern() { - // RemovePatternInappropriateSlices leaves the three slices "(*)" stands for, of - // which "Slice3" lies in the namespace focused on. + // "(*)" captures a single segment, so the slices are Slice1 to Slice3, of which + // "Slice3" lies in the namespace focused on. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(*)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFromFocusOn(slices, Root + "Slice3", Nested) @@ -363,6 +389,19 @@ public Task BuildUmlBySlicesFocusOn_SingleAsteriskPattern() return VerifyUml(uml); } + [Fact] + public Task BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes() + { + // Slice3, Group1, Group2 and Inner lie in Slice3, although their names don't say so. + // Group2 and Inner have no arrows and are left out like any such slice; Service, + // Slice1 and Slice2 are drawn as neighbours. + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + ".(*)")); + var uml = new PlantUmlFileBuilder() + .WithDependenciesFromFocusOn(slices, Root + "Slice3", Nested) + .AsString(); + return VerifyUml(uml); + } + [Fact] public void BuildUmlBySlicesFocusOn_SiblingWithSamePrefix_IsNotFocused() { @@ -418,6 +457,17 @@ public void BuildUmlBySlicesFocusOn_FocusesTheSameSlicesFlatAndNested() // A nested slice sits in the package of the deepest namespace that contains all of its // types. The packages come from those namespaces, not from the pattern. + [Fact] + public Task BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup() + { + // The alternation captures Slice1 or Slice2, and the ".." folds their Service + // namespaces into them, so there are two slices and no Slice3. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(Slice1|Slice2).."), + Nested + ); + } + [Fact] public Task BuildUmlBySlicesNested_DuplicatePrefixSegment() { @@ -501,6 +551,31 @@ public void BuildUmlBySlicesNested_SliceWithoutCommonNamespace_IsDrawnOnItsOwn() ); } + [Fact] + public Task BuildUmlBySlicesNested_WildcardBeforeFirstGroup() + { + // "*" matches Slice1 for one Service namespace and Slice2 for the other, but both + // capture "Service", so slice "Service" has types in Slice1.Service and + // Slice2.Service, and it sits in MultipleSubnamespaces. To keep the two Service + // namespaces apart, capture the segment instead: "(*).(*)" + // (BuildUmlBySlicesNested_MultipleCaptureGroups). + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "*.(*)"), Nested); + } + + [Fact] + public Task BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes() + { + // The ".." lets "(*)" capture the last segment after any prefix, so slices are named + // after it alone. Service sits at MultipleSubnamespaces, as its types are in + // Slice1.Service and Slice2.Service, and Inner at Slice3. + return VerifySlices( + SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces..(*)"), + Nested + ); + } + // --- One set of slices, drawn flat and nested ------------------------------------ [Fact] diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..110ac56d0 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,27 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 #99ffd1 + } + [Slice Slice3] as Slice_Slice3 #99ffd1 + } + [Slice Service] as Slice_Service + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Group1] --|> [Slice_Slice3] +[Slice_Service] --|> [Slice_Slice3] +[Slice_Slice2] --|> [Slice_Slice3] +[Slice_Slice3] --|> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt new file mode 100644 index 000000000..a03244e56 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt @@ -0,0 +1,11 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Inner" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Inner { + [Slice Slice3 - Inner] as Slice_Slice3___Inner +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt index 9e1b7f74b..1aa7241db 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt @@ -5,33 +5,21 @@ HIDE_STEREOTYPE() package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { - package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { - [Slice Slice1] as Slice_Slice1 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { - [Slice Slice1.Service] as Slice_Slice1.Service - } + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service } - package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { - [Slice Slice2] as Slice_Slice2 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { - [Slice Slice2.Service] as Slice_Slice2.Service - } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service } package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { - [Slice Slice3] as Slice_Slice3 package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { - [Slice Slice3.Group1] as Slice_Slice3.Group1 + [Slice Slice3 - Group1] as Slice_Slice3___Group1 } package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { - [Slice Slice3.Group2] as Slice_Slice3.Group2 + [Slice Slice3 - Group2] as Slice_Slice3___Group2 } } } -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt index 9e1b7f74b..6223615c5 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt @@ -5,33 +5,22 @@ HIDE_STEREOTYPE() package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { - package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { - [Slice Slice1] as Slice_Slice1 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { - [Slice Slice1.Service] as Slice_Slice1.Service - } + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service } - package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { - [Slice Slice2] as Slice_Slice2 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { - [Slice Slice2.Service] as Slice_Slice2.Service - } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service } package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { - [Slice Slice3] as Slice_Slice3 package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { - [Slice Slice3.Group1] as Slice_Slice3.Group1 + [Slice Slice3 - Group1] as Slice_Slice3___Group1 } package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { - [Slice Slice3.Group2] as Slice_Slice3.Group2 + [Slice Slice3 - Group2] as Slice_Slice3___Group2 } + [Slice Slice3 - Inner] as Slice_Slice3___Inner } } -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt new file mode 100644 index 000000000..39eab6f7b --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt @@ -0,0 +1,26 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +Boundary(SlicesTestAssembly.MultipleSubnamespaces, SlicesTestAssembly.MultipleSubnamespaces) { + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service, Slice1.Service) { + Container(Slice_Slice1___Service, Slice Slice1 - Service) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service, Slice2.Service) { + Container(Slice_Slice2___Service, Slice Slice2 - Service) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3, Slice3) { + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1, Group1) { + Container(Slice_Slice3___Group1, Slice Slice3 - Group1) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2, Group2) { + Container(Slice_Slice3___Group2, Slice Slice3 - Group2) + } + Container(Slice_Slice3___Inner, Slice Slice3 - Inner) + } +} + +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt new file mode 100644 index 000000000..fa31c1ee3 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt @@ -0,0 +1,26 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service + } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service + } + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Slice3 - Group1] as Slice_Slice3___Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Slice3 - Group2] as Slice_Slice3___Group2 + } + [Slice Slice3 - Inner] as Slice_Slice3___Inner + } +} + +[Slice_Slice1___Service] --> [Slice_Slice2___Service] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt new file mode 100644 index 000000000..9628a2619 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt @@ -0,0 +1,17 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Slice1] --|> [Slice_Slice2] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..e73935b2e --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,32 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Group2] as Slice_Group2 + } + [Slice Inner] as Slice_Inner + [Slice Slice3] as Slice_Slice3 + } + [Slice Service] as Slice_Service + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Group1] --|> [Slice_Slice3] +[Slice_Service] --|> [Slice_Slice3] +[Slice_Slice1] --|> [Slice_Slice2] +[Slice_Slice2] --|> [Slice_Slice3] +[Slice_Slice3] --|> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt new file mode 100644 index 000000000..3b6968744 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt @@ -0,0 +1,19 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Group2] as Slice_Group2 + } + } + [Slice Service] as Slice_Service +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt new file mode 100644 index 000000000..6c69253f1 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt @@ -0,0 +1,11 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces.Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + [Slice Slice3 - Inner] as Slice_Slice3___Inner +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..0403c5fad --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,17 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +[Slice Group1] as Slice_Group1 +[Slice Group2] as Slice_Group2 +[Slice Inner] as Slice_Inner +[Slice Service] as Slice_Service +[Slice Slice1] as Slice_Slice1 +[Slice Slice2] as Slice_Slice2 +[Slice Slice3] as Slice_Slice3 +[Slice_Slice1] --> [Slice_Slice2] +[Slice_Slice2] --> [Slice_Slice3] +[Slice_Slice3] --> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt index 8e5c21f27..9dff3296d 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt @@ -4,17 +4,9 @@ HIDE_STEREOTYPE() -[Slice Slice1] as Slice_Slice1 -[Slice Slice1.Service] as Slice_Slice1.Service -[Slice Slice2] as Slice_Slice2 -[Slice Slice2.Service] as Slice_Slice2.Service -[Slice Slice3] as Slice_Slice3 -[Slice Slice3.Group1] as Slice_Slice3.Group1 -[Slice Slice3.Group2] as Slice_Slice3.Group2 -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice Slice1 - Service] as Slice_Slice1___Service +[Slice Slice2 - Service] as Slice_Slice2___Service +[Slice Slice3 - Group1] as Slice_Slice3___Group1 +[Slice Slice3 - Group2] as Slice_Slice3___Group2 +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt index 8e5c21f27..4edda8cd4 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt @@ -4,17 +4,10 @@ HIDE_STEREOTYPE() -[Slice Slice1] as Slice_Slice1 -[Slice Slice1.Service] as Slice_Slice1.Service -[Slice Slice2] as Slice_Slice2 -[Slice Slice2.Service] as Slice_Slice2.Service -[Slice Slice3] as Slice_Slice3 -[Slice Slice3.Group1] as Slice_Slice3.Group1 -[Slice Slice3.Group2] as Slice_Slice3.Group2 -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice Slice1 - Service] as Slice_Slice1___Service +[Slice Slice2 - Service] as Slice_Slice2___Service +[Slice Slice3 - Group1] as Slice_Slice3___Group1 +[Slice Slice3 - Group2] as Slice_Slice3___Group2 +[Slice Slice3 - Inner] as Slice_Slice3___Inner +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Fluent/FreezeTests.cs b/ArchUnitNETTests/Fluent/FreezeTests.cs index ff8e36c84..38561c55b 100644 --- a/ArchUnitNETTests/Fluent/FreezeTests.cs +++ b/ArchUnitNETTests/Fluent/FreezeTests.cs @@ -40,6 +40,14 @@ public class FreezeTests .Should() .NotDependOnEachOther(); + // Several capture groups, so the store keys its violations by slice names whose parts + // are joined with " - ". + private readonly IArchRule _frozenMultiGroupSliceRule = SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*).(*)") + .Should() + .NotDependOnEachOther(); + private readonly IArchRule _failingFrozenSliceRule = SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") @@ -52,6 +60,8 @@ public void PassFrozenRules() Freeze(_frozenRule).Check(Architecture); Freeze(_frozenRule2).Check(Architecture); Freeze(_frozenSliceRule).Check(StaticTestArchitectures.SlicesTestArchitecture); + Freeze(_frozenMultiGroupSliceRule) + .Check(StaticTestArchitectures.SlicesTestArchitecture); } [Fact] @@ -61,6 +71,8 @@ public void PassFrozenRulesUsingXmlViolationStore() Freeze(_frozenRule2, new XmlViolationStore()).Check(Architecture); Freeze(_frozenSliceRule, new XmlViolationStore()) .Check(StaticTestArchitectures.SlicesTestArchitecture); + Freeze(_frozenMultiGroupSliceRule, new XmlViolationStore()) + .Check(StaticTestArchitectures.SlicesTestArchitecture); } [Fact] diff --git a/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs index 7230472fc..83784c0bc 100644 --- a/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs @@ -30,6 +30,9 @@ public class MatchingWithPackagesTests Root + "(**)..", Root + "(*).(*)", Root + "(*)..(*)", + Root + "(*).*.(*)", + // A wildcard before the first group: the slices are grouped by the capture alone. + Root + "*.(*)", "..DuplicatePrefix.Sub.(**)", "(**).Service", "SlicesTestAssembly.SubnamespaceCircle.(**)", diff --git a/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs b/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs index bcc81a835..1b7258aab 100644 --- a/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs @@ -6,9 +6,8 @@ namespace ArchUnitNETTests.Fluent.Slices { /// - /// Pins which slice patterns are rejected and with what message. The patterns are - /// validated while the slices are enumerated rather than when the rule is defined, so - /// every case here has to consume the result before the exception surfaces. + /// Pins which slice patterns are rejected and with what message. Patterns are validated + /// when the rule is defined, so no architecture is needed to provoke the failure. /// public class PatternValidationTests { @@ -19,7 +18,7 @@ public class PatternValidationTests public void PatternWithoutCaptureGroupThrows(string pattern) { var ex = Assert.Throws(() => - SliceRuleDefinition.Slices().Matching(pattern).GetObjects(Architecture).ToList() + SliceRuleDefinition.Slices().Matching(pattern) ); Assert.Contains( "have to contain (*) or (**)", @@ -32,11 +31,7 @@ public void PatternWithoutCaptureGroupThrows(string pattern) public void PatternMixingSingleAndDoubleAsteriskThrows() { var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching("Foo.(*).(**)") - .GetObjects(Architecture) - .ToList() + SliceRuleDefinition.Slices().Matching("Foo.(*).(**)") ); Assert.Contains( "can't contain both (*) and (**)", @@ -49,11 +44,7 @@ public void PatternMixingSingleAndDoubleAsteriskThrows() public void PatternWithRepeatedDoubleAsteriskThrows() { var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching("Foo.(**).(**)") - .GetObjects(Architecture) - .ToList() + SliceRuleDefinition.Slices().Matching("Foo.(**).(**)") ); Assert.Contains( "can contain (**) only once", @@ -62,28 +53,19 @@ public void PatternWithRepeatedDoubleAsteriskThrows() ); } - // Prefix and postfix are matched independently against the full namespace - // string, without regard for segment boundaries. Crafting them so their required - // regions overlap makes the postfix check pass against the full namespace while the - // slice string computed after stripping the prefix no longer contains it -- this is - // the one real way to reach AssignFunc's "not clearly assignable" guard. - [Fact] - public void PatternWithOverlappingPrefixAndPostfix_NotClearlyAssignableThrows() + [Theory] + [InlineData("Foo...(*)", "more than two '.' in a row")] + [InlineData("Foo.**.(*)", "more than one '*' in a row")] + [InlineData("Foo.(..).Bar", "does not support capturing via (..)")] + [InlineData("Foo.[Bar].(*)", "without specifying any alternative via '|'")] + [InlineData("Foo.Bar|Baz.(*)", "only supports '|' inside of '[]' or '()'")] + [InlineData("Foo.((*)).Bar", "does not support nesting")] + public void MalformedPatternThrows(string pattern, string expectedMessage) { - const string prefix = "SlicesTestAssembly.DotDotSeman"; - const string postfix = "Assembly.DotDotSemantics.Single"; var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching(prefix + "(**)" + postfix) - .GetObjects(Architecture) - .ToList() - ); - Assert.Contains( - "is not clearly assignable", - ex.Message, - StringComparison.OrdinalIgnoreCase + SliceRuleDefinition.Slices().Matching(pattern) ); + Assert.Contains(expectedMessage, ex.Message, StringComparison.OrdinalIgnoreCase); } [Theory] @@ -92,6 +74,9 @@ public void PatternWithOverlappingPrefixAndPostfix_NotClearlyAssignableThrows() [InlineData("Foo.(**)..")] [InlineData("Foo.(*)..")] [InlineData("Foo.(*).(*)")] + [InlineData("Foo.[Bar|Baz].(*)")] + [InlineData("Foo.(Bar|Baz)")] + [InlineData("Foo.(Bar|Baz).(*)")] public void ValidPatternDoesNotThrow(string pattern) { SliceRuleDefinition.Slices().Matching(pattern).GetObjects(Architecture).ToList(); diff --git a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs index d7ae5f03c..e2c29b8c7 100644 --- a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs @@ -183,6 +183,29 @@ public void SliceIdentifier_PartsContainingTheSeparator_AreADifferentSlice() Assert.False(SliceIdentifier.Comparer.Equals(onePart, twoParts)); } + [Theory] + [InlineData("App.(*)", "App.Orders", "Slice Orders")] + [InlineData("App.(**)", "App.Orders.Web", "Slice Orders.Web")] + [InlineData("App.(*)..", "App.Orders.Web", "Slice Orders")] + [InlineData("App.(*).(*)", "App.Orders.Http", "Slice Orders - Http")] + [InlineData("App.(*).Service.(*)", "App.Orders.Service.Http", "Slice Orders - Http")] + [InlineData("App.(*)..(*)", "App.Orders.Web.Http", "Slice Orders - Http")] + [InlineData("App.(*).*.(*)", "App.Orders.Web.Http", "Slice Orders - Http")] + public void SliceIdentifier_Description_OfAMatch_IsSliceFollowedByTheCaptures( + string pattern, + string nameSpace, + string expected + ) + { + var match = SliceRuleInitializer.ConvertPatternToRegex(pattern).Match(nameSpace); + + Assert.True(match.Success); + Assert.Equal( + expected, + SliceIdentifier.Of(SliceRuleInitializer.Captures(match)).Description + ); + } + [Fact] public void SliceIdentifier_ToString_IsTheDescription() { @@ -208,21 +231,15 @@ public void ViolationKey_IsTheDescription() [Fact] public void ViolationKey_OfSeveralParts_IsTheDescription() { - // Patterns capture a single part, so a custom assignment builds several. - var creator = new SliceRuleCreator(); - creator.SetSliceAssignment( - new SliceAssignment( - type => SliceIdentifier.Of(new[] { "Custom", type.Name }), - "by name" - ) - ); - - var keys = new SlicesShould(creator) + var keys = SliceRuleDefinition + .Slices() + .Matching(Root + "(*).(*)") + .Should() .NotDependOnEachOther() .Evaluate(Architecture) .Select(result => result.EvaluatedObjectIdentifier.Identifier); - Assert.Contains("Slice Custom - Slice1Class", keys); + Assert.Contains("Slice Slice1 - Service", keys); } // --- SlicedBy ------------------------------------------------------------------ diff --git a/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs b/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs index b62f9bdd4..742c4d373 100644 --- a/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs @@ -38,10 +38,9 @@ public class SliceRuleCombinationTests private static SliceRule CyclicSliceRule => SliceRuleDefinition.Slices().Matching(Root + "(**)").Should().BeFreeOfCycles(); - // "(**).." on MultipleSubnamespaces is acyclic too, but only because it folds away the - // types that would form the cycle (see SlicesTests.SubnamespaceCycleDetection_ - // FoldedIntoParent_MissesCycle) -- a fixture that is genuinely acyclic keeps this test - // stable once that fold is reimplemented. + // MultipleSubnamespaces has a cycle under every grouping (see + // SlicesTests.CycleDetectionTest), so the acyclic operand comes from SubnamespaceCircle, + // whose "(**)" slices depend on each other without forming a cycle. private static SliceRule AcyclicSliceRule => SliceRuleDefinition .Slices() diff --git a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs index 67290d3ba..c650e3a44 100644 --- a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ArchUnitNET.Domain; using ArchUnitNET.Fluent.Slices; using ArchUnitNETTests.AssemblyTestHelper; using Xunit; @@ -19,11 +21,21 @@ public void CycleDetectionTest() .Should() .BeFreeOfCycles() .AssertOnlyViolations(helper); + // Slice3 depends back on Slice1, so the cycle survives every regrouping of the whole + // namespace -- including "(**)..", which used to skip the top-level classes and + // therefore missed the edge that closes the loop. SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") .Should() .BeFreeOfCycles() + .AssertOnlyViolations(helper); + // Restricting the slices to the "Service" sub-namespaces drops that edge again. + SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**).Service..") + .Should() + .BeFreeOfCycles() .AssertNoViolations(helper); } @@ -31,7 +43,7 @@ public void CycleDetectionTest() public void MatchingTest() { Assert.Equal( - 9, + 3, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)") @@ -47,7 +59,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 9, + 3, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") @@ -55,7 +67,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 3, + 9, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") @@ -63,7 +75,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 4, + 2, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.Slice3.(*)") @@ -106,7 +118,7 @@ public void NotDependOnEachOtherTest() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") .Should() .NotDependOnEachOther() - .AssertAnyViolations(helper); + .AssertOnlyViolations(helper); SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") @@ -133,14 +145,14 @@ public void DirectCycleDetectionTest() } } - // None of these patterns folds Slice2.Inner into Slice2, so Slice1 -> Slice2 and - // Slice2.Inner -> Slice1 stay dependencies between three distinct slices and there is - // genuinely no cycle to find. + // Neither of these patterns folds Slice2.Inner into Slice2 -- "(*)" ignores it altogether + // and "(**)" gives it a slice of its own -- so Slice1 -> Slice2 and Slice2.Inner -> Slice1 + // stay dependencies between distinct slices and there is genuinely no cycle to find. [Fact] public void SubnamespaceCycleDetectionTest() { var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); - foreach (var pattern in new[] { "(*)", "(*)..", "(**)" }) + foreach (var pattern in new[] { "(*)", "(**)" }) { SliceRuleDefinition .Slices() @@ -151,27 +163,54 @@ public void SubnamespaceCycleDetectionTest() } } - // See: https://github.com/TNG/ArchUnitNET/issues/208 -- "(**).." is the one pattern that - // folds Slice2.Inner into Slice2, which should surface the cycle - // Slice1 -> Slice2 -> (Slice2.Inner) -> Slice1. It currently does not: the fold drops the - // types sitting directly in the captured namespace (see - // Matching_DoubleAsteriskDotDot_DropsTypesDirectlyInCapturedNamespace), so Slice1Class - // never makes it into a slice and the cycle has no starting edge. Pinned as-is; this - // assertion is expected to flip once the matcher is reimplemented. + // See: https://github.com/TNG/ArchUnitNET/issues/208 -- "(*).." is the one pattern that + // folds Slice2.Inner into Slice2, which surfaces the cycle + // Slice1 -> Slice2 -> (Slice2.Inner) -> Slice1. [Fact] - public void SubnamespaceCycleDetection_FoldedIntoParent_MissesCycle() + public void SubnamespaceCycleDetection_FoldedIntoParent_DetectsCycle() { var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); SliceRuleDefinition .Slices() - .Matching("SlicesTestAssembly.SubnamespaceCircle.(**)..") + .Matching("SlicesTestAssembly.SubnamespaceCircle.(*)..") .Should() .BeFreeOfCycles() - .AssertNoViolations(helper); + .AssertOnlyViolations(helper); + } + + [Fact] + public void Matching_SingleAsterisk_CapturesExactlyOneSegment() + { + // A namespace deeper than the pattern does not match at all, so the types below + // "Slice1.Service" and friends are left out entirely. + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, + Descriptions(Root + "(*)") + ); + } + + [Fact] + public void Matching_SingleAsteriskDotDot_FoldsSubnamespacesIntoParent() + { + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, + Descriptions(Root + "(*)..") + ); + + // Same names as "(*)", but the trailing ".." pulls the deeper types in as well. + var slices = SliceRuleDefinition + .Slices() + .Matching(Root + "(*)..") + .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) + .ToList(); + Assert.Equal( + 5, + slices.Single(slice => slice.Description == "Slice Slice3").Types.Count() + ); } [Fact] - public void Matching_SingleAsterisk_CapturesEveryDepth() + public void Matching_DoubleAsterisk_CapturesEveryDepth() { Assert.Equal( new[] @@ -186,89 +225,100 @@ public void Matching_SingleAsterisk_CapturesEveryDepth() "Slice Slice3.Group2", "Slice Slice3.Group2.Inner", }, - Descriptions(Root + "(*)") + Descriptions(Root + "(**)") ); } [Fact] - public void Matching_SingleAsteriskDotDot_CapturesEveryDepth() + public void Matching_DoubleAsteriskDotDot_IsRedundant() { - Assert.Equal(Descriptions(Root + "(*)"), Descriptions(Root + "(*)..")); + // "(**)" is already greedy, so a trailing ".." has nothing left to skip. + Assert.Equal(Descriptions(Root + "(**)"), Descriptions(Root + "(**)..")); } [Fact] - public void Matching_DoubleAsteriskDotDot_FoldsSubnamespacesIntoParent() + public void Matching_TrailingLiteralAfterCapture_MatchesOnlyThatLiteral() { Assert.Equal( - new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, - Descriptions(Root + "(**)..") + new[] { "Slice Slice1", "Slice Slice2" }, + Descriptions(Root + "(**).Service..") ); } - /// - /// The descriptions above only say which slices exist; this pins what ends up inside - /// them, which is where "(**).." is at its most surprising. Folding keeps the types - /// from the sub-namespaces but drops the ones sitting directly in the captured - /// namespace, so Slice1Class/Slice2Class/Slice3Class are in no slice at all. That is - /// what makes the NotDependOnEachOther snapshots report Slice2 and Slice3 as - /// depending on nothing, and what hides the cycle in - /// . - /// [Fact] - public void Matching_DoubleAsteriskDotDot_DropsTypesDirectlyInCapturedNamespace() + public void Matching_Alternation_MatchesEitherAlternative() { - var types = SliceRuleDefinition - .Slices() - .Matching(Root + "(**)..") - .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) - .ToDictionary( - slice => slice.Description, - slice => - slice - .Types.Select(type => type.FullName) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray() - ); - - Assert.Equal(new[] { Root + "Slice1.Service.Service1Class" }, types["Slice Slice1"]); - Assert.Equal(new[] { Root + "Slice2.Service.Service2Class" }, types["Slice Slice2"]); Assert.Equal( new[] { - Root + "Slice3.Group1.Group1Class", - Root + "Slice3.Group1.Inner.Inner1Class", - Root + "Slice3.Group2.Group2Class", - Root + "Slice3.Group2.Inner.Inner2Class", + "Slice Slice1", + "Slice Slice2", + "Slice Slice3.Group1", + "Slice Slice3.Group2", }, - types["Slice Slice3"] + Descriptions(Root + "(**).[Service|Inner]") ); } + // "(A|B)" is a capture group like "(*)": the alternative that matched is part of the + // slice's identity and its name, whereas "[A|B]" only selects. [Fact] - public void Matching_TrailingLiteralAfterCapture_MatchesNothing() + public void Matching_ParenthesisedAlternation_IsACaptureGroup() { - Assert.Empty(Descriptions(Root + "(**).Service..")); + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2" }, + Descriptions(Root + "(Slice1|Slice2)..") + ); + Assert.Equal( + new[] { "Slice Slice1 - Service", "Slice Slice2 - Service" }, + Descriptions(Root + "(Slice1|Slice2).(*)") + ); + Assert.Equal( + new[] + { + "Slice Slice1 - Service", + "Slice Slice2 - Service", + "Slice Slice3 - Group1", + }, + Descriptions(Root + "(*).(Service|Group1)") + ); } [Fact] - public void Matching_Alternation_MatchesNothing() + public void Matching_WildcardBeforeFirstGroup_GroupsByTheCaptureAlone() { - Assert.Empty(Descriptions(Root + "(**).[Service|Inner]")); + // "*" matches Slice1 for one Service namespace and Slice2 for the other; only the + // capture identifies a slice, so both are one slice. + var slices = Slices(Root + "*.(*)"); + + Assert.Equal( + new[] { "Slice Group1", "Slice Group2", "Slice Service" }, + Descriptions(Root + "*.(*)") + ); + Assert.Equal( + new[] + { + Root + "Slice1.Service.Service1Class", + Root + "Slice2.Service.Service2Class", + }, + slices + .Single(slice => slice.Description == "Slice Service") + .Types.Select(type => type.FullName) + .OrderBy(name => name, StringComparer.Ordinal) + ); } [Fact] - public void Matching_LeadingDotDot_CapturesEveryDepth() + public void Matching_LeadingDotDot_SkipsWholeLeadingSegments() { Assert.Equal(Descriptions(Root + "(*)"), Descriptions("..MultipleSubnamespaces.(*)")); } // The fixture's namespace contains "DuplicatePrefix.Sub" twice // (SlicesTestAssembly.DuplicatePrefix.Sub.DuplicatePrefix.Sub), so a leading ".." pattern - // for that prefix has two candidate starting positions to strip from. This pins that - // AssignFunc's IndexOf (leftmost match) picks the first occurrence, leaving the second - // "DuplicatePrefix.Sub" in the description. With an absolute (non-"..") prefix, the - // prefix can only ever be found at index 0, so that variant of this test would pass even - // if IndexOf were replaced by a hardcoded 0. + // for that prefix has two candidate positions to match at. The ".." is greedy, but + // skipping as far as the second occurrence leaves nothing for the capture group, so the + // match backtracks to the first: the captured slice is the *second* "DuplicatePrefix.Sub". [Fact] public void DuplicatePrefixSegment_CapturesAfterTheFirstOccurrenceOfThePrefix() { @@ -285,39 +335,55 @@ public void DuplicatePrefixSegment_CapturesAfterTheFirstOccurrenceOfThePrefix() // --- ".." semantics ------------------------------------------------------------- [Fact] - public void DotDot_BetweenCaptureGroups_CapturesEveryDepth() + public void DotDot_BetweenCaptureGroups_SkipsWholeSegments() { + // "Single" has nothing for the second group to capture and "AlphaService" is a single + // segment, so neither matches; "Outer.Mid.Inner" has its middle segment skipped. Assert.Equal( - new[] - { - "Slice Alpha.Service", - "Slice AlphaService", - "Slice Outer.Inner", - "Slice Outer.Mid.Inner", - "Slice Single", - }, + new[] { "Slice Alpha - Service", "Slice Outer - Inner" }, Descriptions(DotDot + "(*)..(*)") ); } [Fact] - public void DotDot_BeforeLiteral_CapturesEveryDepth() + public void Matching_LiteralBetweenCaptureGroups_IsNotPartOfTheName() + { + Assert.Equal(new[] { "Slice Outer - Inner" }, Descriptions(DotDot + "(*).Mid.(*)")); + } + + [Fact] + public void Matching_WildcardBetweenCaptureGroups_IsNotPartOfTheName() { + Assert.Equal(new[] { "Slice Outer - Inner" }, Descriptions(DotDot + "(*).*.(*)")); + } + + [Fact] + public void Matching_TrailingPatternText_IsNotPartOfTheName() + { + // Everything after the last capture group selects types rather than naming them. + Assert.Equal(new[] { "Slice Outer" }, Descriptions(DotDot + "(*).Mid.Inner")); + } + + [Fact] + public void Matching_Parts_AreTheCapturesInGroupOrder() + { + var slice = Assert.Single(Slices(DotDot + "(*).Mid.(*)")); + + Assert.Equal(new[] { "Outer", "Inner" }, slice.Identifier.Parts); Assert.Equal( - new[] - { - "Slice Alpha.Service", - "Slice AlphaService", - "Slice Outer.Inner", - "Slice Outer.Mid.Inner", - "Slice Single", - }, - Descriptions(DotDot + "(*)..Service") + new[] { "Outer.Mid.Inner" }, + Assert.Single(Slices(DotDot + "(**)", "Outer.Mid.Inner")).Identifier.Parts ); } [Fact] - public void DotDot_BeforeLiteral_GivesAlphaServiceAndAlphaDotServiceDistinctSlices() + public void DotDot_BeforeLiteral_MatchesOnlyWholeSegments() + { + Assert.Equal(new[] { "Slice Alpha" }, Descriptions(DotDot + "(*)..Service")); + } + + [Fact] + public void DotDot_BeforeLiteral_DoesNotSplitWithinASegment() { var slices = SliceRuleDefinition .Slices() @@ -325,14 +391,47 @@ public void DotDot_BeforeLiteral_GivesAlphaServiceAndAlphaDotServiceDistinctSlic .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) .ToList(); - // "AlphaService" is one segment and "Alpha.Service" is two, but that distinction is - // never actually evaluated: the "..Service" postfix is discarded (see - // AlphaServiceClass.cs), so both namespaces just get their own unfolded slice, each - // holding exactly the one type it's made of. This is not evidence the matcher tells - // the two shapes apart -- it would look identical if it ignored segment boundaries - // entirely. - Assert.Single(slices.Single(slice => slice.Description == "Slice AlphaService").Types); - Assert.Single(slices.Single(slice => slice.Description == "Slice Alpha.Service").Types); + // Only "Alpha.Service" matches. "AlphaService" is one segment and must not be split + // into "Alpha" + "Service". + var alpha = Assert.Single(slices); + Assert.Equal("Slice Alpha", alpha.Description); + Assert.Equal( + "SlicesTestAssembly.DotDotSemantics.Alpha.Service.AlphaServiceSegmentClass", + Assert.Single(alpha.Types).FullName + ); + } + + // --- identity across rules ------------------------------------------------------- + + [Fact] + public void OneCaptureAndTwoCapturesOfTheSameNamespace_AreDifferentSlices() + { + // "(**)" captures "Slice1.Service" as one value and "(*).(*)" as two. A slice is + // identified by its captures, so the two rules produce different slices, and their + // names say so. + var oneCapture = Slices(Root + "(**)", "Slice1.Service").Single(); + var twoCaptures = Slices(Root + "(*).(*)", "Slice1.Service").Single(); + + Assert.Equal(new[] { "Slice1.Service" }, oneCapture.Identifier.Parts); + Assert.Equal(new[] { "Slice1", "Service" }, twoCaptures.Identifier.Parts); + Assert.Equal("Slice Slice1.Service", oneCapture.Description); + Assert.Equal("Slice Slice1 - Service", twoCaptures.Description); + Assert.NotEqual(oneCapture, twoCaptures); + } + + [Fact] + public void SameCapturesWithDifferentSeparators_AreTheSameSlice() + { + // All three patterns capture "Outer" and "Inner" from "Outer.Mid.Inner"; what the + // pattern says between the groups only selects types. + var literal = Assert.Single(Slices(DotDot + "(*).Mid.(*)")); + var wildcard = Assert.Single(Slices(DotDot + "(*).*.(*)")); + var dotDot = Slices(DotDot + "(*)..(*)") + .Single(slice => slice.Description == "Slice Outer - Inner"); + + Assert.Equal(literal, dotDot); + Assert.Equal(literal, wildcard); + Assert.Equal(literal.Description, dotDot.Description); } // --- failure messages ----------------------------------------------------------- @@ -411,6 +510,30 @@ public Task NotDependOnEachOther_ReturnsDescriptiveDependencyMessage() return helper.AssertSnapshotMatches(); } + private static List Slices(string pattern) + { + return SliceRuleDefinition + .Slices() + .Matching(pattern) + .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) + .ToList(); + } + + /// + /// The slices of the pattern that hold a type of the given namespace, relative to the + /// pattern's fixture. + /// + private static List Slices(string pattern, string relativeNamespace) + { + return Slices(pattern) + .Where(slice => + slice.Types.Any(type => + type.Namespace.FullName.EndsWith("." + relativeNamespace) + ) + ) + .ToList(); + } + private static string[] Descriptions(string pattern) { return SliceRuleDefinition diff --git a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt b/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt index 56687bc1e..054362c57 100644 --- a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt +++ b/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt @@ -2,18 +2,65 @@ Result: False Description: Slice Slice1 does depend on other slices: Slice Slice1 -> Slice Slice2 + SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class + +Result: False +Description: Slice Slice1.Service does depend on other slices: +Slice Slice1.Service -> Slice Slice2.Service SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class +Result: False +Description: Slice Slice2 does depend on other slices: +Slice Slice2 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + +Result: False +Description: Slice Slice2.Service does depend on other slices: +Slice Slice2.Service -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + +Result: False +Description: Slice Slice3 does depend on other slices: +Slice Slice3 -> Slice Slice1 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class + +Result: False +Description: Slice Slice3.Group1 does depend on other slices: +Slice Slice3.Group1 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Group1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + Result: True -Description: Slice Slice2 does not depend on another slice. +Description: Slice Slice3.Group1.Inner does not depend on another slice. Result: True -Description: Slice Slice3 does not depend on another slice. +Description: Slice Slice3.Group2 does not depend on another slice. +Result: True +Description: Slice Slice3.Group2.Inner does not depend on another slice. Message: "Slices matching "SlicesTestAssembly.MultipleSubnamespaces.(**).." should not depend on each other" failed: Slice Slice1 does depend on other slices: Slice Slice1 -> Slice Slice2 + SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class + + Slice Slice1.Service does depend on other slices: +Slice Slice1.Service -> Slice Slice2.Service SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class + Slice Slice2 does depend on other slices: +Slice Slice2 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + + Slice Slice2.Service does depend on other slices: +Slice Slice2.Service -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + + Slice Slice3 does depend on other slices: +Slice Slice3 -> Slice Slice1 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class + + Slice Slice3.Group1 does depend on other slices: +Slice Slice3.Group1 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Group1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + diff --git a/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs b/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs index feedab11d..bf35a30e3 100644 --- a/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs +++ b/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs @@ -1,7 +1,6 @@ namespace SlicesTestAssembly.DotDotSemantics.AlphaService; -// A single segment that ends with the literal "Service". Parse() discards everything after the -// first "(*" for single-asterisk patterns, so "DotDotSemantics.(*)..Service" never actually -// applies the "..Service" postfix: this namespace matches it just the same as Alpha/Service below, -// even though "AlphaService" is one segment and not "Alpha" + "Service". +// A single segment that ends with the literal "Service", unlike Alpha/Service below, which really +// is two segments. "DotDotSemantics.(*)..Service" tells the two apart only if ".." matches whole +// segments. public class AlphaServiceClass { } diff --git a/documentation/docs/guide.md b/documentation/docs/guide.md index 1d3df4d90..8e6ba43c4 100644 --- a/documentation/docs/guide.md +++ b/documentation/docs/guide.md @@ -184,9 +184,27 @@ To use the `Slices()` entry point directly, add: using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition; ``` +#### Pattern syntax +| Token | Meaning | +|----------|---------| +| `(*)` | Captures exactly one namespace segment. | +| `(**)` | Captures one or more segments. | +| `(A\|B)` | Captures one of the listed alternatives. | +| `*` | Matches one segment without capturing it. | +| `..` | Matches zero or more segments. | +| `[A\|B]` | Matches one of the listed alternatives. | + +A pattern must contain at least one capture group, may not mix `(*)` and `(**)`, and may use `(**)` +at most once. `Matching` throws an `ArgumentException` otherwise -- when the rule is defined, not when +it is checked. Types whose namespace does not match are left out entirely. + #### How slices are named A slice is identified by **what its capture groups captured**, in group order. Two types end up in -the same slice exactly when their groups captured the same values. +the same slice exactly when their groups captured the same values. The pattern text between, before +and after the groups only selects which types belong to a slice: `"App.(*).(*)"`, +`"App.(*).Service.(*)"` and `"App.(*)..(*)"` all put the types they match in `Orders` and `Http` +into the same slice, while `"App.(**)"` over `App.Orders.Http` captures the single value +`Orders.Http`, which is a different slice. A slice is named after its captures: `Slice ` followed by the captured values joined with ` - `. The name never reads as a namespace, and it is what failure messages and diagrams show. @@ -195,10 +213,19 @@ name never reads as a namespace, and it is what failure messages and diagrams sh |---|---|---| | `App.(*)` | `App.Orders` | `Slice Orders` | | `App.(**)` | `App.Orders.Web` | `Slice Orders.Web` | +| `App.(*)..` | `App.Orders.Web` | `Slice Orders` | +| `App.(*).(*)` | `App.Orders.Http` | `Slice Orders - Http` | +| `App.(*).Service.(*)` | `App.Orders.Service.Http` | `Slice Orders - Http` | +| `App.(*)..(*)` | `App.Orders.Web.Http` | `Slice Orders - Http` | +| `App.(*).*.(*)` | `App.Orders.Web.Http` | `Slice Orders - Http` | Frozen violations of a slice rule (see `FreezingArchRule`) are stored by the slice name, e.g. `Slice Orders`. +With `NestSlicesInPackages`, each slice is drawn under its name inside the package of the deepest +namespace all its types share (see [5.2](#52-structured-diagram-dependencies)). `Slice Orders - Http` +of `App.(*)..(*)` sits in `Orders` when its types are in `Orders.Web.Http` and `Orders.Api.Http`. + #### Cycle-free rule ![Cycle](img/cycle.svg) ```cs @@ -302,7 +329,7 @@ PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetOb ``` ### 5.4 Small slices -In order not to display all slices and all occurrences, you can use a single asterisk in the pattern. One star is one slice deep. You cannot mix single (*) and double (**) asterisks in a pattern. +In order not to display all slices and all occurrences, you can use a single asterisk in the pattern. One star captures one namespace segment. You cannot mix single `(*)` and double `(**)` asterisks in one pattern, but you can repeat `(*)` -- `"ArchUnitNET.(*).(*)"` captures two segments and draws `Slice Fluent - Syntax` inside the `ArchUnitNET.Fluent.Syntax` namespace. #### 5.4.1 ArchUnitNET.(\*) ![Diagram](diagrams/archUnitNet_one.svg)