Skip to content
Merged
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
186 changes: 186 additions & 0 deletions Coder.Test/Languages/TypeMemberDeclarationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Languages;

using ktsu.Coder.Ast;
using ktsu.Coder.Languages;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that an alias or a class declared as a member of a class comes out where the target accepts
/// one, in C# and JavaScript.
/// </summary>
/// <remarks>
/// Neither language takes either declaration in a class body as written at namespace scope: C# has a
/// using alias only at file or namespace scope (CS1513, CS1529, CS1022), and a JavaScript class body
/// holds only methods and fields, so <c>const Id = …</c> and <c>class Inner { }</c> are both syntax
/// errors there.
/// </remarks>
[TestClass]
public class TypeMemberDeclarationTests
{
/// <summary>
/// A class whose field is typed by an alias the class declares.
/// </summary>
private static ClassDeclaration Holder()
{
ClassDeclaration holder = new("Holder");
holder.Members.Add(new UsingAlias("Id", "int"));
holder.Members.Add(new FieldDeclaration("id", "Id") { Visibility = Visibility.Public });
return holder;
}

/// <summary>
/// A class holding another, which holds an alias of its own.
/// </summary>
private static ClassDeclaration Outer()
{
ClassDeclaration inner = new("Inner");
inner.Members.Add(new UsingAlias("Count", "long"));
inner.Members.Add(new FieldDeclaration("count", "Count") { Visibility = Visibility.Public });

ClassDeclaration outer = new("Outer");
outer.Members.Add(inner);
outer.Members.Add(new FieldDeclaration("size", "int") { Visibility = Visibility.Public });
return outer;
}

/// <summary>
/// The project the generated C# is compiled as.
/// </summary>
private const string Project = """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
""";

/// <summary>
/// Tests that an alias a C# type declares is written ahead of the type rather than inside it.
/// </summary>
[TestMethod]
public void CSharp_MemberAlias_IsWrittenAheadOfItsType()
{
string code = new CSharpGenerator().Generate(Holder());

int alias = code.IndexOf("using Id = int;", StringComparison.Ordinal);
int type = code.IndexOf("class Holder", StringComparison.Ordinal);

Assert.IsGreaterThanOrEqualTo(0, alias, code);
Assert.IsLessThan(type, alias, code);
Assert.AreEqual(1, CountOf(code, "using Id"), code);
}

/// <summary>
/// Tests that every alias a namespace holds, its own and those of the types in it, is written
/// ahead of everything else in it, which is the only place C# takes one.
/// </summary>
[TestMethod]
public void CSharp_NamespaceAliases_PrecedeItsTypes()
{
NamespaceDeclaration shapes = new("Shapes");
shapes.Members.Add(Outer());
shapes.Members.Add(new UsingAlias("Name", "string"));

string code = new CSharpGenerator().Generate(shapes);

int type = code.IndexOf("class Outer", StringComparison.Ordinal);
Assert.IsLessThan(type, code.IndexOf("using Count = long;", StringComparison.Ordinal), code);
Assert.IsLessThan(type, code.IndexOf("using Name = string;", StringComparison.Ordinal), code);
Assert.AreEqual(1, CountOf(code, "using Count"), code);
}

/// <summary>
/// Tests that C# accepts a file holding a class-member alias, a nested class with its own alias,
/// and a namespace alias declared after a type.
/// </summary>
[TestMethod]
public void CSharp_MemberAliasesAndNestedClasses_Compile()
{
if (ToolchainHarness.FindOnPath("--version", "dotnet") is null)
{
Assert.Inconclusive("No dotnet on the path, so nothing was compiled.");
return;
}

NamespaceDeclaration shapes = new("Shapes");
shapes.Members.Add(Holder());
shapes.Members.Add(Outer());
shapes.Members.Add(new UsingAlias("Name", "string"));

SourceFile file = new("Generated");
file.Members.Add(shapes);

const string driver = """
public static class Driver
{
public static long Use() =>
new global::Shapes.Holder().id + new global::Shapes.Outer.Inner().count + new global::Shapes.Outer().size;
}
""";

ToolchainHarness.InTemporaryDirectory(directory =>
{
File.WriteAllText(Path.Combine(directory, "Generated.csproj"), Project);
File.WriteAllText(Path.Combine(directory, "Driver.cs"), driver);
File.WriteAllText(Path.Combine(directory, "Generated.cs"), new CSharpGenerator().Generate(file));

(int exitCode, string output) = ToolchainHarness.Run("dotnet", "build -nologo", directory);
Assert.AreEqual(0, exitCode, $"C# rejected the generated source:{Environment.NewLine}{output}");
});
}

/// <summary>
/// Tests that JavaScript writes a member alias and a nested class as static members, the shape a
/// nested enumeration already takes.
/// </summary>
[TestMethod]
public void JavaScript_MemberAliasAndNestedClass_AreStaticMembers()
{
JavaScriptGenerator generator = new();

StringAssert.Contains(generator.Generate(Holder()), "static Id = int;", StringComparison.Ordinal);

Check warning on line 145 in Coder.Test/Languages/TypeMemberDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaE4-jD5reFZwbJUyLbx&open=AaE4-jD5reFZwbJUyLbx&pullRequest=227
StringAssert.Contains(generator.Generate(Outer()), "static Inner = class Inner {", StringComparison.Ordinal);

Check warning on line 146 in Coder.Test/Languages/TypeMemberDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaE4-jD5reFZwbJUyLby&open=AaE4-jD5reFZwbJUyLby&pullRequest=227
}

/// <summary>
/// Tests that Node.js parses a class holding an alias and one holding a nested class.
/// </summary>
[TestMethod]
public void JavaScript_MemberAliasAndNestedClass_Parse()
{
string? node = ToolchainHarness.FindOnPath("--version", "node");
if (node is null)
{
Assert.Inconclusive("No Node.js runtime on the path, so nothing was parsed.");
return;
}

SourceFile file = new("members");
file.Members.Add(Holder());
file.Members.Add(Outer());
string generated = new JavaScriptGenerator().Generate(file);

ToolchainHarness.InTemporaryDirectory(directory =>
{
string path = Path.Combine(directory, "members.mjs");
File.WriteAllText(path, generated);
(int exitCode, string output) = ToolchainHarness.Run(node, $"--check {path}", directory);
Assert.AreEqual(0, exitCode, $"Node.js rejected the generated source:{Environment.NewLine}{generated}{Environment.NewLine}{output}");
});
}

private static int CountOf(string text, string value)
{
int count = 0;
for (int index = text.IndexOf(value, StringComparison.Ordinal); index >= 0; index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal))
{
count++;
}

return count;
}
}
121 changes: 97 additions & 24 deletions Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@
/// </remarks>
private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
{
bool aliasesWereHoisted = memberAliasesHoisted;
if (!aliasesWereHoisted)
{
WriteHoistedAliases(MemberAliases(classDecl), code);
}

WritePreamble(classDecl, code, PreambleSite.Type);

// C++ can attach a declaration to a type it does not own, by specialising a template on it.
Expand Down Expand Up @@ -235,6 +241,7 @@
using Scope members = new(code);
bool enclosingIsReadOnlyStruct = inReadOnlyStruct;
inReadOnlyStruct = isReadOnlyStruct;
memberAliasesHoisted = true;
try
{
foreach (AstNode member in classDecl.Members)
Expand All @@ -243,7 +250,7 @@
{
GenerateFunction(method, code, classDecl.Name);
}
else
else if (member is not UsingAlias)
{
GenerateInternal(member, code);
}
Expand All @@ -252,7 +259,44 @@
finally
{
inReadOnlyStruct = enclosingIsReadOnlyStruct;
memberAliasesHoisted = aliasesWereHoisted;
}
}

/// <summary>
/// Collects the aliases a type and the types nested in it declare as members.
/// </summary>
/// <param name="classDecl">The type.</param>
/// <returns>The aliases, outermost type first and each type's in the order declared.</returns>
/// <remarks>
/// C# has a using alias only at file or namespace scope, never inside a type (CS1513), so these are
/// written in the scope that encloses the type instead. The name then resolves everywhere the type
/// used it, which is what declaring it as a member was for.
/// </remarks>
private static IEnumerable<UsingAlias> MemberAliases(ClassDeclaration classDecl) =>
classDecl.Members.SelectMany(member => member switch
{
UsingAlias alias => [alias],
ClassDeclaration nested => MemberAliases(nested),
_ => [],
});

/// <summary>
/// Writes aliases ahead of the declarations they were lifted out of.
/// </summary>
/// <param name="aliases">The aliases to write.</param>
/// <param name="code">The writer to emit into.</param>
/// <returns>Whether anything was written.</returns>
private bool WriteHoistedAliases(IEnumerable<UsingAlias> aliases, CodeBlocker code)
{
bool any = false;
foreach (UsingAlias alias in aliases)
{
GenerateUsingAlias(alias, code);
any = true;
}

return any;
}

/// <summary>
Expand Down Expand Up @@ -323,7 +367,7 @@
/// The constants on <see cref="double"/>, which every file can name without a <c>using</c>.
/// </remarks>
protected override string SpellNonFiniteDouble(double value) =>
double.IsNaN(value) ? "double.NaN" : value > 0 ? "double.PositiveInfinity" : "double.NegativeInfinity";

Check warning on line 370 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 370 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 370 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 370 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
/// <remarks>
Expand All @@ -331,7 +375,7 @@
/// <c>float</c> implicitly.
/// </remarks>
protected override string SpellNonFiniteSingle(float value) =>
float.IsNaN(value) ? "float.NaN" : value > 0 ? "float.PositiveInfinity" : "float.NegativeInfinity";

Check warning on line 378 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 378 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 378 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 378 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <summary>
/// Emits a namespace and its members.
Expand Down Expand Up @@ -379,41 +423,60 @@
/// and one at file scope with CS0106. Every other target has somewhere to put them, so the AST
/// allows them, and a static class is what C# has in their place — a reader names
/// <c>Geometry.twice</c> where C++ names <c>Geometry::twice</c>. The class is written where the
/// first of them was declared, and types, enums, aliases and nested namespaces stay where they
/// are.
/// first of them was declared, and types, enums and nested namespaces stay where they are. Aliases
/// come first, those of the scope's types with them, because C# takes them nowhere else.
/// </remarks>
private void GenerateScopeMembers(IEnumerable<AstNode> members, string holderName, CodeBlocker code)
{
AstNode[] declared = [.. members];
AstNode[] typeless = [.. declared.Where(IsTypelessMember)];
bool first = true;
bool holderWritten = false;

foreach (AstNode member in declared)
// Every alias the scope holds goes first, its own and those lifted out of its types: C# takes
// a using alias only ahead of everything else declared in a namespace or file (CS1529).
IEnumerable<UsingAlias> aliases = declared.SelectMany(member => member switch
{
bool isTypeless = IsTypelessMember(member);
if (isTypeless && holderWritten)
{
continue;
}
UsingAlias alias => [alias],
ClassDeclaration type => MemberAliases(type),
_ => [],
});

if (!first)
bool first = !WriteHoistedAliases(aliases, code);

bool aliasesWereHoisted = memberAliasesHoisted;
memberAliasesHoisted = true;
try
{
foreach (AstNode member in declared)
{
code.NewLine();
}
bool isTypeless = IsTypelessMember(member);
if ((isTypeless && holderWritten) || member is UsingAlias)
{
continue;
}

first = false;
if (!first)
{
code.NewLine();
}

if (isTypeless)
{
holderWritten = true;
GenerateMemberHolder(UnclaimedName(holderName, declared), typeless, code);
}
else
{
GenerateInternal(member, code);
first = false;

if (isTypeless)
{
holderWritten = true;
GenerateMemberHolder(UnclaimedName(holderName, declared), typeless, code);
}
else
{
GenerateInternal(member, code);
}
}
}
finally
{
memberAliasesHoisted = aliasesWereHoisted;
}
}

/// <summary>
Expand Down Expand Up @@ -485,8 +548,8 @@
/// <param name="code">The writer to emit into.</param>
/// <remarks>
/// C# has a using alias but only at file or namespace scope, never inside a type. One declared as
/// a member is therefore said rather than written, since a generated file that silently drops it
/// looks complete and is not.
/// a member is lifted to the scope enclosing its type rather than written where it stands; see
/// <see cref="MemberAliases"/>.
/// </remarks>
private void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code)
{
Expand Down Expand Up @@ -976,6 +1039,16 @@
/// </remarks>
private bool inReadOnlyStruct;

/// <summary>
/// Whether the aliases declared in the types being written have already been written in front of
/// them, by the scope that encloses those types or by an enclosing type.
/// </summary>
/// <remarks>
/// A type generated on its own has no enclosing scope to lift its aliases into, so it writes them
/// in front of itself; one inside a namespace, a file or another type leaves them to that.
/// </remarks>
private bool memberAliasesHoisted;

/// <summary>
/// The member of a namespace or file being written into the static class that holds it, which
/// is written static whether or not it says so.
Expand Down
Loading
Loading