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
29 changes: 24 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ dotnet run --project Coder.Editor
## Project Structure

`ktsu.Coder` represents code as a language-agnostic AST, round-trips it through YAML, and generates
source in four target languages. The solution uses:
source in five target languages. The solution uses:

- **ktsu.Sdk** — custom SDK providing shared build configuration
- **MSTest.Sdk** — test project SDK with Microsoft Testing Platform
Expand Down Expand Up @@ -78,7 +78,8 @@ source in four target languages. The solution uses:
that it is an array, with no bound, because where the brackets go is the generator's business and
C++ is the one language here that puts them on the declarator rather than the type.
`IsConstant` is the intent rather than the keyword: C++ writes `inline constexpr` at namespace
scope and `static constexpr` inside a type, C# writes `static readonly`, and a language with no
scope and `static constexpr` inside a type, C# writes `static readonly`, C writes `static const`
at file scope and a note inside a struct, having no static data member at all, and a language with no
spelling for it omits it the way it omits an indirection.
- `Coder/Ast/ClassDeclaration.cs`'s `SpecialisationArguments` — what makes a declaration be *for* a
type rather than *of* one. `template<> struct Describe<RigidBody>` is how C++ attaches a fact to a
Expand All @@ -88,15 +89,33 @@ source in four target languages. The solution uses:
`CompileTimeAssertion`; the arguments are `TypeReference` rather than text, though, because a
specialisation argument is a type and the comma in `Result<Handle, Error>` belongs to one of them
rather than separating two.
- `Coder/Ast/CompileTimeAssertion.cs` — what a generated type promises that the type itself cannot
- `Coder/Ast/CompileTimeAssertion.cs` — what a generated type promises that the type itself cannot
say. Its `Condition` is text for the same reason `SourceFile.Imports` are: a compile-time predicate
is language-specific in a way most of the AST is not, and there is no shared idea underneath
`std::is_trivially_copyable_v<T>` to model. Only C++ has one; the others write a comment, because a
file that quietly loses a guarantee looks like one that still makes it.
`std::is_trivially_copyable_v<T>` to model. Only C++ and C have one — `static_assert` and
`_Static_assert`, the second of which requires a message, so an assertion with none is given its
own condition; the others write a comment, because a file that quietly loses a guarantee looks
like one that still makes it.
- `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares.
- `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived
generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately
does not derive from it.
- `Coder/Languages/CFamilyGenerator.cs` — what C and C++ share beyond what every generator shares,
and all of it is about C: the preprocessor (`#pragma once`, `#include`), the braced list with its
designated initialisers, the declarator that puts an array's brackets after the name, and the rule
that two undocumented members of a kind stay in one block. The type mappings deliberately stay
with each generator, because `str` is a `std::string` in one language and a `const char*` in the
other and the whole of what a mapping is is the spelling.
- `Coder/Languages/CGenerator.cs` — the target with the least to map onto, and so the one whose
decisions are worth reading. C has no classes, namespaces, overloading or generics, so a type is a
`typedef struct`, a member function is a free function taking the instance, an interface is a
struct of function pointers, a base type is the first member (which is what makes the two
layout-compatible), and a namespace is a comment — folding its name into the declarations would
rename them without renaming the references to them. The dialect is C99 plus C11's
`_Static_assert`, which is why a pure function gets no `[[nodiscard]]` and an enumeration no fixed
underlying type. `Coder.Test/Languages/CGeneratedSourceCompilesTests.cs` compiles what it writes,
because C's rules about linkage, empty parameter lists and what may initialise an object with
static storage duration are not visible in the text.
- `Coder.Graph/AstSchema.cs` — the uniform view of the AST's parent/child structure, hand-written
rather than reflective. Adding a node type means adding it here.
- `Coder.Graph/AstFields.cs` — a node's editable properties as named fields of a kind, which is what
Expand Down
19 changes: 16 additions & 3 deletions Coder.Test/Ast/CompileTimeAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ namespace ktsu.Coder.Test.Ast;
/// cannot say.
/// </summary>
/// <remarks>
/// Only C++ has anything checked before the program runs, so this is the clearest case of the rule
/// the whole AST follows — say what is true, and let each language say as much of it as it can. The
/// other three write a comment rather than dropping it, because a file that quietly loses a
/// Only C++ and C have anything checked before the program runs, so this is the clearest case of the
/// rule the whole AST follows — say what is true, and let each language say as much of it as it can.
/// The other three write a comment rather than dropping it, because a file that quietly loses a
/// guarantee looks exactly like one that still makes it.
/// </remarks>
[TestClass]
Expand Down Expand Up @@ -59,6 +59,19 @@ public void Cpp_EscapesTheMessage()
Assert.Contains("\\\"T\\\"", new CppGenerator().Generate(assertion), StringComparison.Ordinal);
}

/// <summary>
/// C writes it too, under C11's own spelling — and always with a message, which C11 requires and
/// which an assertion that carries none is given from its own condition.
/// </summary>
[TestMethod]
public void C_WritesTheAssertionWithAMessageEitherWay()
{
Assert.AreEqual(
"_Static_assert(sizeof(Handle) == 8,\n"
+ " \"sizeof(Handle) == 8\");\n",
new CGenerator().Generate(new CompileTimeAssertion("sizeof(Handle) == 8")).ReplaceLineEndings("\n"));
}

/// <summary>
/// The other three have nothing checked before the program runs, so they say what was asserted
/// rather than dropping it.
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Editor/CoderEditorAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private static DocumentStore NewStore() =>
private string PathIn(string name) => Path.Combine(root, name + DocumentStore.Extension);

private static CoderEditorApp NewApp(DocumentStore store, EditorSettings? settings = null) =>
new(store, [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new JavaScriptGenerator()],
new(store, [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new JavaScriptGenerator()],
settings ?? new EditorSettings());

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Editor/EditorWiringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public sealed class EditorWiringTests
{
private const string ConfigHomeVariable = "XDG_CONFIG_HOME";

private static readonly string[] ExpectedLanguageIds = ["python", "csharp", "javascript", "cpp"];
private static readonly string[] ExpectedLanguageIds = ["python", "csharp", "javascript", "cpp", "c"];
private static readonly string[] ExpectedRecentFiles = ["/work/second.coder.yaml", "/work/first.coder.yaml"];

private string root = string.Empty;
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Editor/GeneratedCodeHighlightingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ private static FunctionDeclaration SampleFunction()
}

private static ILanguageGenerator[] Generators() =>
[new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new JavaScriptGenerator()];
[new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new JavaScriptGenerator()];

/// <summary>
/// Tests that every generator's language id is one the highlighter recognises, by generating real
Expand Down
236 changes: 236 additions & 0 deletions Coder.Test/Languages/CGeneratedSourceCompilesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Languages;

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

/// <summary>
/// Compiles what <see cref="CGenerator"/> writes, with a real C compiler.
/// </summary>
/// <remarks>
/// Every other test here pins a spelling, and a spelling can be pinned and still be wrong: C's rules
/// about what may initialise an object with static storage duration, what an empty parameter list
/// declares, and what a file-scope <c>const</c> links as are not visible in the text at all. The only
/// thing that knows them is a compiler.
/// <para>
/// The header is included twice on purpose. That is what a header is for, and it is what makes
/// <c>#pragma once</c> and the <c>static const</c> spelling of a constant load-bearing rather than
/// stylistic.
/// </para>
/// <para>
/// The test is inconclusive rather than failing where no compiler is on the path, which is the
/// honest result: nothing was checked. A hosted Linux or macOS runner has one; a Windows one usually
/// does not, and the rest of the suite covers what the text says there.
/// </para>
/// </remarks>
[TestClass]
public class CGeneratedSourceCompilesTests
{
/// <summary>
/// The compilers to look for, in the order a C project would.
/// </summary>
private static readonly string[] Compilers = ["cc", "gcc", "clang"];

/// <summary>
/// The consumer of the generated header, written the way a person would write one.
/// </summary>
/// <remarks>
/// It uses every declaration the header makes, so a declaration that compiles on its own but
/// cannot be used — a constant that is not a constant expression, a function pointer whose
/// signature does not match what implements it — fails here rather than passing quietly.
/// </remarks>
private const string Driver = """
#include "exemplar.h"
#include "exemplar.h"

static double circle_area(const void* self)
{
(void)self;
return 1.0;
}

static void circle_draw(void* self, double scale)
{
(void)self;
(void)scale;
}

int main(void)
{
Point built = Point_create(1, 2);
Point zeroed = Point_zero();
Origin first = origins[0];
Shape shape = { .draw = circle_draw, .area = circle_area };
Colour colour = Colour_Green;

shape.draw(&built, 2.0);

return (built.x + zeroed.y + first.y + (int)shape.area(&first) + (int)colour) * 0;
}

""";

/// <summary>
/// Tests that a header holding every kind of declaration the generator writes compiles, and that
/// a translation unit including it twice compiles too.
/// </summary>
[TestMethod]
public void GeneratedHeader_Compiles()
{
string? compiler = FindCompiler();
if (compiler is null)
{
Assert.Inconclusive("No C compiler on the path, so nothing was compiled.");
return;
}

string directory = Path.Combine(Path.GetTempPath(), $"coder-c-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);

try
{
File.WriteAllText(
Path.Combine(directory, "exemplar.h"),
new CGenerator().Generate(Exemplar()));
File.WriteAllText(Path.Combine(directory, "driver.c"), Driver);

(int exitCode, string output) = Run(
compiler,
$"-std=c11 -Wall -Wextra -pedantic -c driver.c -o driver.o",
directory);

Assert.AreEqual(0, exitCode, $"{compiler} rejected the generated header:{Environment.NewLine}{output}");
}
finally
{
Directory.Delete(directory, recursive: true);
}
}

/// <summary>
/// Builds a header holding one of everything the generator has a spelling for.
/// </summary>
/// <returns>The file to generate.</returns>
private static SourceFile Exemplar()
{
SourceFile file = new("exemplar") { IsHeader = true };
file.HeaderComment.Add("Generated by Coder. Do not edit.");
file.Imports.Add("<stdbool.h>");

EnumDeclaration colour = new("Colour") { UnderlyingType = "int" };
colour.Documentation.Add("What something is coloured.");
colour.Members.Add(new EnumMember("Red") { Value = "1" });
colour.Members.Add(new EnumMember("Green"));

ClassDeclaration point = new("Point") { Kind = TypeDeclarationKind.Struct };
point.Documentation.Add("Somewhere on a surface.");
point.Members.Add(new VariableDeclaration("x", "int"));
point.Members.Add(new VariableDeclaration("y", "int", new LiteralExpression<int>(1)));

FunctionDeclaration create = new("Point") { Kind = FunctionKind.Constructor };
create.Parameters.Add(new Parameter("x", "int"));
create.Parameters.Add(new Parameter("y", "int") { IsOptional = true, DefaultValue = "0" });
create.Initialisers.Add(new MemberInitialiser("x") { Value = new VariableReference("x") });
create.Initialisers.Add(new MemberInitialiser("y") { Value = new VariableReference("y") });
point.Members.Add(create);

ConstructionExpression origin = new(new TypeReference("Point"));
origin.Arguments.Add(new MemberInitialiser("x") { Value = new LiteralExpression<int>(0) });
origin.Arguments.Add(new MemberInitialiser("y") { Value = new LiteralExpression<int>(0) });

FunctionDeclaration zero = new("zero") { ReturnType = "Point", IsStatic = true, IsPure = true };
zero.Body.Add(new ReturnStatement(origin));
point.Members.Add(zero);

ClassDeclaration shape = new("Shape") { Kind = TypeDeclarationKind.Interface };
shape.Documentation.Add("What every shape can do.");
FunctionDeclaration draw = new("draw") { IsAbstract = true };
draw.Parameters.Add(new Parameter("scale", "double"));
shape.Members.Add(draw);
shape.Members.Add(new FunctionDeclaration("area")
{
ReturnType = "double",
IsReadOnly = true,
IsAbstract = true,
});

UsingAlias origins = new() { Name = "Origin", AliasedType = "Point" };

ConstructionExpression table = new(type: null);
foreach (int offset in new[] { 0, 1 })
{
ConstructionExpression row = new(new TypeReference("Point"));
row.Arguments.Add(new MemberInitialiser("x") { Value = new LiteralExpression<int>(offset) });
row.Arguments.Add(new MemberInitialiser("y") { Value = new LiteralExpression<int>(offset) });
table.Arguments.Add(row);
}

FieldDeclaration origin_table = new()
{
Name = "origins",
Type = new TypeReference("Point") { IsArray = true, IsReadOnly = true },
IsConstant = true,
InitialValue = table,
};
origin_table.Documentation.Add("Where each shape starts.");

file.Members.Add(colour);
file.Members.Add(point);
file.Members.Add(shape);
file.Members.Add(origins);
file.Members.Add(origin_table);
file.Members.Add(new CompileTimeAssertion
{
Condition = "sizeof(Point) == 2 * sizeof(int)",
Message = "Point must stay two ints",
});

return file;
}

/// <summary>
/// Finds the first compiler on the path.
/// </summary>
/// <returns>Its name, or null when there is none.</returns>
private static string? FindCompiler() =>
Compilers.FirstOrDefault(compiler => Run(compiler, "--version", Path.GetTempPath()).ExitCode == 0);

/// <summary>
/// Runs a command, waiting for it to finish.
/// </summary>
/// <param name="command">The executable to run.</param>
/// <param name="arguments">Its arguments.</param>
/// <param name="workingDirectory">Where to run it.</param>
/// <returns>What it exited with, and everything it wrote.</returns>
private static (int ExitCode, string Output) Run(string command, string arguments, string workingDirectory)
{
ProcessStartInfo start = new(command, arguments)
{
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};

try
{
using Process? process = Process.Start(start);
if (process is null)
{
return (-1, string.Empty);
}

string output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit();
return (process.ExitCode, output);
}
catch (System.ComponentModel.Win32Exception)
{
// The command is not on the path, which is the answer rather than a failure.
return (-1, string.Empty);
}
}
}
Loading
Loading