diff --git a/CLAUDE.md b/CLAUDE.md index d6d59e1..d165d46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,14 @@ source in seven target languages. The solution uses: whole a pointer to the member — to exactly one of them, and Go satisfies an interface structurally and so writes `var _ Contract = (*Type)(nil)`, an assertion the compiler checks rather than a declaration. A generator handed one list would be guessing which entry was the class. + Python folds the two back into the one list it has, and is the one target that has to do more than + spell them: a base list is an expression evaluated as the `class` statement runs, so a base naming + the class being declared — the self-type idiom, an interface written over its own implementer — + names something that does not exist yet and raises `NameError` on import. The argument is quoted, + which is what `typing` takes as a forward reference and resolves once something asks; + `from __future__ import annotations` is the other half of the idea and does not reach a base, which + is an expression rather than an annotation. `PythonGeneratedSourceImportsTests` loads what the + generator writes, because that is a class of error no test pinning the text can see. The three modifiers split along a line worth stating once: `IsRecord` and `IsReadOnly` are claims about the type — it compares by value, no member of it modifies it — so a target with no word for one writes it down, the same as `CompileTimeAssertion`, while Rust's `#[derive(Clone, Debug, diff --git a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs new file mode 100644 index 0000000..aa56fbe --- /dev/null +++ b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs @@ -0,0 +1,122 @@ +// 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; + +/// +/// Runs what writes, with a real interpreter. +/// +/// +/// The other four targets that are checked by a toolchain are checked by compiling. Python has no +/// compiler, and the equivalent question is whether the module can be imported at all: a Python file +/// is a program that builds its own declarations, so a class statement runs when the module is loaded +/// and anything wrong with it is raised then. That is the whole class of error a test pinning the +/// text cannot see — the spelling looks right and the interpreter disagrees — and a base list naming +/// the class being declared is exactly one of them. +/// +/// The driver is written the way a consumer would be: it supplies the names the generated module +/// expects to find — the interface it implements, and the type variable it is written over — and then +/// runs the module against them. runpy is how a module is run with names already in its +/// namespace; an import cannot, and the AST has no way to declare a TypeVar or a +/// Generic for the file to carry its own. +/// +/// +/// The test is inconclusive rather than failing where no interpreter is on the path, which is the +/// honest result: nothing was run. +/// +/// +[TestClass] +public class PythonGeneratedSourceImportsTests +{ + /// + /// The consumer of the generated module, written the way a person would write one. + /// + /// + /// Every declaration the module makes is asked for afterwards, so a module that loads but declares + /// nothing fails here rather than passing quietly. Length is the self-type idiom and + /// Node the mutually-referential pair, which are the two shapes a base list can hold a name + /// that does not exist yet. + /// + private const string Driver = """ + import runpy + from typing import Generic, TypeVar + + T = TypeVar("T") + TSelf = TypeVar("TSelf") + + + class IVector0(Generic[TSelf, T]): + pass + + + class Visitor(Generic[TSelf]): + pass + + + module = runpy.run_path( + "exemplar.py", + init_globals={"IVector0": IVector0, "Visitor": Visitor, "T": T}, + ) + + assert module["Length"].__name__ == "Length" + assert module["Node"].__name__ == "Node" + + """; + + /// + /// Tests that a module whose classes are written over themselves can be loaded. + /// + [TestMethod] + public void GeneratedSource_Imports() + { + string? python = ToolchainHarness.FindOnPath("--version", "python3", "python"); + if (python is null) + { + Assert.Inconclusive("No Python interpreter on the path, so nothing was run."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "driver.py"), Driver); + File.WriteAllText( + Path.Combine(directory, "exemplar.py"), + new PythonGenerator().Generate(Exemplar())); + + (int exitCode, string output) = ToolchainHarness.Run(python, "driver.py", directory); + Assert.AreEqual( + 0, + exitCode, + $"Python could not load the generated module:{Environment.NewLine}{output}"); + }); + } + + /// + /// Builds a file whose declarations are each written over themselves. + /// + /// The file to generate. + private static SourceFile Exemplar() + { + SourceFile file = new("exemplar"); + file.HeaderComment.Add("Generated by Coder. Do not edit."); + + // The shape every generated ktsu.Semantics quantity has: an interface written over the type + // implementing it, so that the method it declares answers that type rather than the interface. + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0, T>")); + length.Members.Add(new VariableDeclaration("Value", "T")); + + // The same knot without generics of its own: a type and the thing that visits it, each named + // in the other's declaration. + ClassDeclaration node = new("Node"); + node.Interfaces.Add(TypeReference.Parse("Visitor")); + + file.Members.Add(length); + file.Members.Add(node); + + return file; + } +} diff --git a/Coder.Test/Languages/ToolchainHarness.cs b/Coder.Test/Languages/ToolchainHarness.cs index a9af2d2..48dd26c 100644 --- a/Coder.Test/Languages/ToolchainHarness.cs +++ b/Coder.Test/Languages/ToolchainHarness.cs @@ -5,7 +5,7 @@ namespace ktsu.Coder.Test.Languages; using System.Diagnostics; /// -/// Runs a real compiler over generated source. +/// Runs a real toolchain over generated source. /// /// /// Three of the generators here are checked by compiling what they write, because the rules they @@ -13,6 +13,11 @@ namespace ktsu.Coder.Test.Languages; /// about receivers, associated items and what a trait implementation owes its trait, and Go's about /// unused names, which it refuses rather than warns about. Everything those tests share about /// *running* a compiler is here, so that what is left in each of them is the language. +/// +/// Python is asked the same question by an interpreter rather than a compiler, having no compiler to +/// ask: a Python file builds its own declarations as it is loaded, so a declaration the language +/// refuses raises on import rather than failing to build. +/// /// internal static class ToolchainHarness { diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index 24840bd..3e4ac5d 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -55,6 +55,19 @@ private static ClassDeclaration Money() => IsReadOnly = true, }; + /// + /// A type implementing an interface written over the type itself, which is how an interface gives + /// a method the implementing type as its result — TSelf Create(T value) rather than + /// IVector0 Create(T value). + /// + /// The declaration. + private static ClassDeclaration SelfTyped() + { + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0, T>")); + return length; + } + private static string Generate(ILanguageGenerator generator, AstNode node) => generator.Generate(node); // ------------------------------------------------------------------ Interfaces @@ -138,6 +151,82 @@ public void Python_TakesThemAllAsBases() StringAssert.Contains(code, "class Widget(Control, Drawable, Clickable):"); } + /// + /// Python quotes the argument of a base that names the class being declared, because a base list + /// is an expression Python evaluates before the name exists. + /// + /// + /// The self-type idiom, which is how an interface hands a method the implementing type — every one + /// of the 212 generated quantities in ktsu.Semantics is declared this way. Unquoted, the + /// module raises NameError on import rather than misbehaving later, so this is the + /// difference between a file that can be loaded and one that cannot. + /// + [TestMethod] + public void Python_QuotesABaseArgumentNamingTheClassBeingDeclared() + { + string code = Generate(new PythonGenerator(), SelfTyped()); + + Assert.Contains("class Length(IVector0[\"Length[T]\", T]):", code); + } + + /// + /// The same for a pair that refer to each other, which needs no generics of its own. + /// + [TestMethod] + public void Python_QuotesABaseArgumentThatIsTheClassItself() + { + ClassDeclaration node = new("Node"); + node.Interfaces.Add(TypeReference.Parse("Visitor")); + + string code = Generate(new PythonGenerator(), node); + + Assert.Contains("class Node(Visitor[\"Node\"]):", code); + } + + /// + /// An argument holding the class further down is quoted whole, which is the one place the quote + /// can go: a second pair inside the first would end the string rather than nest. + /// + [TestMethod] + public void Python_QuotesTheWholeArgumentWhenTheClassIsNestedInIt() + { + ClassDeclaration length = new("Length"); + length.Interfaces.Add(TypeReference.Parse("IVector0>, T>")); + + string code = Generate(new PythonGenerator(), length); + + Assert.Contains("class Length(IVector0[\"Wrapper[Length[T]]\", T]):", code); + } + + /// + /// An argument naming anything else is left alone: a forward reference is what a name that does + /// not exist yet needs, and every other name is one Python can already resolve. + /// + [TestMethod] + public void Python_LeavesABaseArgumentNamingSomethingElseUnquoted() + { + ClassDeclaration button = new("Button"); + button.Interfaces.Add(TypeReference.Parse("Handler")); + + string code = Generate(new PythonGenerator(), button); + + Assert.Contains("class Button(Handler[Event]):", code); + Assert.DoesNotContain("\"", code, "Python quoted a base that names nothing being declared."); + } + + /// + /// The other targets write the same declaration verbatim, because the declaration is not what is + /// wrong with it: Python is the one target that reads a base list eagerly. + /// + [TestMethod] + public void OtherTargets_WriteASelfTypedInterfaceVerbatim() + { + Assert.Contains( + "class Length : IVector0, T>", Generate(new CSharpGenerator(), SelfTyped())); + Assert.Contains( + "class Length : public IVector0, T>", Generate(new CppGenerator(), SelfTyped())); + } + /// /// JavaScript extends one thing and has no interfaces, so it writes down what it cannot say. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 74d505e..2e60f6e 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -408,10 +408,11 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod // Python inherits from as many things as it is given and has no separate notion of an // interface, so the base and the interfaces are one list of bases -- which is what the // abstract base classes in the standard library already are. + string? declaring = classDecl.Name; string[] bases = [ - .. classDecl.BaseType is TypeReference baseType ? (string[])[PythonTypeFromGenericType(baseType)] : [], - .. classDecl.Interfaces.Select(PythonTypeFromGenericType), + .. classDecl.BaseType is TypeReference baseType ? (string[])[PythonBaseFromGenericType(baseType, declaring)] : [], + .. classDecl.Interfaces.Select(implemented => PythonBaseFromGenericType(implemented, declaring)), ]; if (bases.Length > 0) @@ -678,16 +679,7 @@ protected override void GenerateParameter(Parameter parameter, CodeBlocker code, /// private static string PythonTypeFromGenericType(TypeReference type) { - string name = type.Name.ToLowerInvariant() switch - { - "int" => "int", - "string" => "str", - "bool" => "bool", - "float" => "float", - "double" => "float", - "void" => "None", - _ => type.Name - }; + string name = PythonTypeName(type); string spelled = type.TypeArguments.Count == 0 ? name @@ -698,6 +690,88 @@ private static string PythonTypeFromGenericType(TypeReference type) return type.IsArray ? $"list[{spelled}]" : spelled; } + /// + /// Maps a type's name to the Python spelling of it. + /// + /// The type whose name to map. + /// The name Python knows it by. + private static string PythonTypeName(TypeReference type) => type.Name.ToLowerInvariant() switch + { + "int" => "int", + "string" => "str", + "bool" => "bool", + "float" => "float", + "double" => "float", + "void" => "None", + _ => type.Name + }; + + /// + /// Spells a base of a class, quoting any argument that names the class being declared. + /// + /// The base to spell. + /// The name of the class the base list belongs to. + /// The Python source for it. + /// + /// A base list is an expression and Python evaluates it as the class statement is run, so a + /// base naming the class being declared is a name that does not exist yet: the self-type idiom — + /// IVector0<TSelf, T>, the interface that hands a method the implementing type — makes + /// the module raise NameError on import rather than misbehave later. Nothing about the + /// declaration is wrong; Python is the one target that reads a base eagerly. + /// + /// A quoted argument is the language's own answer, and the one typing consumers write for + /// exactly this case: typing takes a string as a forward reference and resolves it when + /// something asks, by which time the class exists. from __future__ import annotations is + /// the other half of the idea and does not reach here — it defers *annotations*, and a base is an + /// expression. + /// + /// + /// Only the arguments are quoted, never the base itself. A class may inherit from a subscripted + /// generic holding a forward reference and may not inherit from a string, and quoting is at the + /// outermost argument alone for the same reason: one quoted argument carries every name inside it, + /// and a second pair of quotes within the first would end the string rather than nest. + /// + /// + private static string PythonBaseFromGenericType(TypeReference type, string? declaring) + { + if (declaring is null || type.TypeArguments.Count == 0 || !NamesType(type.TypeArguments, declaring)) + { + return PythonTypeFromGenericType(type); + } + + IEnumerable arguments = type.TypeArguments.Select(argument => + NamesType(argument, declaring) + ? $"\"{PythonTypeFromGenericType(argument)}\"" + : PythonTypeFromGenericType(argument)); + + string spelled = $"{PythonTypeName(type)}[{string.Join(", ", arguments)}]"; + + return type.IsArray ? $"list[{spelled}]" : spelled; + } + + /// + /// Asks whether a type names another anywhere in it. + /// + /// The type to look through. + /// The name being looked for. + /// True when the name is the type's own or any of its arguments'. + /// + /// Through the arguments rather than at the top alone, because a base is as readily written over a + /// type holding the class — IVector0<Wrapper<Length<T>>, T> — as over the + /// class itself, and Python evaluates all of it at once either way. + /// + private static bool NamesType(TypeReference type, string name) => + string.Equals(type.Name, name, StringComparison.Ordinal) || NamesType(type.TypeArguments, name); + + /// + /// Asks whether any of several types names another anywhere in it. + /// + /// The types to look through. + /// The name being looked for. + /// True when any of them names it. + private static bool NamesType(IEnumerable types, string name) => + types.Any(type => NamesType(type, name)); + /// /// /// A constant is emitted as an ordinary assignment: Python has no constant declaration, and the