Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions ArchUnitNET/Domain/PlantUml/Export/PlantUmlDiagram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ public string GetPlantUmlString(RenderOptions renderOptions)
/// </summary>
/// <remarks>
/// A slice is identified in the diagram by its name, but not every name is a valid id.
/// PlantUML rejects "[Inner] as App.Outer+Inner" and "Container(App.Orders.*.Http, ...)"
/// as syntax errors, and slice names can contain such characters: SlicedBy names slices
/// after type names such as "Outer+Inner" or "List`1", and a hand-built SliceIdentifier
/// can be called anything. No character that a namespace cannot contain works in their
/// place either, so the id is made from the name with those characters replaced by "_",
/// and suffixed until it clashes with no other name in the diagram. The name itself stays
/// the label, and slices whose names are valid ids keep them.
/// PlantUML rejects "[Inner] as App.Outer+Inner" and "Container(Slice Orders, ...)" as
/// syntax errors, and slice names can contain such characters: every slice description
/// starts with "Slice " and joins several parts with " - ", SlicedBy names slices after
/// type names such as "Outer+Inner" or "List`1", and a hand-built SliceIdentifier can be
/// called anything. No character that a namespace cannot contain works in their place either, so
/// the id is made from the name with those characters replaced by "_", and suffixed until
/// it clashes with no other name in the diagram. The name itself stays the label, and
/// slices whose names are valid ids keep them.
/// </remarks>
private static void AssignAliases(IReadOnlyCollection<IPlantUmlElement> elements)
{
Expand Down
27 changes: 18 additions & 9 deletions ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -728,12 +728,14 @@ private void ReplaceCirclesWithAppropriateDependencyType()
/// nested slice is therefore drawn under its qualified name, the namespace its types
/// matched before the first capture group followed by its
/// <see cref="SliceIdentifier.DiagramPath"/>, e.g. "App.Orders.Http", with that namespace
/// as its root package. Every other slice is drawn under its description, without a
/// package.
/// as its root package. Every other slice is drawn under its description, e.g.
/// "Slice Orders - Http", without a package.
/// </para>
/// <para>
/// Qualified names of one pattern are distinct, but a description can coincide with a
/// qualified name. Drawing two slices as one node would be wrong, so that is rejected.
/// A description always contains a space and a qualified name never does, so the two
/// can't be mistaken for each other, and qualified names of one pattern are distinct.
/// Descriptions can still coincide, for a custom slice whose part contains " - ".
/// Drawing two slices as one node would be wrong, so that is rejected.
/// </para>
/// </remarks>
/// <param name="nestSlicesInPackages">
Expand All @@ -757,11 +759,11 @@ private void AssignDiagramNames(bool nestSlicesInPackages)
if (slicesByName.TryGetValue(diagramName.Name, out var other))
{
throw new ArgumentException(
"The slices \""
+ other.Identifier.Identifier
+ "\" and \""
+ slice.Identifier.Identifier
+ "\" would both be drawn as \""
"The slices with parts "
+ FormatParts(other)
+ " and "
+ FormatParts(slice)
+ " would both be drawn as \""
+ diagramName.Name
+ "\"."
);
Expand All @@ -772,6 +774,13 @@ private void AssignDiagramNames(bool nestSlicesInPackages)
}
}

private static string FormatParts(Slice slice)
{
return "["
+ string.Join(", ", slice.Identifier.Parts.Select(part => "\"" + part + "\""))
+ "]";
}

/// <summary>The name the diagram draws a slice under; see <see cref="AssignDiagramNames"/>.</summary>
private string NameOf(Slice slice)
{
Expand Down
105 changes: 88 additions & 17 deletions ArchUnitNET/Domain/SliceIdentifier.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
using JetBrains.Annotations;
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;

namespace ArchUnitNET.Domain
{
public class SliceIdentifier : StringIdentifier, IHasDescription
/// <summary>
/// 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.
/// </summary>
public class SliceIdentifier : IHasDescription
{
public static readonly SliceIdentifierComparer Comparer = new SliceIdentifierComparer();
public readonly bool Ignored;

private SliceIdentifier(
string identifier,
IReadOnlyList<string> parts,
bool ignored,
int? countOfAsteriskInPattern = null,
string matchedPrefix = null,
string diagramPath = null
)
: base(identifier)
{
Parts = parts;
Ignored = ignored;
CountOfAsteriskInPattern = countOfAsteriskInPattern;
MatchedPrefix = matchedPrefix;
DiagramPath = diagramPath;
}

public string Description => Identifier;
/// <summary>
/// What identifies the slice: for a slice pattern, the values its capture groups
/// captured, in group order. "App.(**)" over "App.Orders.Web" gives
/// <c>[Orders.Web]</c>.
/// </summary>
public IReadOnlyList<string> Parts { get; }

/// <summary>
/// The slice's name, built from its parts: "Slice " followed by the parts joined with
/// " - ", e.g. "Slice Orders - Http". It depends on the parts only, so equal slices always
/// have the same name.
/// </summary>
public string Description => "Slice " + string.Join(" - ", Parts);

/// <summary>
/// The part of the type's namespace the pattern matched before the first capture group,
Expand All @@ -44,24 +64,46 @@ private SliceIdentifier(

public readonly int? CountOfAsteriskInPattern;

public static SliceIdentifier Of(string identifier, int? countOfAsteriskInPattern = null)
/// <summary>
/// Creates an identifier for a slice with a single part, the same slice as
/// <c>Of(new[] { part })</c>.
/// </summary>
/// <param name="part">
/// The slice's only part: a custom <c>SliceAssignment</c> that returns identifiers with the
/// same part for two types puts them into the same slice.
/// </param>
/// <param name="countOfAsteriskInPattern">
/// The number of "(*)" groups in the pattern, if the slice comes from one.
/// </param>
public static SliceIdentifier Of(string part, int? countOfAsteriskInPattern = null)
{
return new SliceIdentifier(CheckParts(new[] { part }), false, countOfAsteriskInPattern);
}

/// <summary>
/// Creates an identifier for the slice with the given parts. Identifiers with equal parts,
/// in the same order, belong to the same slice.
/// </summary>
/// <param name="parts">The parts, at least one; none of them may be <c>null</c>.</param>
/// <exception cref="ArgumentException">There are no parts, or one of them is null.</exception>
public static SliceIdentifier Of(IEnumerable<string> parts)
{
return new SliceIdentifier(identifier, false, countOfAsteriskInPattern);
return new SliceIdentifier(CheckParts(parts), false);
}

/// <summary>
/// Creates the identifier a slice pattern assigns to a type, together with where the
/// pattern matched, which diagrams use to nest the slice.
/// </summary>
internal static SliceIdentifier OfMatch(
string identifier,
IEnumerable<string> parts,
int? countOfAsteriskInPattern,
string matchedPrefix,
string diagramPath
)
{
return new SliceIdentifier(
identifier,
CheckParts(parts),
false,
countOfAsteriskInPattern,
matchedPrefix,
Expand All @@ -71,14 +113,34 @@ string diagramPath

public static SliceIdentifier Ignore()
{
return new SliceIdentifier("Ignored", true);
return new SliceIdentifier(new[] { "Ignored" }, true);
}

private static IReadOnlyList<string> CheckParts(IEnumerable<string> parts)
{
if (parts == null)
{
throw new ArgumentNullException(nameof(parts));
}

var list = parts.ToList();
if (list.Count == 0)
{
throw new ArgumentException("A slice needs at least one part.", nameof(parts));
}

if (list.Any(part => part == null))
{
throw new ArgumentException("A slice part can't be null.", nameof(parts));
}

return list.AsReadOnly();
}

/// <summary>
/// Is true when the two SliceIdentifiers belong to the same slice
/// Whether the two identifiers belong to the same slice: both ignored, or both not
/// ignored with the same parts.
/// </summary>
/// <param name="other"></param>
/// <returns></returns>
public bool CompareTo(SliceIdentifier other)
{
if (other == null)
Expand All @@ -87,12 +149,12 @@ public bool CompareTo(SliceIdentifier other)
}

return Ignored && other.Ignored
|| !Ignored && !other.Ignored && Identifier == other.Identifier;
|| !Ignored && !other.Ignored && Parts.SequenceEqual(other.Parts);
}

private bool Equals(SliceIdentifier other)
{
return Identifier == other.Identifier && Ignored == other.Ignored;
return Ignored == other.Ignored && Parts.SequenceEqual(other.Parts);
}

public override bool Equals(object obj)
Expand All @@ -114,10 +176,19 @@ public override int GetHashCode()
{
unchecked
{
var hashCode = 397 ^ Identifier.GetHashCode();
hashCode = (hashCode * 397) ^ Ignored.GetHashCode();
var hashCode = Ignored.GetHashCode();
foreach (var part in Parts)
{
hashCode = (hashCode * 397) ^ part.GetHashCode();
}

return hashCode;
}
}

public override string ToString()
{
return Description;
}
}
}
2 changes: 1 addition & 1 deletion ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ private static SliceIdentifier AssignFunc(
// What the pattern captured is both what identifies the slice and the path a
// diagram nests it along.
return SliceIdentifier.OfMatch(
sliceString,
new[] { sliceString },
countOfSingleAsterisk,
matchedPrefix,
sliceString
Expand Down
6 changes: 3 additions & 3 deletions ArchUnitNET/Fluent/Slices/SlicesShould.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Architecture architecture
}

var cycleIdentifier = new EnumerableIdentifier(
cycle.Contents.Select(slice => slice.Identifier)
cycle.Contents.Select(slice => new StringIdentifier(slice.Description))
);
yield return new EvaluationResult(
cycle,
Expand All @@ -106,7 +106,7 @@ Architecture architecture
else
{
var slicesIdentifier = new EnumerableIdentifier(
slicesList.Select(slice => slice.Identifier)
slicesList.Select(slice => new StringIdentifier(slice.Description))
);
yield return new EvaluationResult(
slicesList,
Expand Down Expand Up @@ -166,7 +166,7 @@ Architecture architecture

yield return new EvaluationResult(
slice,
slice.Identifier,
new StringIdentifier(slice.Description),
passed,
description,
archRule,
Expand Down
12 changes: 6 additions & 6 deletions ArchUnitNETTests/ArchUnitNET/Storage/CustomPathFrozenRules.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
{
"ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(**)\" should not depend on each other",
"Violations": [
"Slice3",
"Slice3.Group1",
"Slice2",
"Slice2.Service",
"Slice1",
"Slice1.Service"
"Slice Slice3",
"Slice Slice3.Group1",
"Slice Slice2",
"Slice Slice2.Service",
"Slice Slice1",
"Slice Slice1.Service"
]
}
]
12 changes: 6 additions & 6 deletions ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
{
"ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(**)\" should not depend on each other",
"Violations": [
"Slice3",
"Slice3.Group1",
"Slice2",
"Slice2.Service",
"Slice1",
"Slice1.Service"
"Slice Slice3",
"Slice Slice3.Group1",
"Slice Slice2",
"Slice Slice2.Service",
"Slice Slice1",
"Slice Slice1.Service"
]
},
{
Expand Down
12 changes: 6 additions & 6 deletions ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
<Violation>ArchUnitNETTests.Fluent.FreezeTests+Violation</Violation>
</FrozenRule>
<FrozenRule ArchRule="Slices matching &quot;SlicesTestAssembly.MultipleSubnamespaces.(**)&quot; should not depend on each other">
<Violation>Slice3</Violation>
<Violation>Slice3.Group1</Violation>
<Violation>Slice2</Violation>
<Violation>Slice2.Service</Violation>
<Violation>Slice1</Violation>
<Violation>Slice1.Service</Violation>
<Violation>Slice Slice3</Violation>
<Violation>Slice Slice3.Group1</Violation>
<Violation>Slice Slice2</Violation>
<Violation>Slice Slice2.Service</Violation>
<Violation>Slice Slice1</Violation>
<Violation>Slice Slice1.Service</Violation>
</FrozenRule>
</FrozenRules>
27 changes: 12 additions & 15 deletions ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,8 @@ public Task BuildUmlBySlicesFocusOn_Matching_ColorsFocusedSlices()
{
// Slices that are not nested have no namespace, so the focused ones take
// PlantUmlSlice.BuildString's namespace-less branch with a colour set -- the only
// path on which the exporter renders "[Slice1] #99ffd1" rather than a package.
// They are drawn under their description, which carries no Root prefix, hence the
// bare focus string.
// path on which the exporter renders "[Slice Slice1] #99ffd1" rather than a package.
// They are drawn under their description, which "Slice1" is contained in.
var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)"));
var uml = new PlantUmlFileBuilder()
.WithDependenciesFromFocusOn(slices, "Slice1")
Expand Down Expand Up @@ -371,30 +370,28 @@ public void BuildUmlBySlices_SameSlicesDrawnFlatAndNested()
var nested = new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested).AsString();

Assert.DoesNotContain("package", flat);
Assert.Contains("[Slice1.Service]", flat);
Assert.Contains("[Slice Slice1] as Slice_Slice1", flat);
Assert.Contains("package \"SlicesTestAssembly.MultipleSubnamespaces\"", nested);
Assert.DoesNotContain("[Slice1.Service]", nested);
Assert.DoesNotContain("Slice Slice1", nested);
}

// --- Two slices may not share a node ----------------------------------------------

[Fact]
public void BuildUmlBySlices_SameDiagramName_Throws()
{
// A slice that is not nested is drawn under its description, which here is the
// qualified name a nested slice is drawn under.
var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)"))
.Concat(
new[] { new Slice(SliceIdentifier.Of(Root + "Slice1"), Array.Empty<IType>()) }
)
.ToList();
// Different slices, both described "Slice A - B".
var slices = new[]
{
new Slice(SliceIdentifier.Of(new[] { "A - B" }), Array.Empty<IType>()),
new Slice(SliceIdentifier.Of(new[] { "A", "B" }), Array.Empty<IType>()),
};

var ex = Assert.Throws<ArgumentException>(() =>
new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested)
new PlantUmlFileBuilder().WithDependenciesFrom(slices)
);
Assert.Equal(
"The slices \"Slice1\" and \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\" "
+ "would both be drawn as \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\".",
"The slices with parts [\"A - B\"] and [\"A\", \"B\"] would both be drawn as \"Slice A - B\".",
ex.Message
);
}
Expand Down
Loading
Loading