Skip to content
Open
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
202 changes: 202 additions & 0 deletions Coder.Test/Languages/ReservedWordIdentifierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// 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>
/// Compiles and runs what the Rust and C++ generators write for names that are keywords of the
/// target: a property called <c>Type</c> or <c>Default</c>, which are ordinary C# names, and fields,
/// parameters and locals called <c>where</c>, <c>virtual</c>, <c>match</c>, <c>delete</c>,
/// <c>loop</c> and <c>new</c>. Each name has to come out escaped where it is declared and spelled the
/// same way wherever it is used, or the output either fails to parse or fails to resolve.
/// </summary>
/// <remarks>
/// The tests are inconclusive rather than failing where no compiler is on the path, which is the
/// honest result: nothing was checked.
/// </remarks>
[TestClass]
public class ReservedWordIdentifierTests
{
private static readonly string[] CppCompilers = ["c++", "g++", "clang++"];

/// <summary>
/// The Rust driver. <c>r#</c> is how a Rust caller names a keyword, so it reads the escaped names
/// back exactly as a user of the generated type would.
/// </summary>
private const string RustDriver = """

fn main() {
let mut holder = Holder::make();
if holder.r#where != 3 || holder.r#virtual != 4 { std::process::exit(1); }
if holder.r#type() != 1 { std::process::exit(2); }
if holder.default() != 2 { std::process::exit(3); }
if holder.total(5, 6) != 14 { std::process::exit(4); }
}

""";

/// <summary>
/// The C++ driver. C++ has no escape for a keyword, so an escaped name has had an underscore
/// appended, and that is the name a caller uses.
/// </summary>
private const string CppDriver = """
#include "holder.hpp"

int main()
{
Holder holder = Holder::make();
if (holder.where != 3 || holder.virtual_ != 4) { return 1; }
if (holder.type() != 1) { return 2; }
if (holder.default_() != 2) { return 3; }
if (holder.total(5, 6) != 15) { return 4; }
return 0;
}

""";

/// <summary>
/// Tests that Rust names a keyword-named field, getter, parameter and local as raw identifiers,
/// at their declarations and where they are used.
/// </summary>
[TestMethod]
public void Rust_KeywordNamesCompileAndResolve()
{
if (ToolchainHarness.FindOnPath("--version", "rustc") is null)
{
Assert.Inconclusive("No Rust compiler on the path, so nothing was compiled.");
return;
}

ToolchainHarness.InTemporaryDirectory(directory =>
{
string program = OperatingSystem.IsWindows() ? "holder.exe" : "holder";
string source = new RustGenerator().Generate(Types("self.where")) + RustDriver;
File.WriteAllText(Path.Combine(directory, "holder.rs"), source);

(int built, string output) = ToolchainHarness.Run("rustc", $"--edition 2021 -o {program} holder.rs", directory);
Assert.AreEqual(0, built, $"rustc rejected the generated source:{Environment.NewLine}{output}{Environment.NewLine}{source}");

(int ran, _) = ToolchainHarness.Run(Path.Combine(directory, program), string.Empty, directory);
Assert.AreEqual(0, ran, $"a keyword-named member read back wrongly (driver check {ran}):{Environment.NewLine}{source}");
});
}

/// <summary>
/// Tests that C++ appends an underscore to a keyword-named field, getter, parameter and local,
/// at their declarations and where they are used.
/// </summary>
[TestMethod]
public void Cpp_KeywordNamesCompileAndResolve()
{
string? compiler = ToolchainHarness.FindOnPath("--version", CppCompilers);
if (compiler is null)
{
Assert.Inconclusive("No C++ compiler on the path, so nothing was compiled.");
return;
}

ToolchainHarness.InTemporaryDirectory(directory =>
{
string program = OperatingSystem.IsWindows() ? "holder.exe" : "holder";
string header = new CppGenerator().Generate(Types("virtual"));
File.WriteAllText(Path.Combine(directory, "holder.hpp"), header);
File.WriteAllText(Path.Combine(directory, "driver.cpp"), CppDriver);

(int built, string output) = ToolchainHarness.Run(
compiler,
$"-std=c++20 -Wall -Wextra -pedantic -o {program} driver.cpp",
directory);
Assert.AreEqual(0, built, $"{compiler} rejected the generated header:{Environment.NewLine}{output}{Environment.NewLine}{header}");

(int ran, _) = ToolchainHarness.Run(Path.Combine(directory, program), string.Empty, directory);
Assert.AreEqual(0, ran, $"a keyword-named member read back wrongly (driver check {ran}):{Environment.NewLine}{header}");
});
}

/// <summary>
/// Tests that a reference keeps a receiver as written and escapes only the plain names after it.
/// </summary>
[TestMethod]
public void Rust_ReferenceKeepsTheReceiverAndEscapesTheMember()
{
FunctionDeclaration read = new("read") { ReturnType = "int" };
read.Body.Add(new ReturnStatement(new VariableReference("self.type")));
ClassDeclaration holder = new("Holder") { Kind = TypeDeclarationKind.Struct };
holder.Members.Add(new VariableDeclaration("type", "int"));
holder.Members.Add(read);
SourceFile file = new("holder");
file.Members.Add(holder);

string source = new RustGenerator().Generate(file);

Assert.Contains("pub r#type: i32,", source, StringComparison.Ordinal, source);
Assert.Contains("self.r#type", source, StringComparison.Ordinal, source);
}

/// <summary>
/// Tests that the path keywords, which Rust refuses as raw identifiers, are given an underscore
/// instead when they name a declaration.
/// </summary>
[TestMethod]
public void Rust_PathKeywordNamesAreSuffixed()
{
FunctionDeclaration function = new("super") { ReturnType = "int" };
function.Parameters.Add(new Parameter("crate", "int"));
function.Body.Add(new ReturnStatement(new VariableReference("crate")));
SourceFile file = new("paths");
file.Members.Add(function);

string source = new RustGenerator().Generate(file);

Assert.Contains("fn super_(crate_: i32)", source, StringComparison.Ordinal, source);
Assert.DoesNotContain("r#super", source, StringComparison.Ordinal, source);
}

/// <summary>
/// Builds a struct whose members are all named after a keyword of one target or the other.
/// </summary>
/// <param name="fieldReference">How the target's method body refers to one of the struct's own fields.</param>
/// <returns>The file to generate.</returns>
private static SourceFile Types(string fieldReference)
{
ClassDeclaration holder = new("Holder") { Kind = TypeDeclarationKind.Struct };
holder.Members.Add(new VariableDeclaration("where", "int"));
holder.Members.Add(new VariableDeclaration("virtual", "int"));
holder.Members.Add(new PropertyDeclaration("Type", "int")
{
GetterBody = { new ReturnStatement(new LiteralExpression<int>(1)) },
});
holder.Members.Add(new PropertyDeclaration("Default", "int")
{
GetterBody = { new ReturnStatement(new LiteralExpression<int>(2)) },
});

ConstructionExpression built = new(new TypeReference("Holder"));
built.Arguments.Add(new MemberInitialiser("where", new LiteralExpression<int>(3)));
built.Arguments.Add(new MemberInitialiser("virtual", new LiteralExpression<int>(4)));
FunctionDeclaration make = new("make") { ReturnType = "Holder", IsStatic = true };
make.Body.Add(new ReturnStatement(built));
holder.Members.Add(make);

FunctionDeclaration total = new("total") { ReturnType = "int" };
total.Parameters.Add(new Parameter("match", "int"));
total.Parameters.Add(new Parameter("delete", "int"));
total.Body.Add(new VariableDeclaration(
"loop",
"int",
new BinaryExpression(new VariableReference("match"), BinaryOperator.Add, new VariableReference("delete"))));
total.Body.Add(new VariableDeclaration(
"new",
"int",
new BinaryExpression(new VariableReference("loop"), BinaryOperator.Add, new VariableReference(fieldReference))));
total.Body.Add(new ReturnStatement(new VariableReference("new")));
holder.Members.Add(total);

SourceFile file = new("holder");
file.Members.Add(holder);
return file;
}
}
2 changes: 1 addition & 1 deletion Coder/Languages/CFamilyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ protected string SpellDeclarator(TypeReference type, string name)
element.IsArray = false;
}

string declared = name.Length == 0 ? string.Empty : $" {name}";
string declared = name.Length == 0 ? string.Empty : $" {EscapeIdentifier(name)}";

return $"{SpellType(element)}{declared}{(type.IsArray ? "[]" : string.Empty)}";
}
Expand Down
34 changes: 30 additions & 4 deletions Coder/Languages/CppGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ private static void WriteTemplateHead(IEnumerable<TypeParameter> parameters, Cod
}
}

/// <inheritdoc/>
/// <remarks>
/// The C++20 keywords and the alternative operator tokens (<c>and</c>, <c>not</c> and the rest),
/// which the standard reserves just as firmly. An escaped name has an underscore appended, which
/// is a rename: C++ has no way to use a keyword as a name.
/// </remarks>
protected override IReadOnlySet<string> ReservedWords { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", "bool", "break", "case",
"catch", "char", "char8_t", "char16_t", "char32_t", "class", "compl", "concept", "const", "consteval",
"constexpr", "constinit", "const_cast", "continue", "co_await", "co_return", "co_yield", "decltype",
"default", "delete", "do", "double", "dynamic_cast", "else", "enum", "explicit", "export", "extern",
"false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace",
"new", "noexcept", "not", "not_eq", "nullptr", "operator", "or", "or_eq", "private", "protected",
"public", "register", "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", "static",
"static_assert", "static_cast", "struct", "switch", "template", "this", "thread_local", "throw", "true",
"try", "typedef", "typeid", "typename", "union", "unsigned", "using", "virtual", "void", "volatile",
"wchar_t", "while", "xor", "xor_eq",
};

/// <inheritdoc/>
/// <remarks>
/// <c>this</c> and the literal words, which a reference written as text means as C++ means them.
/// </remarks>
protected override bool IsReceiverWord(string word) => word is "this" or "true" or "false" or "nullptr";

/// <inheritdoc/>
/// <remarks>
/// The standard library spells an invented accessor <c>set_value</c>, and that is the convention a reader of any C++ header already has.
Expand Down Expand Up @@ -352,7 +378,7 @@ private void WriteInitialiserList(FunctionDeclaration funcDecl, CodeBlocker code
}

MemberInitialiser initialiser = funcDecl.Initialisers[index];
code.Write($"{initialiser.Name}(");
code.Write($"{EscapeIdentifier(initialiser.Name ?? string.Empty)}(");

if (initialiser.Value is not null)
{
Expand All @@ -372,7 +398,7 @@ private void WriteInitialiserList(FunctionDeclaration funcDecl, CodeBlocker code
/// <param name="funcDecl">The declaration being emitted.</param>
/// <param name="enclosingType">The name of the type it belongs to, when it belongs to one.</param>
/// <returns>The name as C++ writes it.</returns>
private static string SpellFunctionName(FunctionDeclaration funcDecl, string? enclosingType)
private string SpellFunctionName(FunctionDeclaration funcDecl, string? enclosingType)
{
string typeName = enclosingType ?? funcDecl.Name ?? "UnnamedType";

Expand All @@ -383,7 +409,7 @@ private static string SpellFunctionName(FunctionDeclaration funcDecl, string? en
FunctionKind.Operator => $"operator{funcDecl.Name}",
FunctionKind.ConversionOperator =>
$"operator {MapToCppType(funcDecl.ReturnType ?? new TypeReference("void"))}",
_ => funcDecl.Name ?? "unnamedFunction",
_ => EscapeIdentifier(funcDecl.Name ?? "unnamedFunction"),
};
}

Expand Down Expand Up @@ -835,7 +861,7 @@ private string SpellVariableDeclarator(VariableDeclaration varDecl)
return SpellDeclarator(declared, varDecl.Name);
}

return $"{(varDecl.InitialValue is not null ? "auto" : "std::any")} {varDecl.Name}";
return $"{(varDecl.InitialValue is not null ? "auto" : "std::any")} {EscapeIdentifier(varDecl.Name)}";
}

/// <summary>
Expand Down
72 changes: 71 additions & 1 deletion Coder/Languages/LanguageGeneratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ protected bool TryGenerateCommonNode(AstNode node, CodeBlocker code)
switch (node)
{
case VariableReference varRef:
code.Write(varRef.Name);
code.Write(EscapeReference(varRef.Name));
return true;

case LiteralExpression<string> stringLit:
Expand Down Expand Up @@ -1127,6 +1127,76 @@ or AstLeafNode<int>
protected static Visibility VisibilityOf(AstNode node) =>
node is IHasVisibility declaration ? declaration.Visibility : Visibility.Unspecified;

/// <summary>
/// Gets the words the target language reserves, which no declared name may be spelled as.
/// </summary>
/// <remarks>
/// The AST is language-neutral, so a name that is ordinary in one target is a keyword in another:
/// a property called <c>Type</c> becomes a getter <c>type</c> in snake case, which Rust reserves,
/// and one called <c>Default</c> becomes <c>default</c>, which C++ does. Empty by default, so a
/// generator that declares nothing escapes nothing.
/// </remarks>
protected virtual IReadOnlySet<string> ReservedWords { get; } = new HashSet<string>(StringComparer.Ordinal);

/// <summary>
/// Spells a declared name so the target does not read it as one of its own keywords.
/// </summary>
/// <param name="name">The name as the AST holds it.</param>
/// <returns>The name unchanged, or escaped when it is one of <see cref="ReservedWords"/>.</returns>
/// <remarks>
/// Appends an underscore, which is the only escape most targets have: <c>default</c> becomes
/// <c>default_</c>. A target with a real escape — Rust's raw identifiers — overrides this. Every
/// site that writes a name, at its declaration and wherever it is used, goes through here or
/// through <see cref="EscapeReference"/>, so the two cannot come out spelled differently.
/// </remarks>
protected virtual string EscapeIdentifier(string name)
{
Ensure.NotNull(name);
return ReservedWords.Contains(name) ? $"{name}_" : name;
}

/// <summary>
/// Tells whether a word leading a reference means what the language means by it.
/// </summary>
/// <param name="word">The first dotted segment of a <see cref="VariableReference"/>.</param>
/// <returns>True when the word is left as written there; false when it is escaped like any other name.</returns>
/// <remarks>
/// A reference is text, and <c>self.x</c> or <c>this</c> names the receiver rather than something
/// the AST declared. Such a word is a keyword, but escaping it would break the reference.
/// </remarks>
protected virtual bool IsReceiverWord(string word) => false;

/// <summary>
/// Spells a reference so any segment naming a declared member escapes as its declaration did.
/// </summary>
/// <param name="reference">The reference's text, possibly a dotted member access such as <c>self.type</c>.</param>
/// <returns>The reference with each reserved segment escaped.</returns>
/// <remarks>
/// Only a segment that is exactly a reserved word changes. Anything else — a call such as
/// <c>args.len()</c>, an index such as <c>steps[0]</c>, a path such as <c>Self::CAPACITY</c> — is
/// written as given, since a reference is text and only its plain names are the AST's.
/// </remarks>
protected string EscapeReference(string reference)
{
Ensure.NotNull(reference);

if (ReservedWords.Count == 0)
{
return reference;
}

string[] segments = reference.Split('.');
for (int i = 0; i < segments.Length; i++)
{
if (ReservedWords.Contains(segments[i]) && !(i == 0 && IsReceiverWord(segments[i])))
{
segments[i] = EscapeIdentifier(segments[i]);
}
}

return string.Join(".", segments);
}

/// <summary>
/// Escapes a string literal's contents for a target language using C-style backslash escapes.
/// </summary>
Expand Down
Loading
Loading