From 8e055e06db207a701f72db2e99560f243d1cfce2 Mon Sep 17 00:00:00 2001 From: Alexander Linne Date: Fri, 23 Oct 2026 18:31:16 +0200 Subject: [PATCH 1/2] test: cover the PlantUML import with tests Cover the error paths of PlantUmlParser: null arguments, a missing file and a stream that fails while reading. Pin the optional parts of the component and dependency patterns with characterization tests, so they can be rewritten safely: stereotypes with and without whitespace between them, quoted and unquoted aliases, surrounding whitespace, lines that are not components, and dependencies in both directions with and without brackets and descriptions. Signed-off-by: Alexander Linne --- .../Domain/PlantUml/PlantUmlParserTest.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs index ecd7ab241..e8b4ce733 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs @@ -477,6 +477,155 @@ public void ParsesAComponentDiagramThatUsesAliasWithAndWithoutBrackets() Assert.Empty(bar.Dependencies); } + [Fact] + public void ParsesAComponentDiagramThatUsesQuotedAliasWithAndWithoutBrackets() + { + TestDiagram + .From(_memoryStream) + .RawLine("[A] <> as \"foo\"") + .RawLine("[B] <> as \"bar\"") + .DependencyFrom("foo") + .To("bar") + .DependencyFrom("[foo]") + .To("[bar]") + .Write(); + + PlantUmlParsedDiagram diagram = CreateDiagram(_memoryStream); + + PlantUmlComponent foo = GetComponentWithAlias(new Alias("foo"), diagram); + PlantUmlComponent bar = GetComponentWithAlias(new Alias("bar"), diagram); + + Assert.Equal(new[] { bar }, foo.Dependencies); + Assert.Empty(bar.Dependencies); + } + + [Theory] + [InlineData("[A] <>", "A", null, new[] { "X" })] + [InlineData("[A]<>", "A", null, new[] { "X" })] + [InlineData("[A] <><>", "A", null, new[] { "X", "Y" })] + [InlineData("[A] <> <> <>", "A", null, new[] { "X", "Y", "Z" })] + [InlineData("[A B] <>", "A B", null, new[] { "X.*" })] + [InlineData("[A] <> as B", "A", "B", new[] { "X" })] + [InlineData("[A] <> as \"B\"", "A", "B", new[] { "X" })] + [InlineData("[A] <> as \"B C\"", "A", "B C", new[] { "X" })] + [InlineData("[A] <> <> as \"B\"", "A", "B", new[] { "X", "Y" })] + [InlineData(" [A] <>", "A", null, new[] { "X" })] + [InlineData("[A] <> ", "A", null, new[] { "X" })] + [InlineData("\t[A] <> as \"B\"\t", "A", "B", new[] { "X" })] + public void ParsesComponentLineVariants( + string line, + string expectedName, + string expectedAlias, + string[] expectedStereotypes + ) + { + PlantUmlParsedDiagram diagram = CreateDiagram( + TestDiagram.From(_memoryStream).RawLine(line).Write() + ); + + PlantUmlComponent component = diagram.AllComponents.Single(); + Assert.Equal(new ComponentName(expectedName), component.ComponentName); + Assert.Equal(expectedAlias != null ? new Alias(expectedAlias) : null, component.Alias); + Assert.Equal( + expectedStereotypes.Select(s => new Stereotype(s)), + component.Stereotypes.OrderBy(s => s.AsString()) + ); + } + + [Theory] + [InlineData("[A <>")] + [InlineData("A] <>")] + [InlineData("[[A]] <>")] + [InlineData("[A] <")] + [InlineData("[A] <> B")] + [InlineData("[A] <> as \"B\" C")] + [InlineData("B [A] <>")] + public void IgnoresLinesThatAreNotComponents(string line) + { + PlantUmlParsedDiagram diagram = CreateDiagram( + TestDiagram.From(_memoryStream).RawLine(line).Write() + ); + + Assert.Empty(diagram.AllComponents); + } + + [Theory] + [ClassData(typeof(DependencyLineVariantsTestData))] + public void ParsesDependencyLineVariants(string dependency) + { + PlantUmlParsedDiagram diagram = CreateDiagram( + TestDiagram + .From(_memoryStream) + .Component("SomeOrigin") + .WithStereoTypes("Origin.*") + .Component("SomeTarget") + .WithStereoTypes("Target.*") + .RawLine(dependency) + .Write() + ); + + PlantUmlComponent origin = GetComponentWithName("SomeOrigin", diagram); + PlantUmlComponent target = GetComponentWithName("SomeTarget", diagram); + + Assert.Equal(new[] { target }, origin.Dependencies); + Assert.Empty(target.Dependencies); + } + + [Theory] + [InlineData("[SomeOrigin] -- [SomeTarget]")] + [InlineData("[SomeOrigin] - [SomeTarget]")] + [InlineData("[SomeOrigin] [SomeTarget]")] + public void IgnoresLinesThatAreNotDependencies(string line) + { + PlantUmlParsedDiagram diagram = CreateDiagram( + TestDiagram + .From(_memoryStream) + .Component("SomeOrigin") + .WithStereoTypes("Origin.*") + .Component("SomeTarget") + .WithStereoTypes("Target.*") + .RawLine(line) + .Write() + ); + + Assert.All(diagram.AllComponents, c => Assert.Empty(c.Dependencies)); + } + + [Fact] + public void ThrowsArgumentNullExceptionForNullFilename() + { + Assert.Throws(() => _parser.Parse((string)null)); + } + + [Fact] + public void ThrowsArgumentNullExceptionForNullStream() + { + Assert.Throws(() => _parser.Parse((Stream)null)); + } + + [Fact] + public void ThrowsParseExceptionForMissingFile() + { + string path = Path.Combine( + Path.GetTempPath(), + "plantuml_diagram_" + Guid.NewGuid() + ".puml" + ); + + PlantUmlParseException exception = Assert.Throws(() => + _parser.Parse(path) + ); + Assert.IsAssignableFrom(exception.InnerException); + } + + [Fact] + public void ThrowsParseExceptionForUnreadableStream() + { + PlantUmlParseException exception = Assert.Throws(() => + _parser.Parse(new UnreadableStream()) + ); + Assert.IsAssignableFrom(exception.InnerException); + } + [Fact] public void ParsesASimpleComponentWithFile() { @@ -527,6 +676,78 @@ private PlantUmlParsedDiagram CreateDiagram(Stream stream) { return _parser.Parse(stream); } + + private class UnreadableStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new IOException("Simulated read failure"); + } + + public override void Flush() { } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } + } + + public class DependencyLineVariantsTestData : IEnumerable + { + // Complements DependencyArrowTestData, which covers every arrow center between + // bracketed endpoints without a description + public IEnumerator GetEnumerator() + { + string[] arrowCenters = { "-", "--", "-left-", "-[#green]-" }; + foreach (bool bracketed in new[] { true, false }) + { + foreach (bool described in new[] { true, false }) + { + if (bracketed && !described) + { + continue; + } + string origin = bracketed ? "[SomeOrigin]" : "SomeOrigin"; + string target = bracketed ? "[SomeTarget]" : "SomeTarget"; + string description = described ? " : some description" : ""; + foreach (string arrowCenter in arrowCenters) + { + yield return new object[] + { + origin + " " + arrowCenter + "> " + target + description, + }; + yield return new object[] + { + target + " <" + arrowCenter + " " + origin + description, + }; + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } public class DependencyArrowTestData : IEnumerable From edf1e952608a4612b45b1361204536ff86b74971 Mon Sep 17 00:00:00 2001 From: Alexander Linne Date: Fri, 23 Oct 2026 18:33:51 +0200 Subject: [PATCH 2/2] refactor: simplify PlantUML import patterns Write each regex as one readable definition instead of assembling it from fields in a static constructor via Capture and AnythingBut. PlantUmlPatterns becomes a static class holding the shared stereotype fragment, and the matchers become parsed results: TryParse returns null (or nothing) for lines that don't match, so each component line is matched once, and the Match...() methods become properties. This removes FilterComponents, MatchComponent and the unreachable throw and null checks. In the parser, merge the two ReadLines overloads, drop a redundant Distinct() and stop building a regex for every line to filter comments. Stereotypes are now read from a named group of the component match instead of a second pass over the whole line. Before, a stereotype-like alias was read as a stereotype too, so `[A] <> as "<>"` gave A the stereotypes Real.* and Fake.*, and `[A] as "<>"` was accepted although it has no stereotype. Signed-off-by: Alexander Linne --- .../Domain/PlantUml/Import/PlantUmlParser.cs | 76 ++---- .../PlantUml/Import/PlantUmlPatterns.cs | 242 +++++------------- .../Domain/PlantUml/PlantUmlParserTest.cs | 12 + 3 files changed, 100 insertions(+), 230 deletions(-) diff --git a/ArchUnitNET/Domain/PlantUml/Import/PlantUmlParser.cs b/ArchUnitNET/Domain/PlantUml/Import/PlantUmlParser.cs index 90ce70484..cec2e9210 100644 --- a/ArchUnitNET/Domain/PlantUml/Import/PlantUmlParser.cs +++ b/ArchUnitNET/Domain/PlantUml/Import/PlantUmlParser.cs @@ -7,57 +7,42 @@ using System.Text; using System.Text.RegularExpressions; using ArchUnitNET.Domain.PlantUml.Exceptions; -using static ArchUnitNET.Domain.PlantUml.Import.PlantUmlPatterns; namespace ArchUnitNET.Domain.PlantUml.Import { internal class PlantUmlParser { - private PlantUmlPatterns _plantUmlPatterns = new PlantUmlPatterns(); - public PlantUmlParsedDiagram Parse(string filename) { if (filename is null) { throw new ArgumentNullException(nameof(filename)); } - return CreateDiagram(ReadLines(filename)); + return CreateDiagram( + ReadLines(() => new StreamReader(filename, Encoding.UTF8), filename) + ); } public PlantUmlParsedDiagram Parse(Stream stream) { if (stream is null) { - throw new ArgumentNullException("stream"); - } - return CreateDiagram(ReadLines(stream)); - } - - private IEnumerable ReadLines(string filename) - { - try - { - return File.ReadAllLines(filename, Encoding.UTF8); - } - catch (Exception ex) - when (ex is IOException - || ex is UnauthorizedAccessException - || ex is SecurityException - ) - { - throw new PlantUmlParseException("Could not parse diagram from " + filename, ex); + throw new ArgumentNullException(nameof(stream)); } + return CreateDiagram( + ReadLines(() => new StreamReader(stream, Encoding.UTF8), "stream") + ); } - private IEnumerable ReadLines(Stream stream) + private static IEnumerable ReadLines(Func open, string source) { try { var lines = new List(); - using (StreamReader sr = new StreamReader(stream, Encoding.UTF8)) + using (TextReader reader = open()) { string line; - while ((line = sr.ReadLine()) != null) + while ((line = reader.ReadLine()) != null) { lines.Add(line); } @@ -70,7 +55,7 @@ private IEnumerable ReadLines(Stream stream) || ex is SecurityException ) { - throw new PlantUmlParseException("Could not parse diagram from stream", ex); + throw new PlantUmlParseException("Could not parse diagram from " + source, ex); } } @@ -98,18 +83,18 @@ IEnumerable plantUmlDiagramLines { var result = new List(); foreach ( - PlantUmlDependencyMatcher matcher in _plantUmlPatterns.MatchDependencies( - plantUmlDiagramLines + PlantUmlDependencyMatcher matcher in plantUmlDiagramLines.SelectMany( + PlantUmlDependencyMatcher.TryParse ) ) { PlantUmlComponent origin = FindComponentMatching( plantUmlComponents, - matcher.MatchOrigin + matcher.Origin ); PlantUmlComponent target = FindComponentMatching( plantUmlComponents, - matcher.MatchTarget + matcher.Target ); result.Add(new ParsedDependency(origin.Identifier, target.Identifier)); } @@ -130,27 +115,24 @@ string originOrTargetString private ISet ParseComponents(IEnumerable plantUmlDiagramLines) { return new HashSet( - _plantUmlPatterns - .FilterComponents(plantUmlDiagramLines) - .Select(p => CreateNewComponent(p)) - .Distinct() + plantUmlDiagramLines + .Select(PlantUmlComponentMatcher.TryParse) + .Where(matcher => matcher != null) + .Select(CreateNewComponent) ); } - private PlantUmlComponent CreateNewComponent(string input) + private PlantUmlComponent CreateNewComponent(PlantUmlComponentMatcher matcher) { - PlantUmlComponentMatcher matcher = _plantUmlPatterns.MatchComponent(input); - - var componentName = new ComponentName(matcher.MatchComponentName()); + var componentName = new ComponentName(matcher.Name); ImmutableHashSet immutableStereotypes = IdentifyStereotypes( matcher, componentName ); - string alias = matcher.MatchAlias(); return new PlantUmlComponent( componentName, immutableStereotypes, - alias != null ? new Alias(alias) : null + matcher.Alias != null ? new Alias(matcher.Alias) : null ); } @@ -159,13 +141,9 @@ private ImmutableHashSet IdentifyStereotypes( ComponentName componentName ) { - var stereotypes = ImmutableHashSet.CreateBuilder(); - foreach (string stereotype in matcher.MatchStereoTypes()) - { - stereotypes.Add(new Stereotype(stereotype)); - } - - ImmutableHashSet result = stereotypes.ToImmutable(); + ImmutableHashSet result = matcher + .Stereotypes.Select(stereotype => new Stereotype(stereotype)) + .ToImmutableHashSet(); if (result.IsEmpty) { throw new IllegalDiagramException( @@ -181,7 +159,9 @@ ComponentName componentName private IEnumerable FilterOutComments(IEnumerable lines) { - return lines.Where(l => !Regex.IsMatch(l, "^\\s*'")).ToList(); + return lines + .Where(l => !l.TrimStart().StartsWith("'", StringComparison.Ordinal)) + .ToList(); } } } diff --git a/ArchUnitNET/Domain/PlantUml/Import/PlantUmlPatterns.cs b/ArchUnitNET/Domain/PlantUml/Import/PlantUmlPatterns.cs index cca03f89f..826835058 100644 --- a/ArchUnitNET/Domain/PlantUml/Import/PlantUmlPatterns.cs +++ b/ArchUnitNET/Domain/PlantUml/Import/PlantUmlPatterns.cs @@ -1,214 +1,92 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace ArchUnitNET.Domain.PlantUml.Import { - internal class PlantUmlPatterns + internal static class PlantUmlPatterns { - private static readonly string ComponentNameGroupName = "componentName"; - private static readonly string ComponentNameFormat; - private static readonly Regex StereotypePattern; - private static readonly string StereotypeFormat; - private static readonly string AliasGroupName = "alias"; - private static readonly string AliasFormat; - private static readonly Regex PlantUmlComponentPattern; + // e.g. '<>', can be repeated + internal const string Stereotypes = @"(?:<<(?[^<>]+)>>\s*)*"; - static PlantUmlPatterns() + internal static ISet GetStereotypes(Match match) { - StereotypeFormat = "(?:<<" + Capture(AnythingBut("<>")) + ">>\\s*)"; - ComponentNameFormat = - "\\[" + Capture(AnythingBut("\\[\\]"), ComponentNameGroupName) + "]"; - StereotypePattern = new Regex(StereotypeFormat); - AliasFormat = "\\s*(?:as \"?" + Capture("[^\"]+", AliasGroupName) + "\"?)?"; - PlantUmlComponentPattern = new Regex( - @"\A(?:^\s*" - + ComponentNameFormat - + @"\s*" - + StereotypeFormat - + "*" - + AliasFormat - + @"\s*)\z" + return new HashSet( + match.Groups["stereotype"].Captures.Cast().Select(c => c.Value) ); } + } - private static string Capture(string pattern) - { - return "(" + pattern + ")"; - } + internal class PlantUmlDependencyMatcher + { + // e.g. '-left->' or '--[#green]->' + private const string ArrowCenter = @"(?:left|right|up|down|\[[^\]]+\])?"; - private static string Capture(string pattern, string name) - { - return "(?<" + name + ">" + pattern + ")"; - } + private static readonly Regex RightArrowPattern = new Regex( + @"\s-+" + ArrowCenter + @"-*>\s" + ); + private static readonly Regex LeftArrowPattern = new Regex( + @"\s<-*" + ArrowCenter + @"-+\s" + ); - private static string AnythingBut(string charsJoined) + private PlantUmlDependencyMatcher(string origin, string target) { - return "[^" + charsJoined + "]+"; + Origin = origin; + Target = target; } - internal IEnumerable FilterComponents(IEnumerable plantUmlDiagramLines) - { - return plantUmlDiagramLines.Where(l => PlantUmlComponentPattern.Match(l).Success); - } + internal string Origin { get; } + internal string Target { get; } - internal IEnumerable MatchDependencies( - IEnumerable diagramLines - ) + internal static IEnumerable TryParse(string line) { - var result = new List(); - foreach (string line in diagramLines) - { - result.AddRange(PlantUmlDependencyMatcher.TryParseFromLeftToRight(line)); - result.AddRange(PlantUmlDependencyMatcher.TryParseFromRightToLeft(line)); - } - return result; - } - - internal PlantUmlComponentMatcher MatchComponent(string input) - { - return new PlantUmlComponentMatcher(input); - } - - internal class PlantUmlDependencyMatcher - { - private static readonly string ColorRegex = @"\[[^]]+]"; // for arrows like '--[#green]->' - private static readonly string DeppendencyArrowCenterRegex = - @"(left|right|up|down|" + ColorRegex + ")?"; - private static readonly Regex DependencyRightArrowPattern = new Regex( - @"\s-+" + DeppendencyArrowCenterRegex + @"-*>\s" - ); - private static readonly Regex DependencyLeftArrowPattern = new Regex( - @"\s<-*" + DeppendencyArrowCenterRegex + @"-+\s" - ); - - public PlantUmlDependencyMatcher(string origin, string target) - { - if (origin is null) - { - throw new ArgumentNullException(nameof(origin)); - } - - if (target is null) - { - throw new ArgumentNullException(nameof(target)); - } - - MatchOrigin = origin; - MatchTarget = target; - } - - public string MatchTarget { get; private set; } - public string MatchOrigin { get; private set; } - - internal static IEnumerable TryParseFromLeftToRight( - string line - ) - { - return IsDependencyFromLeftToRight(line) - ? ImmutableList.CreateRange( - new List { ParseDependencyFromLeftToRight(line) } - ) - : Enumerable.Empty(); - } - - internal static IEnumerable TryParseFromRightToLeft( - string line - ) - { - return IsDependencyFromRightToLeft(line) - ? ImmutableList.CreateRange( - new List { ParseDependencyFromRightToLeft(line) } - ) - : Enumerable.Empty(); - } - - private static PlantUmlDependencyMatcher ParseDependencyFromRightToLeft(string line) - { - IList parts = ParseParts(line, DependencyLeftArrowPattern); - return new PlantUmlDependencyMatcher(parts[1], parts[0]); - } - - private static bool IsDependencyFromRightToLeft(string line) - { - return DependencyLeftArrowPattern.IsMatch(line); - } - - private static bool IsDependencyFromLeftToRight(string line) + if (RightArrowPattern.IsMatch(line)) { - return DependencyRightArrowPattern.IsMatch(line); + IList parts = ParseParts(line, RightArrowPattern); + yield return new PlantUmlDependencyMatcher(parts[0], parts[1]); } - - private static PlantUmlDependencyMatcher ParseDependencyFromLeftToRight(string line) - { - IList parts = ParseParts(line, DependencyRightArrowPattern); - return new PlantUmlDependencyMatcher(parts[0], parts[1]); - } - - private static IList ParseParts(string line, Regex dependencyRightArrowPattern) - { - line = RemoveOptionalDescription(line); - return dependencyRightArrowPattern - .Replace(line, " ") - .Split(' ') - .Select(l => l.Trim()) - .Take(2) - .ToList(); - } - - private static string RemoveOptionalDescription(string line) + if (LeftArrowPattern.IsMatch(line)) { - return Regex.Replace(line, ":.*", ""); + IList parts = ParseParts(line, LeftArrowPattern); + yield return new PlantUmlDependencyMatcher(parts[1], parts[0]); } } - internal class PlantUmlComponentMatcher + private static IList ParseParts(string line, Regex arrowPattern) { - private Match _componentMatch; - private Match _stereotypeMatch; + line = Regex.Replace(line, ":.*", ""); // remove the optional description + return arrowPattern + .Replace(line, " ") + .Split(' ') + .Select(l => l.Trim()) + .Take(2) + .ToList(); + } + } - public PlantUmlComponentMatcher(string input) - { - _componentMatch = PlantUmlComponentPattern.Match(input); - if (!_componentMatch.Success) - { - throw new InvalidOperationException( - string.Format( - "input {0} does not match pattern {1}", - input, - PlantUmlComponentPattern - ) - ); - } - _stereotypeMatch = StereotypePattern.Match(input); - } + internal class PlantUmlComponentMatcher + { + private static readonly Regex Pattern = new Regex( + @"^\s*\[(?[^\[\]]+)\]\s*" + + PlantUmlPatterns.Stereotypes + + @"\s*(?:as ""?(?[^""]+)""?)?\s*$" + ); - internal string MatchAlias() - { - if (_componentMatch.Groups[AliasGroupName].Success) - { - return _componentMatch.Groups[AliasGroupName].Value; - } - return null; - } + private PlantUmlComponentMatcher(Match match) + { + Name = match.Groups["name"].Value; + Alias = match.Groups["alias"].Success ? match.Groups["alias"].Value : null; + Stereotypes = PlantUmlPatterns.GetStereotypes(match); + } - internal string MatchComponentName() - { - return _componentMatch.Groups[ComponentNameGroupName].Value; - } + internal string Name { get; } + internal string Alias { get; } // null without 'as' + internal ISet Stereotypes { get; } - internal ISet MatchStereoTypes() - { - ISet result = new HashSet(); - while (_stereotypeMatch.Success) - { - result.Add(_stereotypeMatch.Groups[1].Value); - _stereotypeMatch = _stereotypeMatch.NextMatch(); - } - return result; - } + internal static PlantUmlComponentMatcher TryParse(string line) + { + Match match = Pattern.Match(line); + return match.Success ? new PlantUmlComponentMatcher(match) : null; } } } diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs index e8b4ce733..becb7a837 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs @@ -532,6 +532,18 @@ string[] expectedStereotypes ); } + [Fact] + public void DoesNotReadStereotypesFromTheAlias() + { + PlantUmlParsedDiagram diagram = CreateDiagram( + TestDiagram.From(_memoryStream).RawLine("[A] <> as \"<>\"").Write() + ); + + PlantUmlComponent component = diagram.AllComponents.Single(); + Assert.Equal(new[] { new Stereotype("Real.*") }, component.Stereotypes); + Assert.Equal(new Alias("<>"), component.Alias); + } + [Theory] [InlineData("[A <>")] [InlineData("A] <>")]