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 ecd7ab241..becb7a837 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlParserTest.cs @@ -477,6 +477,167 @@ 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()) + ); + } + + [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] <>")] + [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 +688,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