using System.Text.RegularExpressions;
namespace OpenNest.Tests;
///
/// OpenNest.Math and OpenNest.Geometry are the bottom layers of OpenNest.Core: Math
/// uses nothing else in OpenNest, and Geometry uses only Math plus the root-namespace types in
/// . Namespaces inside one assembly are not checked by the
/// compiler, so this scans the sources against the type names reflection finds in Core.
///
/// It is a token scan, not a binder: it catches a higher type being named, which is how every
/// dependency so far has arrived. It cannot see a higher type reached without naming it (an
/// extension method declared in the root OpenNest namespace, which child namespaces see
/// without a using).
///
///
public class CoreLayeringTests
{
private const string MathNamespace = "OpenNest.Math";
private const string GeometryNamespace = "OpenNest.Geometry";
///
/// Root-namespace (OpenNest) types Geometry is allowed to name. They stay in the root
/// namespace because moving them would rename public types external plug-ins bind to;
/// keeps them from pulling a
/// higher layer in behind Geometry's back. Do not grow this list to make a failure go away:
/// move the offending code up a layer instead.
///
private static readonly string[] RootTypesGeometryMayUse =
[
"OffsetSide",
"RotationType",
"PushDirection",
"RelativePosition",
"PerfCounters",
"SpecialLayers",
];
///
/// Higher-layer type names the scan cannot tell from something else of the same name, each
/// with the reason. Names that Math/Geometry declare themselves are not listed here: inside
/// those namespaces C# binds the simple name to the nearer declaration, so the scan drops
/// them on its own (see ).
///
private static readonly Dictionary AmbiguousNames = new()
{
// SpecialLayers.Tab is a Layer field. The Tab class lives in OpenNest.CNC.CuttingStrategy,
// which no scanned file can reach without a using the namespace rule already reports.
["Tab"] = "SpecialLayers.Tab field",
};
private static readonly Regex NamespaceReference = new(@"(?First segments of Core's namespaces under OpenNest: Math, Geometry, CNC...
private static readonly HashSet ChildNamespaces = typeof(Plate).Assembly.GetTypes()
.Select(type => type.Namespace)
.Where(name => name != null && name.StartsWith("OpenNest.", StringComparison.Ordinal))
.Select(name => name!.Split('.')[1])
.ToHashSet(StringComparer.Ordinal);
[Fact]
public void Math_ReferencesNothingElseInOpenNest()
{
var higher = HigherTypeNames(lowerNamespaces: [MathNamespace], shadowingNamespace: MathNamespace);
var violations = SourceFiles("Math")
.SelectMany(file => Violations(file, allowedNamespaces: ["Math"], higher))
.ToList();
Assert.True(violations.Count == 0, Report("OpenNest.Math must not reference the rest of OpenNest", violations));
}
[Fact]
public void Geometry_ReferencesOnlyMathAndAllowlistedRootTypes()
{
var higher = HigherTypeNames(lowerNamespaces: [MathNamespace, GeometryNamespace], shadowingNamespace: GeometryNamespace);
higher.ExceptWith(RootTypesGeometryMayUse);
var violations = SourceFiles("Geometry")
.SelectMany(file => Violations(file, allowedNamespaces: ["Math", "Geometry"], higher))
.ToList();
Assert.True(violations.Count == 0, Report("OpenNest.Geometry may reference only OpenNest.Math and the allowlisted root types", violations));
}
[Fact]
public void AllowlistedRootTypes_ReferenceOnlyMathAndGeometry()
{
// These files sit in the root namespace, where a root type wins over a same-named
// Geometry type, so nothing is shadowed here.
var higher = HigherTypeNames(lowerNamespaces: [MathNamespace, GeometryNamespace], shadowingNamespace: null);
higher.ExceptWith(RootTypesGeometryMayUse);
var coreDirectory = CoreDirectory();
var rootFiles = Directory.GetFiles(coreDirectory, "*.cs", SearchOption.TopDirectoryOnly)
.Select(path => (Path: path, Code: StripCommentsAndLiterals(File.ReadAllText(path))))
.ToList();
var violations = new List();
foreach (var name in RootTypesGeometryMayUse)
{
var type = typeof(Plate).Assembly.GetType("OpenNest." + name);
Assert.True(type != null, $"Allowlisted type OpenNest.{name} no longer exists in OpenNest.Core; remove it from the allowlist.");
var declaration = new Regex(@"\b(?:enum|class|struct|interface|record)\s+" + Regex.Escape(name) + @"\b");
var declaring = rootFiles.Where(file => declaration.IsMatch(file.Code)).ToList();
Assert.True(declaring.Count == 1, $"Expected exactly one file in OpenNest.Core's root declaring {name}, found {declaring.Count}.");
violations.AddRange(Violations(declaring[0].Path, allowedNamespaces: ["Math", "Geometry"], higher));
}
Assert.True(violations.Count == 0, Report("Types Geometry is allowed to use may reference only OpenNest.Math and OpenNest.Geometry", violations));
}
[Fact]
public void AmbiguousNames_AreStillAmbiguous()
{
// An exclusion that no longer names a higher Core type is dead weight hiding nothing.
var higher = HigherTypeNames(lowerNamespaces: [MathNamespace, GeometryNamespace], shadowingNamespace: null, applyExclusions: false);
Assert.Empty(AmbiguousNames.Keys.Where(name => !higher.Contains(name)));
}
///
/// Simple names of Core's top-level types outside . Nested
/// types are left out: they are only reachable through their outer type, which is in the set.
/// Names that also declares are left out too, because
/// code in that namespace binds the simple name to its own type before any outer namespace.
///
private static HashSet HigherTypeNames(string[] lowerNamespaces, string? shadowingNamespace, bool applyExclusions = true)
{
var types = typeof(Plate).Assembly.GetTypes()
.Where(type => !type.IsNested && type.Namespace != null && !type.Name.Contains('<'))
.ToList();
var higher = types
.Where(type => !lowerNamespaces.Contains(type.Namespace))
.Select(SimpleName)
.ToHashSet(StringComparer.Ordinal);
if (shadowingNamespace != null)
higher.ExceptWith(types.Where(type => type.Namespace == shadowingNamespace).Select(SimpleName));
if (applyExclusions)
higher.ExceptWith(AmbiguousNames.Keys);
return higher;
}
private static string SimpleName(Type type)
{
var tick = type.Name.IndexOf('`');
return tick < 0 ? type.Name : type.Name[..tick];
}
private static IEnumerable Violations(string path, string[] allowedNamespaces, HashSet higherTypeNames)
{
var code = StripCommentsAndLiterals(File.ReadAllText(path));
var file = Path.GetRelativePath(RepositoryRoot(), path).Replace('\\', '/');
foreach (Match match in NamespaceReference.Matches(code))
{
var name = match.Groups[1].Value;
if (!allowedNamespaces.Contains(name))
yield return $"{file}:{LineOf(code, match.Index)}: OpenNest.{name}";
}
foreach (Match match in Identifier.Matches(code))
{
// A name after a dot is a member (entity.Layer) or was qualified, and a qualified
// OpenNest.X is already reported above.
if (FollowsDot(code, match.Index))
continue;
if (higherTypeNames.Contains(match.Value))
yield return $"{file}:{LineOf(code, match.Index)}: {match.Value}";
// Code inside OpenNest.* can also qualify from a sibling namespace: CNC.Program.
else if (ChildNamespaces.Contains(match.Value) && !allowedNamespaces.Contains(match.Value)
&& PrecedesDot(code, match.Index + match.Length))
yield return $"{file}:{LineOf(code, match.Index)}: {match.Value}. (namespace OpenNest.{match.Value})";
}
}
private static bool FollowsDot(string code, int index)
{
var i = index - 1;
while (i >= 0 && char.IsWhiteSpace(code[i]))
i--;
return i >= 0 && code[i] == '.';
}
private static bool PrecedesDot(string code, int index)
{
while (index < code.Length && char.IsWhiteSpace(code[index]))
index++;
return index < code.Length && code[index] == '.';
}
private static int LineOf(string code, int index)
{
var line = 1;
for (var i = 0; i < index; i++)
if (code[i] == '\n')
line++;
return line;
}
private static string Report(string rule, List violations) =>
$"{rule}. Offending references (file:line: identifier):{Environment.NewLine}{string.Join(Environment.NewLine, violations)}";
private static IEnumerable SourceFiles(string coreSubdirectory)
{
var directory = Path.Combine(CoreDirectory(), coreSubdirectory);
var files = Directory.GetFiles(directory, "*.cs", SearchOption.AllDirectories);
Assert.NotEmpty(files);
return files.OrderBy(path => path, StringComparer.Ordinal);
}
private static string CoreDirectory() => Path.Combine(RepositoryRoot(), "OpenNest.Core");
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
directory = directory.Parent;
Assert.NotNull(directory);
return directory.FullName;
}
///
/// Blanks comments, string literals and char literals with spaces, keeping every offset and
/// line break so matches report true line numbers. Interpolation holes of ordinary and
/// verbatim strings are code and are kept; raw string literals are blanked whole.
///
internal static string StripCommentsAndLiterals(string source)
{
var output = source.ToCharArray();
var position = 0;
ScanCode(source, output, ref position, untilClosingBrace: false);
return new string(output);
}
private static void ScanCode(string source, char[] output, ref int i, bool untilClosingBrace)
{
var depth = 0;
while (i < source.Length)
{
var c = source[i];
var next = i + 1 < source.Length ? source[i + 1] : '\0';
if (c == '/' && next == '/')
{
while (i < source.Length && source[i] != '\n')
Blank(source, output, i++);
}
else if (c == '/' && next == '*')
{
var end = source.IndexOf("*/", i + 2, StringComparison.Ordinal);
end = end < 0 ? source.Length : end + 2;
while (i < end)
Blank(source, output, i++);
}
else if (c == '\'')
{
Blank(source, output, i++);
while (i < source.Length && source[i] != '\'')
{
if (source[i] == '\\')
Blank(source, output, i++);
if (i < source.Length)
Blank(source, output, i++);
}
if (i < source.Length)
Blank(source, output, i++);
}
else if (c == '"' || ((c == '$' || c == '@') && StartsString(source, i)))
{
ScanString(source, output, ref i);
}
else if (untilClosingBrace && c == '{')
{
depth++;
i++;
}
else if (untilClosingBrace && c == '}')
{
if (depth == 0)
return;
depth--;
i++;
}
else
{
i++;
}
}
}
private static bool StartsString(string source, int i)
{
while (i < source.Length && (source[i] == '$' || source[i] == '@'))
i++;
return i < source.Length && source[i] == '"';
}
private static void ScanString(string source, char[] output, ref int i)
{
var interpolated = false;
var verbatim = false;
for (; source[i] != '"'; i++)
{
interpolated |= source[i] == '$';
verbatim |= source[i] == '@';
Blank(source, output, i);
}
var quotes = 0;
while (i + quotes < source.Length && source[i + quotes] == '"')
quotes++;
if (!verbatim && quotes >= 3)
{
var end = source.IndexOf(new string('"', quotes), i + quotes, StringComparison.Ordinal);
end = end < 0 ? source.Length : end + quotes;
while (end < source.Length && source[end] == '"')
end++;
while (i < end)
Blank(source, output, i++);
return;
}
Blank(source, output, i++);
while (i < source.Length)
{
var c = source[i];
var next = i + 1 < source.Length ? source[i + 1] : '\0';
if (verbatim && c == '"' && next == '"')
{
Blank(source, output, i++);
Blank(source, output, i++);
}
else if (c == '"')
{
Blank(source, output, i++);
return;
}
else if (!verbatim && c == '\\')
{
Blank(source, output, i++);
if (i < source.Length)
Blank(source, output, i++);
}
else if (interpolated && (c == '{' || c == '}') && next == c)
{
Blank(source, output, i++);
Blank(source, output, i++);
}
else if (interpolated && c == '{')
{
Blank(source, output, i++);
ScanCode(source, output, ref i, untilClosingBrace: true);
if (i < source.Length)
Blank(source, output, i++);
}
else
{
Blank(source, output, i++);
}
}
}
private static void Blank(string source, char[] output, int i)
{
if (source[i] != '\n' && source[i] != '\r')
output[i] = ' ';
}
}