diff --git a/src/build/fan/BuildPod.fan b/src/build/fan/BuildPod.fan index 9801cb914..d7305ea02 100644 --- a/src/build/fan/BuildPod.fan +++ b/src/build/fan/BuildPod.fan @@ -107,6 +107,12 @@ abstract class BuildPod : BuildScript ** Uri[]? jsDirs + ** + ** List of Uris relative to build script of directories containing + ** the Python source files to compile for Python native methods. + ** + Uri[]? pyDirs + ** ** List of Uris relative to build script that should be searched for '.props' ** files to compile to JavaScript. You may also give relative paths to files @@ -244,6 +250,7 @@ abstract class BuildPod : BuildScript meta["pod.native.jni"] = (jniDirs != null && !jniDirs.isEmpty).toStr meta["pod.native.dotnet"] = (dotnetDirs != null && !dotnetDirs.isEmpty).toStr meta["pod.native.js"] = (jsDirs != null && !jsDirs.isEmpty).toStr + meta["pod.native.py"] = (pyDirs != null && !pyDirs.isEmpty).toStr // TODO: add additinal meta props defined by config file/env var // this behavior is not guaranteed in future versions, rather we @@ -289,6 +296,7 @@ abstract class BuildPod : BuildScript ci.resFiles = resDirs ci.jsFiles = jsDirs ci.jsPropsFiles = jsProps ?: resDirs + ci.pyFiles = pyDirs ci.log = log ci.includeDoc = docApi ci.includeSrc = docSrc diff --git a/src/compiler/fan/CompilerInput.fan b/src/compiler/fan/CompilerInput.fan index 0bbce9838..4857ef0ad 100644 --- a/src/compiler/fan/CompilerInput.fan +++ b/src/compiler/fan/CompilerInput.fan @@ -191,6 +191,13 @@ class CompilerInput ** Uri[]? jsPropsFiles + ** + ** List of Python files or directories containing Python files + ** to include in the Python output. Uris are relative to `baseDir`. + ** This field is used only in file mode. + ** + Uri[]? pyFiles + ////////////////////////////////////////////////////////////////////////// // CompilerInputMode.str ////////////////////////////////////////////////////////////////////////// diff --git a/src/fanc/build.fan b/src/fanc/build.fan index aade43567..a17aed73b 100755 --- a/src/fanc/build.fan +++ b/src/fanc/build.fan @@ -28,8 +28,8 @@ class Build : BuildPod depends = ["sys 1.0", "build 1.0", "compiler 1.0", "util 1.0"] srcDirs = [`fan/`, `fan/java/`, + `fan/py/`, `fan/util/`] docSrc = true } } - diff --git a/src/fanc/fan/py/PyExprPrinter.fan b/src/fanc/fan/py/PyExprPrinter.fan new file mode 100644 index 000000000..7cf5d8d6f --- /dev/null +++ b/src/fanc/fan/py/PyExprPrinter.fan @@ -0,0 +1,1730 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using compiler + +** +** PyExprPrinter generates Python expressions from Fantom AST nodes. +** +** Key Patterns: +** - Primitives (Int, Str, Bool, Float) use static dispatch: sys.Int.plus(x, y) +** - List/Map use instance dispatch: list.each(f) (NOT primitives) +** - Closures wrap in Func.make_closure() for Fantom Func API (bind, params, etc.) +** - Safe navigation (?.) uses lambda wrapper: ((lambda _safe_: ... if _safe_ is not None else None)(target)) +** - ObjUtil handles cross-type operations: equals, compare, typeof, is_, as_ +** +** Dispatch Priority in call(): +** 1. Safe navigation check (?.method()) +** 2. cvar wrapper detection (closure variable wrappers) +** 3. Dynamic call (-> operator) via ObjUtil.trap() +** 4. Func.call() / Func.callList() -> direct invocation +** 5. ObjUtil methods (equals, compare, typeof, etc.) +** 6. Primitive type static dispatch (Int, Str, etc.) +** 7. Private method static dispatch (non-virtual) +** 8. Normal instance/static method call +** +** See design.md in this directory for full documentation. +** +class PyExprPrinter : PyPrinter +{ + new make(PyPrinter parent) : super.make(parent.m.out) + { + this.m = parent.m + } + + ** Print an expression + Void expr(Expr e) + { + switch (e.id) + { + case ExprId.nullLiteral: nullLiteral + case ExprId.trueLiteral: trueLiteral + case ExprId.falseLiteral: falseLiteral + case ExprId.intLiteral: intLiteral(e) + case ExprId.floatLiteral: floatLiteral(e) + case ExprId.strLiteral: strLiteral(e) + case ExprId.listLiteral: listLiteral(e) + case ExprId.mapLiteral: mapLiteral(e) + case ExprId.rangeLiteral: rangeLiteral(e) + case ExprId.durationLiteral: durationLiteral(e) + case ExprId.decimalLiteral: decimalLiteral(e) + case ExprId.uriLiteral: uriLiteral(e) + case ExprId.localVar: localVar(e) + case ExprId.thisExpr: thisExpr + case ExprId.superExpr: superExpr(e) + case ExprId.call: call(e) + case ExprId.construction: construction(e) + case ExprId.field: field(e) + case ExprId.assign: assign(e) + case ExprId.same: same(e) + case ExprId.notSame: notSame(e) + case ExprId.boolNot: boolNot(e) + case ExprId.boolOr: boolOr(e) + case ExprId.boolAnd: boolAnd(e) + case ExprId.cmpNull: cmpNull(e) + case ExprId.cmpNotNull: cmpNotNull(e) + case ExprId.isExpr: isExpr(e) + case ExprId.isnotExpr: isnotExpr(e) + case ExprId.asExpr: asExpr(e) + case ExprId.coerce: coerce(e) + case ExprId.ternary: ternary(e) + case ExprId.elvis: elvis(e) + case ExprId.shortcut: shortcut(e) + case ExprId.closure: closure(e) + case ExprId.staticTarget: staticTarget(e) + case ExprId.typeLiteral: typeLiteral(e) + case ExprId.slotLiteral: slotLiteral(e) + case ExprId.itExpr: itExpr(e) + case ExprId.throwExpr: throwExpr(e) + case ExprId.unknownVar: unknownVar(e) + default: + throw UnsupportedErr("Unhandled expr type: $e.id") + } + } + +////////////////////////////////////////////////////////////////////////// +// Literals +////////////////////////////////////////////////////////////////////////// + + private Void nullLiteral() { none } + + private Void trueLiteral() { true_ } + + private Void falseLiteral() { false_ } + + private Void intLiteral(LiteralExpr e) { w(e.val) } + + private Void floatLiteral(LiteralExpr e) { w(e.val) } + + private Void strLiteral(LiteralExpr e) { str(e.val) } + + private Void listLiteral(ListLiteralExpr e) + { + // Cast to ListType to get element type directly (no try/catch, no dynamic dispatch) + lt := (ListType)((CType)(e.explicitType ?: e.ctype)).deref + sysPrefix() + w("List.from_literal([") + e.vals.each |val, i| + { + if (i > 0) w(", ") + expr(val) + } + w("], ") + str(lt.v.signature) + w(")") + } + + private Void mapLiteral(MapLiteralExpr e) + { + // Cast to MapType to get key/value types directly (no try/catch, no dynamic dispatch) + mt := (MapType)(e.explicitType ?: e.ctype) + sysPrefix() + w("Map.from_literal([") + e.keys.each |key, i| + { + if (i > 0) w(", ") + expr(key) + } + w("], [") + e.vals.each |val, i| + { + if (i > 0) w(", ") + expr(val) + } + w("], ") + str(mt.k.signature) + w(", ") + str(mt.v.signature) + w(")") + } + + private Void rangeLiteral(RangeLiteralExpr e) + { + // Generate Range.make(start, end, exclusive) + sysPrefix() + w("Range.make(") + expr(e.start) + w(", ") + expr(e.end) + if (e.exclusive) + w(", True") + w(")") + } + + private Void durationLiteral(LiteralExpr e) + { + // Duration literal - value is in nanoseconds + dur := e.val as Duration + sysPrefix() + if (dur != null) + w("Duration.make(").w(dur.ticks).w(")") + else + w("Duration.make(0)") + } + + private Void decimalLiteral(LiteralExpr e) + { + // Decimal literal (5d suffix) - emit as Decimal.make("value") + // Matches JS transpiler pattern: sys.Decimal.make(value) + // Use string constructor to preserve precision for large values + val := e.val.toStr + sysPrefix() + w("Decimal.make(\"").w(val).w("\")") + } + + private Void uriLiteral(LiteralExpr e) + { + // URI literal `http://example.com` -> Uri.from_str("http://example.com") + uri := e.val as Uri + sysPrefix() + if (uri != null) + w("Uri.from_str(").str(uri.toStr).w(")") + else + w("Uri.from_str(").str(e.val.toStr).w(")") + } + +////////////////////////////////////////////////////////////////////////// +// Variables +////////////////////////////////////////////////////////////////////////// + + private Void localVar(LocalVarExpr e) { w(escapeName(e.var.name)) } + + private Void thisExpr() { w("self") } + + private Void superExpr(SuperExpr e) { w("super()") } + + private Void itExpr(Expr e) + { + // "it" is the implicit closure parameter - output as "it" + w("it") + } + + ** Unresolved variable reference -- output target.name if target present (matches ES compiler) + private Void unknownVar(Expr e) + { + uv := e as UnknownVarExpr + if (uv.target != null) { expr(uv.target); w(".") } + w(escapeName(uv.name)) + } + + private Void throwExpr(Expr e) + { + // throw as an expression (used in elvis, ternary, etc.) + // Python's `raise` is a statement, so we use a helper function + // ObjUtil.throw_(err) raises the exception and never returns + te := e as ThrowExpr + w("ObjUtil.throw_(") + expr(te.exception) + w(")") + } + +////////////////////////////////////////////////////////////////////////// +// Calls +////////////////////////////////////////////////////////////////////////// + + private Void call(CallExpr e) + { + methodName := e.method.name + + // Dispatch priority chain (see class doc) + if (e.isSafe && e.target != null) { callSafe(e); return } + if (e.isDynamic) { callDynamic(e); return } + // All sys::Func methods handled here: call/callList -> direct invocation, + // enterCtor/exitCtor/checkInCtor -> no-op (compiler-injected const protection) + if (e.method.parent.qname == "sys::Func") + { + if (methodName == "call" || methodName == "callList") { callFunc(e); return } + if (methodName == "enterCtor" || methodName == "exitCtor" || methodName == "checkInCtor") + { w("None"); return } + } + if (e.target != null && isObjUtilMethod(e.method)) { objUtilCall(e); return } + if (e.target != null && isPrimitiveType(e.target.ctype) && e.target.id != ExprId.staticTarget) + { primitiveCall(e); return } + if (e.method.isPrivate && !e.method.isStatic && !e.method.isCtor) { callPrivate(e); return } + + // Normal instance/static method call -- resolve target prefix + callNormal(e) + } + + ** Safe navigation: ((lambda _safe_: None if _safe_ is None else )(target)) + private Void callSafe(CallExpr e) + { + w("((lambda _safe_: None if _safe_ is None else ") + safeCallBody(e) + w(")(") + expr(e.target) + w("))") + } + + ** Dynamic call (-> operator): ObjUtil.trap(target, name, args) + ** targetWriter overrides how the target is written (used by safeCallBody for _safe_) + private Void callDynamic(CallExpr e, |->|? targetWriter := null) + { + w("ObjUtil.trap(") + if (targetWriter != null) targetWriter() + else if (e.target != null) expr(e.target) + else w("self") + w(", ").str(e.name) + if (e.args.isEmpty) + { + w(", None)") + } + else + { + w(", [") + writeArgs(e.args) + w("])") + } + } + + ** Func.call/callList -> direct Python invocation + private Void callFunc(CallExpr e) + { + if (e.target != null) expr(e.target) + w("(") + if (e.method.name == "callList" && !e.args.isEmpty) + { + w("*") + expr(e.args.first) + } + else + { + writeArgs(e.args) + } + w(")") + } + + ** Private method -> static dispatch: ClassName.method(self/target, args) + private Void callPrivate(CallExpr e) + { + w(PyUtil.escapeTypeName(e.method.parent.name)).w(".").w(escapeName(e.method.name)).w("(") + if (e.target != null) + expr(e.target) + else if (!m.inStaticContext) + w("self") + if ((e.target != null || !m.inStaticContext) && !e.args.isEmpty) w(", ") + writeArgs(e.args) + w(")") + } + + ** Normal method call -- resolve target prefix then emit name(args) + private Void callNormal(CallExpr e) + { + if (e.target != null) + { + expr(e.target) + w(".") + } + else if (e.method.isStatic) + { + writeTypeRef(e.method.parent.pod.name, PyUtil.escapeTypeName(e.method.parent.name)) + w(".") + } + else if (m.inStaticContext) + { + w(PyUtil.escapeTypeName(e.method.parent.name)).w(".") + } + else if (e.method.isPrivate && !e.method.isCtor) + { + // Duplicate private check (reached when private method not caught above) + callPrivate(e) + return + } + else + { + w("self.") + } + w(escapeName(e.method.name)) + w("(") + writeArgs(e.args) + w(")") + } + + ** Check if method should be routed through ObjUtil + ** These are Obj/Num methods that may be called on primitives coerced to Obj or Num + private Bool isObjUtilMethod(CMethod m) + { + // All sys::Obj methods route through ObjUtil (matches ES compiler) + if (m.parent.isObj) return true + + // Force equals/compare through ObjUtil for NaN-aware semantics + // even when resolved to a specific type (matches ES compiler) + name := m.name + if (name == "equals" || name == "compare") return true + + // Num methods on Num-typed values (Python has no Num.py for static dispatch) + if (m.parent.isNum) + return name == "toFloat" || name == "toInt" || name == "toDecimal" || name == "toLocale" + + // Decimal.toLocale on Decimal-typed values + if (m.parent.isDecimal) return name == "toLocale" + + return false + } + + ** Output ObjUtil method call: x.method() -> ObjUtil.method(x) + ** targetWriter overrides how the target is written (used by safeCallBody for _safe_) + private Void objUtilCall(CallExpr e, |->|? targetWriter := null) + { + pyName := escapeName(e.method.name) + + w("ObjUtil.").w(pyName).w("(") + if (targetWriter != null) targetWriter() + else expr(e.target) + if (!e.args.isEmpty) + { + e.args.each |arg| + { + w(", ") + expr(arg) + } + } + w(")") + } + + ** Map of Fantom primitive type qnames to their Python wrapper class names. + ** Primitives use static dispatch: x.method() -> sys.Type.method(x) + ** List and Map are NOT primitives -- they use normal instance dispatch. + ** Matches ES compiler pmap: Bool, Decimal, Float, Int, Str + private static const Str:Str primitiveMap := + [ + "sys::Bool": "Bool", + "sys::Int": "Int", + "sys::Float": "Float", + "sys::Decimal": "Float", // Decimal uses Float methods in Python + "sys::Str": "Str", + ] + + ** Check if type is a primitive that needs static method calls + private Bool isPrimitiveType(CType? t) + { + if (t == null) return false + return primitiveMap.containsKey(t.toNonNullable.signature) + } + + ** Write "sys." prefix when current pod is NOT the sys pod. + ** This is the standard pattern for qualifying sys pod types from non-sys code. + private Void sysPrefix() + { + if (m.curType?.pod?.name != "sys") + w("sys.") + } + + ** Hand-written sys types that use Python @property for instance fields. + ** These types use property assignment (self.x = v) not method-call setters (self.x(v)). + ** Derived from: grep -l "@property" fan/src/sys/py/fan/*.py + private static const Str[] handWrittenSysTypes := + [ + "sys::Depend", // @property (version, isPlus, etc.) + "sys::Endian", // @property + "sys::List", // read-write @property (capacity) + "sys::Locale", // @property + "sys::Map", // read-write @property (def_, ordered, caseInsensitive) + "sys::StrBuf", // read-only @property (charset) + "sys::Type", // read-only @property (root, v, k, params, ret) + ] + + ** Check if type is a hand-written sys type that uses Python @property + private Bool isHandWrittenSysType(Str qname) { handWrittenSysTypes.contains(qname) } + + + ** Get Python wrapper class name for primitive type + private Str primitiveClassName(CType t) + { + return primitiveMap.get(t.toNonNullable.signature, t.name) + } + + ** Write a pod-qualified type reference. + ** Handles same-pod (dynamic import), sys-to-non-sys (sys. prefix), + ** cross-pod (dynamic import), and same-pod-sys (bare name). + private Void writeTypeRef(Str targetPod, Str typeName) + { + curPod := m.curType?.pod?.name + if (curPod != null && curPod != "sys" && curPod == targetPod) + { + // Same pod, non-sys - use dynamic import to avoid circular imports + podPath := PyUtil.podImport(targetPod) + w("__import__('${podPath}.${typeName}', fromlist=['${typeName}']).${typeName}") + } + else if (targetPod == "sys" && curPod != "sys") + { + // Sys pod type from non-sys pod - use sys. prefix + w("sys.").w(typeName) + } + else if (targetPod != "sys" && curPod != null && curPod != targetPod) + { + // Cross-pod reference (non-sys to non-sys) - use dynamic import + podPath := PyUtil.podImport(targetPod) + w("__import__('${podPath}.${typeName}', fromlist=['${typeName}']).${typeName}") + } + else + { + // Same pod (sys) or already imported directly + w(typeName) + } + } + + ** Write comma-separated argument expressions + private Void writeArgs(Expr[] args) + { + args.each |arg, i| + { + if (i > 0) w(", ") + expr(arg) + } + } + + ** Output primitive type static method call: x.method() -> sys.Type.method(x) + ** targetWriter overrides how the target is written (used by safeCallBody for _safe_) + private Void primitiveCall(CallExpr e, |->|? targetWriter := null) + { + className := primitiveClassName(e.target.ctype) + methodName := escapeName(e.method.name) + + sysPrefix() + w(className).w(".").w(methodName).w("(") + if (targetWriter != null) targetWriter() + else expr(e.target) + if (!e.args.isEmpty) + { + e.args.each |arg| + { + w(", ") + expr(arg) + } + } + w(")") + } + + ** Generate the body of a safe call using _safe_ as the target variable. + ** Delegates to the same dispatch methods as call() but writes _safe_ instead of expr(e.target). + private Void safeCallBody(CallExpr e) + { + safeTarget := |->| { w("_safe_") } + + if (e.isDynamic) { callDynamic(e, safeTarget); return } + if (e.target != null && isPrimitiveType(e.target.ctype) && e.target.id != ExprId.staticTarget) + { primitiveCall(e, safeTarget); return } + if (e.target != null && isObjUtilMethod(e.method)) { objUtilCall(e, safeTarget); return } + + // Regular instance method call: _safe_.method(args) + w("_safe_.").w(escapeName(e.method.name)).w("(") + writeArgs(e.args) + w(")") + } + + ** Generate the body of a safe field access using _safe_ as the target variable + ** This is called from within a lambda wrapper: ((lambda _safe_: None if _safe_ is None else )(target)) + private Void safeFieldBody(FieldExpr e) + { + fieldName := e.field.name + + // useAccessor=false means direct storage access (&field syntax) + // In Python, backing fields use _fieldName pattern + if (!e.useAccessor && !e.field.isStatic) + w("_safe_._").w(escapeName(fieldName)) + else + { + w("_safe_.").w(escapeName(fieldName)) + // Instance fields on transpiled types need () for accessor method + if (!e.field.isStatic) + { + parentSig := e.field.parent.qname + if (!isHandWrittenSysType(parentSig)) + w("()") + } + } + } + + private Void construction(CallExpr e) + { + // Constructor call - always use factory pattern: ClassName.make(args) + writeTypeRef(e.method.parent.pod.name, PyUtil.escapeTypeName(e.method.parent.name)) + + // Always call the factory method: .make() or .fromStr() etc. + factoryName := e.method.name == "" ? "make" : e.method.name + w(".").w(escapeName(factoryName)) + + w("(") + writeArgs(e.args) + w(")") + } + + ** Check if a field expression is accessing .val on a Wrap$ wrapper variable + ** If so, return the original variable name; null otherwise + ** Works for both outer scope (wrapper name -> original name via map) and + ** inner closure scope (variable already has original name from capture) + private Str? isWrapValAccess(FieldExpr e) + { + // Must be accessing field named "val" on a synthetic Wrap$ type + if (e.field.name != "val") return null + parentType := e.field.parent + if (!parentType.isSynthetic || !parentType.name.startsWith("Wrap\$")) return null + + // Target must be a local variable + if (e.target == null || e.target.id != ExprId.localVar) return null + localTarget := e.target as LocalVarExpr + varName := localTarget.var.name + + // Check if this is a known wrapper variable (outer scope: wrapper_name -> original_name) + origName := m.getNonlocalOriginal(varName) + if (origName != null) return origName + + // Inside closures, the variable already has the original name (from captured field) + // If the field parent is Wrap$ and field is val, strip the field access regardless + return varName + } + + private Void field(FieldExpr e) + { + fieldName := e.field.name + + // Intercept Wrap$.val field access -- output the original variable name + // instead of wrapper._val (we use nonlocal instead of cvar wrappers) + origName := isWrapValAccess(e) + if (origName != null) + { + w(escapeName(origName)) + return + } + + // Handle safe navigation operator (?.): short-circuit to null if target is null + // Pattern: ((lambda _safe_: None if _safe_ is None else _safe_.field)()) + if (e.isSafe && e.target != null) + { + w("((lambda _safe_: None if _safe_ is None else ") + safeFieldBody(e) + w(")(") + expr(e.target) + w("))") + return + } + + // Primitive field access uses static dispatch, same as method calls + // e.g., str.size -> sys.Str.size(str) because Python str has no .size property + if (e.target != null && isPrimitiveType(e.target.ctype)) + { + className := primitiveClassName(e.target.ctype) + sysPrefix() + w(className).w(".").w(escapeName(fieldName)).w("(") + expr(e.target) + w(")") + return + } + + // Check for $this field (outer this capture in closures) + if (fieldName == "\$this") + { + // Inside closure, $this refers to outer self + // Multi-statement closures use _self, inline lambdas use _outer + if (m.inClosureWithOuter) + w("_outer") + else + w("_self") + return + } + + // Check for captured local variable: pattern varName$N + // Fantom creates synthetic fields like js$0, expected$2 for captured locals + if (fieldName.contains("\$")) + { + idx := fieldName.index("\$") + if (idx != null && idx < fieldName.size - 1) + { + suffix := fieldName[idx+1..-1] + // Check if suffix is all digits + if (!suffix.isEmpty && suffix.all |c| { c.isDigit }) + { + // This is a captured local variable - output just the base name + // If we are in a closure, we use the base name to capture from outer scope + baseName := fieldName[0.. property assignment + // - Transpiled types use def field(self, _val_=None): -> method call syntax + parentSig := fieldExpr.field.parent.qname + useMethodCall := !isHandWrittenSysType(parentSig) + + if (useMethodCall) + { + // Use method call syntax: target.fieldName(value) + if (fieldExpr.target != null) + { + expr(fieldExpr.target) + w(".") + } + w(escapeName(fieldExpr.field.name)) + w("(") + expr(e.rhs) + w(")") + } + else + { + // Use Python property assignment: self.fieldName = value + // This works with @property decorated getters/setters on hand-written types + if (fieldExpr.target != null) + { + expr(fieldExpr.target) + w(".") + } + w(escapeName(fieldExpr.field.name)) + w(" = ") + expr(e.rhs) + } + } + else + { + // Direct storage access: self._count = value or ClassName._count for static + if (fieldExpr.target != null) + { + expr(fieldExpr.target) + w(".") + } + else if (fieldExpr.field.isStatic) + { + // Static field without explicit target - need class prefix + w(PyUtil.escapeTypeName(fieldExpr.field.parent.name)).w(".") + } + w("_").w(escapeName(fieldExpr.field.name)) + w(" = ") + expr(e.rhs) + } + } + else + { + // Local var assignment - use walrus operator to make it an expression + // This allows assignment inside function calls, conditions, etc. + w("(") + expr(e.lhs) + w(" := ") + expr(e.rhs) + w(")") + } + } + +////////////////////////////////////////////////////////////////////////// +// Comparison +////////////////////////////////////////////////////////////////////////// + + private Void same(BinaryExpr e) + { + // Use ObjUtil.same() for consistent identity semantics + // Python's 'is' operator is unreliable with interned literals + w("ObjUtil.same(") + expr(e.lhs) + w(", ") + expr(e.rhs) + w(")") + } + + private Void notSame(BinaryExpr e) + { + // Use ObjUtil.same() for consistent identity semantics + w("(not ObjUtil.same(") + expr(e.lhs) + w(", ") + expr(e.rhs) + w("))") + } + + private Void cmpNull(UnaryExpr e) + { + expr(e.operand) + w(" is None") + } + + private Void cmpNotNull(UnaryExpr e) + { + expr(e.operand) + w(" is not None") + } + +////////////////////////////////////////////////////////////////////////// +// Boolean Operators +////////////////////////////////////////////////////////////////////////// + + private Void boolNot(UnaryExpr e) + { + w("not ") + expr(e.operand) + } + + private Void boolOr(CondExpr e) + { + w("(") + e.operands.each |op, i| + { + if (i > 0) w(" or ") + expr(op) + } + w(")") + } + + private Void boolAnd(CondExpr e) + { + w("(") + e.operands.each |op, i| + { + if (i > 0) w(" and ") + expr(op) + } + w(")") + } + +////////////////////////////////////////////////////////////////////////// +// Type Checks +////////////////////////////////////////////////////////////////////////// + + private Void isExpr(TypeCheckExpr e) + { + w("ObjUtil.is_(") + expr(e.target) + w(", ") + typeRef(e.check) + w(")") + } + + private Void isnotExpr(TypeCheckExpr e) + { + w("(not ObjUtil.is_(") + expr(e.target) + w(", ") + typeRef(e.check) + w("))") + } + + private Void asExpr(TypeCheckExpr e) + { + w("ObjUtil.as_(") + expr(e.target) + w(", ") + typeRef(e.check) + w(")") + } + + private Void coerce(TypeCheckExpr e) + { + w("ObjUtil.coerce(") + expr(e.target) + w(", ") + typeRef(e.check) + w(")") + } + + private Void typeRef(CType t) + { + // Sanitize Java FFI types so they're valid Python strings + // (they'll fail at runtime if actually used, like JS transpiler) + sig := PyUtil.sanitizeJavaFfi(t.signature) + str(sig) + } + +////////////////////////////////////////////////////////////////////////// +// Ternary / Elvis +////////////////////////////////////////////////////////////////////////// + + private Void ternary(TernaryExpr e) + { + w("(") + // Handle assignments in ternary using walrus operator + ternaryBranch(e.trueExpr) + w(" if ") + expr(e.condition) + w(" else ") + ternaryBranch(e.falseExpr) + w(")") + } + + ** Output ternary branch, converting assignments to walrus operator + private Void ternaryBranch(Expr e) + { + // Unwrap coerce if present + inner := unwrapCoerce(e) + + // If it's an assignment, convert x = val to (x := val) + if (inner.id == ExprId.assign) + { + assign := inner as BinaryExpr + // For field assignments, fall back to regular expr (can't use walrus) + if (assign.lhs.id == ExprId.field) + { + expr(e) + return + } + // Use walrus operator for local var assignment + w("(") + expr(assign.lhs) + w(" := ") + expr(assign.rhs) + w(")") + } + else + { + expr(e) + } + } + + private Void elvis(BinaryExpr e) + { + // a ?: b -> (lambda v: v if v is not None else b)(a) + w("((lambda _v: _v if _v is not None else ") + expr(e.rhs) + w(")(") + expr(e.lhs) + w("))") + } + +////////////////////////////////////////////////////////////////////////// +// Shortcuts (operators) +////////////////////////////////////////////////////////////////////////// + + private Void shortcut(ShortcutExpr e) + { + // First, try operator maps for binary operators + binaryOp := PyUtil.binaryOperators.get(e.method.qname) + if (binaryOp != null) + { + doShortcutBinaryOp(e, binaryOp) + return + } + + // Try unary operators + unaryOp := PyUtil.unaryOperators.get(e.method.qname) + if (unaryOp != null) + { + w(unaryOp) + expr(e.target) + return + } + + // Fall back to switch for special cases + op := e.op + switch (op) + { + case ShortcutOp.eq: + // Use ObjUtil for NaN-aware comparison (NaN == NaN should be true) + // This matches JS transpiler behavior which uses ObjUtil.compareNE/compareEQ + if (e.opToken == Token.notEq) + comparison(e, "compare_ne") + else + comparison(e, "equals") + return + case ShortcutOp.cmp: + // Check the opToken for comparison type + switch (e.opToken) + { + case Token.lt: comparison(e, "compare_lt") + case Token.ltEq: comparison(e, "compare_le") + case Token.gt: comparison(e, "compare_gt") + case Token.gtEq: comparison(e, "compare_ge") + default: comparison(e, "compare") // <=> + } + case ShortcutOp.negate: w("(-"); expr(e.target); w(")") + case ShortcutOp.increment: increment(e) + case ShortcutOp.decrement: decrement(e) + case ShortcutOp.get: indexGet(e) + case ShortcutOp.set: indexSet(e) + // Fallback for arithmetic ops if not in map + case ShortcutOp.plus: doShortcutBinaryOp(e, "+") + case ShortcutOp.minus: doShortcutBinaryOp(e, "-") + case ShortcutOp.mult: doShortcutBinaryOp(e, "*") + case ShortcutOp.div: divOp(e) // Use ObjUtil.div for Fantom semantics (truncated) + case ShortcutOp.mod: modOp(e) // Use ObjUtil.mod for Fantom semantics (truncated) + default: throw UnsupportedErr("Unhandled shortcut operator: $op") + } + } + + private Void doShortcutBinaryOp(ShortcutExpr e, Str op) + { + // String + non-string: route to Str.plus for proper type conversion + if (op == "+" && !e.isAssign && isStringPlusNonString(e)) + { + stringPlusNonString(e) + return + } + + // Compound assignment (x *= 3 -> x = x * 3) + if (e.isAssign) + { + compoundAssign(e, op) + return + } + + // Simple binary op + w("(") + expr(e.target) + w(" ").w(op).w(" ") + expr(e.args.first) + w(")") + } + + ** Compound assignment dispatch: handles local vars, fields, Wrap$.val, and index access + private Void compoundAssign(ShortcutExpr e, Str op) + { + target := unwrapCoerce(e.target) + + if (target.id == ExprId.localVar) + { + localExpr := target as LocalVarExpr + varName := escapeName(localExpr.var.name) + // String += null needs Str.plus + if (isStringPlusNullAssign(e)) + { + w("(").w(varName).w(" := ") + sysPrefix() + w("Str.plus(").w(varName).w(", ") + expr(e.args.first) + w("))") + } + else + { + w("(").w(varName).w(" := (").w(varName).w(" ").w(op).w(" ") + expr(e.args.first) + w("))") + } + } + else if (target.id == ExprId.field) + { + fieldExpr := target as FieldExpr + // Wrap$.val -> treat as local variable + origName := isWrapValAccess(fieldExpr) + if (origName != null) + { + varName := escapeName(origName) + w("(").w(varName).w(" := (").w(varName).w(" ").w(op).w(" ") + expr(e.args.first) + w("))") + } + else + { + // Normal field: target._field = target._field op value + escapedName := escapeName(fieldExpr.field.name) + if (fieldExpr.target != null) { expr(fieldExpr.target); w(".") } + w("_").w(escapedName).w(" = ") + if (fieldExpr.target != null) { expr(fieldExpr.target); w(".") } + w("_").w(escapedName).w(" ").w(op).w(" ") + expr(e.args.first) + } + } + else if (target.id == ExprId.shortcut) + { + shortcutTarget := target as ShortcutExpr + if (shortcutTarget.op == ShortcutOp.get) + indexCompoundAssign(shortcutTarget, op, e.args.first) + else + { + w("("); expr(e.target); w(" ").w(op).w(" "); expr(e.args.first); w(")") + } + } + else + { + // Fallback + w("("); expr(e.target); w(" ").w(op).w(" "); expr(e.args.first); w(")") + } + } + + ** Check if this is a string + non-string pattern that needs Str.plus() + ** In Fantom, string + anything converts the other operand to string + ** Also handles nullable strings (Str?) which might be null at runtime + private Bool isStringPlusNonString(ShortcutExpr e) + { + // If neither is a string, no special handling needed + targetIsStr := e.target?.ctype?.toNonNullable?.isStr ?: false + argIsStr := e.args.first?.ctype?.toNonNullable?.isStr ?: false + + if (!targetIsStr && !argIsStr) return false + + // If one is string and other is NOT string, use Str.plus for conversion + if (targetIsStr && !argIsStr) return true + if (argIsStr && !targetIsStr) return true + + // Both are strings - but check if either is nullable (might be null at runtime) + // Python can't do "str" + None, so we need Str.plus for null handling + targetIsNullable := e.target?.ctype?.isNullable ?: false + argIsNullable := e.args.first?.ctype?.isNullable ?: false + + if (targetIsNullable || argIsNullable) return true + + // Both are non-null strings - use native Python concatenation + return false + } + + ** Check if this is a string += null compound assignment pattern + private Bool isStringPlusNullAssign(ShortcutExpr e) + { + // Check if RHS is null + if (e.args.first?.id != ExprId.nullLiteral) return false + + // Check if target is string type + return e.target?.ctype?.toNonNullable?.isStr ?: false + } + + ** Handle string + non-string concatenation using sys.Str.plus() + private Void stringPlusNonString(ShortcutExpr e) + { + sysPrefix() + w("Str.plus(") + expr(e.target) + w(", ") + expr(e.args.first) + w(")") + } + + ** Handle indexed compound assignment: x[i] += val -> x[i] = x[i] + val + private Void indexCompoundAssign(ShortcutExpr indexExpr, Str op, Expr value) + { + // Generate: container[index] = container[index] op value + // We need to evaluate container and index only once in case they have side effects + // For simplicity, generate: target[index] = target[index] op value + + expr(indexExpr.target) + w("[") + expr(indexExpr.args.first) + w("] = ") + expr(indexExpr.target) + w("[") + expr(indexExpr.args.first) + w("] ").w(op).w(" ") + expr(value) + } + + private Void comparison(ShortcutExpr e, Str method) + { + w("ObjUtil.").w(method).w("(") + expr(e.target) + w(", ") + expr(e.args.first) + w(")") + } + + private Void divOp(ShortcutExpr e) + { + // Float division uses Python / directly (no truncation issue) + if (e.target?.ctype?.toNonNullable?.isFloat ?: false) + { + doShortcutBinaryOp(e, "/") + return + } + // Int division uses ObjUtil.div for truncated division semantics + // (Python // is floor division, Fantom uses truncated toward zero) + objUtilOp(e, "div") + } + + private Void modOp(ShortcutExpr e) + { + // Use ObjUtil.mod for Fantom-style modulo semantics + // (truncated division vs Python's floor division) + objUtilOp(e, "mod") + } + + ** Emit ObjUtil.{method}(target, arg) with compound assignment support + private Void objUtilOp(ShortcutExpr e, Str method) + { + // Compound assignment to local var: x /= y -> (x := ObjUtil.div(x, y)) + if (e.isAssign) + { + target := unwrapCoerce(e.target) + if (target.id == ExprId.localVar) + { + localExpr := target as LocalVarExpr + varName := escapeName(localExpr.var.name) + w("(").w(varName).w(" := ObjUtil.").w(method).w("(").w(varName).w(", ") + expr(e.args.first) + w("))") + return + } + } + // Simple call or non-local compound assignment fallback + w("ObjUtil.").w(method).w("(") + expr(e.target) + w(", ") + expr(e.args.first) + w(")") + } + + ** Unwrap coerce expressions to get the underlying expression + private Expr unwrapCoerce(Expr e) + { + if (e.id == ExprId.coerce) + { + te := e as TypeCheckExpr + return unwrapCoerce(te.target) + } + return e + } + +////////////////////////////////////////////////////////////////////////// +// Increment / Decrement +////////////////////////////////////////////////////////////////////////// + + // Python has no ++ or -- operators. We generate different code depending on target: + // - Local vars: walrus operator (x := x + 1) for pre, tuple trick for post + // - Fields: ObjUtil.inc_field(obj, "_name") / ObjUtil.inc_field_post(...) + // - Index: ObjUtil.inc_index(container, key) / ObjUtil.inc_index_post(...) + // + // Post-increment/decrement returns the OLD value (before modification). + // Pre-increment/decrement returns the NEW value (after modification). + + private Void increment(ShortcutExpr e) { incDec(e, "+", "inc") } + + private Void decrement(ShortcutExpr e) { incDec(e, "-", "dec") } + + ** Unified increment/decrement: op is "+" or "-", prefix is "inc" or "dec" + ** Pre (++x/--x) returns new value, post (x++/x--) returns old value + private Void incDec(ShortcutExpr e, Str op, Str prefix) + { + target := unwrapCoerce(e.target) + isPost := e.isPostfixLeave + + if (target.id == ExprId.field) + { + fieldExpr := target as FieldExpr + + // Check for Wrap$.val -- treat as local variable + origName := isWrapValAccess(fieldExpr) + if (origName != null) + { + varName := escapeName(origName) + if (isPost) + { + w("((_old_").w(varName).w(" := ").w(varName).w(", ") + w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1, ") + w("_old_").w(varName).w(")[2])") + } + else + { + w("(").w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1)") + } + return + } + + // Normal field access - use ObjUtil helper + method := isPost ? "${prefix}_field_post" : "${prefix}_field" + w("ObjUtil.").w(method).w("(") + if (fieldExpr.target != null) + expr(fieldExpr.target) + else + w("self") + w(", \"_").w(escapeName(fieldExpr.field.name)).w("\")") + } + else if (target.id == ExprId.shortcut) + { + // Index access (list[i]++/--) - use ObjUtil helper + shortcutExpr := target as ShortcutExpr + if (shortcutExpr.op == ShortcutOp.get) + { + method := isPost ? "${prefix}_index_post" : "${prefix}_index" + w("ObjUtil.").w(method).w("(") + expr(shortcutExpr.target) + w(", ") + expr(shortcutExpr.args.first) + w(")") + } + else + { + w("(") + expr(e.target) + w(" ").w(op).w(" 1)") + } + } + else if (target.id == ExprId.localVar) + { + localExpr := target as LocalVarExpr + varName := escapeName(localExpr.var.name) + if (isPost) + { + // Post: return old value via tuple trick: ((_old := x, x := x +/- 1, _old)[2]) + w("((_old_").w(varName).w(" := ").w(varName).w(", ") + w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1, ") + w("_old_").w(varName).w(")[2])") + } + else + { + w("(").w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1)") + } + } + else + { + // Fallback - just apply op (won't assign but won't error) + w("(") + expr(e.target) + w(" ").w(op).w(" 1)") + } + } + + private Void indexGet(ShortcutExpr e) + { + // Check target type for special handling + targetType := e.target?.ctype?.toNonNullable + arg := e.args.first + argType := arg.ctype?.toNonNullable + + // String indexing: str[i] returns Int codepoint, str[range] returns substring + if (targetType?.isStr ?: false) + { + if (argType?.isRange ?: false) + { + // str[range] -> sys.Str.get_range(str, range) + sysPrefix() + w("Str.get_range(") + expr(e.target) + w(", ") + expr(arg) + w(")") + } + else + { + // str[i] -> sys.Str.get(str, i) returns Int codepoint + sysPrefix() + w("Str.get(") + expr(e.target) + w(", ") + expr(arg) + w(")") + } + return + } + + // Check if index is a Range - need to use sys.List.get_range() instead + if (argType?.isRange ?: false) + { + // list[range] -> sys.List.get_range(list, range) + sysPrefix() + w("List.get_range(") + expr(e.target) + w(", ") + expr(arg) + w(")") + } + else + { + expr(e.target) + w("[") + expr(arg) + w("]") + } + } + + private Void indexSet(ShortcutExpr e) + { + expr(e.target) + w("[") + expr(e.args.first) + w("] = ") + expr(e.args[1]) + } + +////////////////////////////////////////////////////////////////////////// +// Static Targets and Type Literals +////////////////////////////////////////////////////////////////////////// + + private Void staticTarget(StaticTargetExpr e) + { + writeTypeRef(e.ctype.pod.name, PyUtil.escapeTypeName(e.ctype.name)) + } + + private Void typeLiteral(LiteralExpr e) + { + // Type literal like Bool# - create a Type instance + t := e.val as CType + if (t != null) + { + sig := PyUtil.sanitizeJavaFfi(t.signature) + sysPrefix() + w("Type.find(").str(sig).w(")") + } + else + { + w("None") + } + } + + private Void slotLiteral(SlotLiteralExpr e) + { + // Slot literal like Int#plus - create Method.find() or Field.find() + // Use original Fantom name (not snake_case) - Type.slot() handles the conversion + parentSig := e.parent.signature + slotName := e.name // Keep original Fantom camelCase name + + // Determine if it's a method or field + if (e.slot != null && e.slot is CField) + { + sysPrefix() + w("Field.find(").str("${parentSig}.${slotName}").w(")") + } + else + { + // Default to Method + sysPrefix() + w("Method.find(").str("${parentSig}.${slotName}").w(")") + } + } + +////////////////////////////////////////////////////////////////////////// +// Closures +////////////////////////////////////////////////////////////////////////// + + private Void closure(ClosureExpr e) + { + // Check if this closure was already registered during scan phase + closureId := m.findClosureId(e) + if (closureId != null) + { + // Already emitted as def, just output reference + w("_closure_${closureId}") + return + } + + // Try various fields for closure body + Block? codeBlock := null + if (e.doCall != null && e.doCall.code != null) + codeBlock = e.doCall.code + else if (e.call != null && e.call.code != null) + codeBlock = e.call.code + else if (e.code != null) + codeBlock = e.code + + if (codeBlock != null) + { + stmts := codeBlock.stmts + + // Single-statement closures can use inline lambda + // Filter out local var decls, synthetic/void return statements + realStmts := stmts.findAll |s| + { + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + return ret.expr != null + } + if (s.id == StmtId.localDef) return false + return true + } + + // Check if simple single-expression body (can use lambda) + // Assignments cannot be in lambdas - they're statements, not expressions + if (realStmts.size == 1) + { + stmt := realStmts.first + if (stmt.id == StmtId.returnStmt) + { + ret := stmt as ReturnStmt + // Skip if return contains assignment or index set + if (ret.expr != null && !isAssignmentExpr(ret.expr)) + { + closureLambda(e) |->| { expr(ret.expr) } + return + } + } + // Handle throw statement - convert to ObjUtil.throw_() for lambda + if (stmt.id == StmtId.throwStmt) + { + throwStmt := stmt as ThrowStmt + closureLambda(e) |->| + { + w("ObjUtil.throw_(") + expr(throwStmt.exception) + w(")") + } + return + } + if (stmt.id == StmtId.expr) + { + exprStmt := stmt as ExprStmt + // Assignments can't be in lambda body (unless we convert them) + if (!isAssignmentExpr(exprStmt.expr)) + { + closureLambda(e) |->| { expr(exprStmt.expr) } + return + } + // Check if it's an index set: map[key] = value + // Can convert to: map.__setitem__(key, value) for lambda + if (exprStmt.expr.id == ExprId.shortcut) + { + se := exprStmt.expr as ShortcutExpr + if (se.op == ShortcutOp.set && se.args.size == 2) + { + closureLambda(e) |->| + { + expr(se.target) + w(".__setitem__(") + expr(se.args.first) // key + w(", ") + expr(se.args[1]) // value + w(")") + } + return + } + } + } + } + } + + // Fallback - wrap with Func.make_closure() even when body not handled + // This ensures ALL closures have bind(), params(), etc. (consistent with JS transpiler) + closureLambda(e) |->| { none } + } + + ** Generate lambda with outer self capture if needed + ** Uses Func.make_closure() for proper Fantom Func methods (bind, params, etc.) + private Void closureLambda(ClosureExpr e, |->| body) + { + // Check if closure captures outer this (has $this field) + needsOuter := e.cls?.fieldDefs?.any |f| { f.name == "\$this" } ?: false + + // Get type info from signature + sig := e.signature as FuncType + + // Determine immutability from compiler analysis + immutCase := m.closureImmutability(e) + + // Generate Func.make_closure(spec, lambda) + sysPrefix() + w("Func.make_closure({") + + // Returns type + retType := sig?.returns?.signature ?: "sys::Void" + w("\"returns\": ").str(retType).w(", ") + + // Immutability case from compiler analysis + w("\"immutable\": ").str(immutCase).w(", ") + + // Params (sanitize Java FFI type signatures) + w("\"params\": [") + if (e.doCall?.params != null) + { + e.doCall.params.each |p, i| + { + if (i > 0) w(", ") + pSig := PyUtil.sanitizeJavaFfi(p.type.signature) + w("{\"name\": ").str(p.name).w(", \"type\": ").str(pSig).w("}") + } + } + else if (sig != null && !sig.params.isEmpty) + { + sig.params.each |p, i| + { + if (i > 0) w(", ") + name := sig.names.getSafe(i) ?: "_p${i}" + pSig := PyUtil.sanitizeJavaFfi(p.signature) + w("{\"name\": ").str(name).w(", \"type\": ").str(pSig).w("}") + } + } + w("]}, ") + + // Lambda body + if (needsOuter) + { + w("(lambda ") + closureParams(e) + w(", _outer=self: ") + m.inClosureWithOuter = true + body() + m.inClosureWithOuter = false + w(")") + } + else + { + w("(lambda ") + closureParams(e) + w(": ") + body() + w(")") + } + + w(")") // Close Func.make_closure() + } + + ** Check if expression is an assignment (can't be in lambda body) + ** Note: Increment/decrement CAN be in lambdas because they transpile to + ** ObjUtil.incField()/decField() which are function calls returning values + private Bool isAssignmentExpr(Expr e) + { + // Direct assignment + if (e.id == ExprId.assign) return true + + // Index set (list[i] = x) + if (e.id == ExprId.shortcut) + { + se := e as ShortcutExpr + if (se.op == ShortcutOp.set) return true + // Compound assignment (x += 5), but NOT increment/decrement + // Increment/decrement transpile to ObjUtil.incField() which returns a value + if (se.isAssign && se.op != ShortcutOp.increment && se.op != ShortcutOp.decrement) + return true + } + + // Check wrapped in coerce + if (e.id == ExprId.coerce) + { + tc := e as TypeCheckExpr + return isAssignmentExpr(tc.target) + } + + return false + } + + private Void closureParams(ClosureExpr e) + { + // Get the signature - this is the EXPECTED type (what the target method wants) + // which may have fewer params than declared in source code (Fantom allows coercion) + sig := e.signature as FuncType + expectedParamCount := sig?.params?.size ?: 0 + + // Use doCall.params for parameter names, but LIMIT to expected count + // This handles cases where closure declares extra params that get coerced away + // ALL params get =None default because Python (unlike JS) requires all args + // JS: f(a,b) called as f() gives a=undefined, b=undefined + // Python: f(a,b) called as f() raises TypeError + if (e.doCall?.params != null && !e.doCall.params.isEmpty) + { + // Only output up to expectedParamCount params (or all if signature unavailable) + maxParams := expectedParamCount > 0 ? expectedParamCount : e.doCall.params.size + actualCount := e.doCall.params.size.min(maxParams) + + actualCount.times |i| + { + if (i > 0) w(", ") + w(escapeName(e.doCall.params[i].name)).w("=None") + } + + // If no params were output but we need at least one for lambda syntax + // Use _=None so it doesn't require an argument + if (actualCount == 0 && expectedParamCount == 0) + w("_=None") + } + // Fallback to signature for it-blocks with implicit it + else + { + if (sig != null && !sig.params.isEmpty) + { + // Check if this is an it-block (uses implicit it) + if (e.isItBlock) + { + w("it=None") + } + else + { + sig.names.each |name, i| + { + if (i > 0) w(", ") + if (name.isEmpty) + w("_p${i}=None") + else + w(escapeName(name)).w("=None") + } + } + } + else + { + w("_=None") // Lambda needs placeholder but shouldn't require arg + } + } + } +} diff --git a/src/fanc/fan/py/PyPrinter.fan b/src/fanc/fan/py/PyPrinter.fan new file mode 100644 index 000000000..0c115580d --- /dev/null +++ b/src/fanc/fan/py/PyPrinter.fan @@ -0,0 +1,283 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using compiler + +** +** Base class for Python code printers +** +class PyPrinter : CodePrinter +{ + new make(OutStream out) { this.m = PyPrinterState(out) } + + override PyPrinterState m + + ** End of statement (newline in Python) + This eos() { nl } + + ** Write a colon and newline (for block start) + This colon() { w(":").nl } + + ** Write a Python string literal + ** Handles UTF-16 surrogate pairs for code points > 0xFFFF + This str(Str s) + { + w("\"") + i := 0 + while (i < s.size) + { + ch := s[i] + code := ch.toInt + + // Check for high surrogate (0xD800-0xDBFF) followed by low surrogate (0xDC00-0xDFFF) + if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.size) + { + lowCh := s[i + 1] + lowCode := lowCh.toInt + if (lowCode >= 0xDC00 && lowCode <= 0xDFFF) + { + // Combine surrogate pair into full code point + fullCode := ((code - 0xD800) * 0x400) + (lowCode - 0xDC00) + 0x10000 + w("\\U${fullCode.toHex(8)}") + i += 2 + continue + } + } + + switch (ch) + { + case '\n': w("\\n") + case '\r': w("\\r") + case '\t': w("\\t") + case '\"': w("\\\"") + case '\\': w("\\\\") + default: + // Escape control characters and non-ASCII to keep output as valid ASCII + if (code < 0x20 || code > 0x7E) + { + if (code < 0x100) + w("\\x${code.toHex(2)}") + else if (code < 0x10000) + w("\\u${code.toHex(4)}") + else + w("\\U${code.toHex(8)}") + } + else + { + w(ch.toChar) + } + } + i++ + } + w("\"") + return this + } + + ** Write 'pass' statement for empty blocks + This pass() { w("pass").eos } + + ** Write import statement + This importStmt(Str module, Str? alias := null) + { + w("import ").w(module) + if (alias != null) w(" as ").w(alias) + eos + return this + } + + ** Write from...import statement + This fromImport(Str module, Str[] names) + { + w("from ").w(module).w(" import ") + names.each |n, i| + { + if (i > 0) w(", ") + w(n) + } + eos + return this + } + + ** Write Python None literal + This none() { w("None") } + + ** Write Python True literal + This true_() { w("True") } + + ** Write Python False literal + This false_() { w("False") } + + ** Escape Python reserved words + Str escapeName(Str name) { PyUtil.escapeName(name) } + + ** Convert Fantom qname to Python module path + Str qnameToPy(CType ctype) + { + "${ctype.pod.name}.${ctype.name}" + } +} + +************************************************************************** +** PyPrinterState +************************************************************************** + +class PyPrinterState : CodePrinterState +{ + new make(OutStream out) : super(out) {} + + ** Current type being generated + TypeDef? curType + + ** Current method being generated + MethodDef? curMethod + + ** Are we in a static context (static method/initializer)? + ** When true, 'self' is not available + Bool inStaticContext := false + + ** Are we inside a closure that captures outer this? + Bool inClosureWithOuter := false + + ** Counter for generating unique closure names + Int closureCount := 0 + + ** Pending multi-statement closures to emit before next statement + Obj[] pendingClosures := [,] + + ** List of [ClosureExpr, Int] pairs for lookup during expr phase + ** We use identity comparison since ClosureExpr isn't immutable + Obj[] registeredClosures := [,] + + ** Map: closureId -> statementIndex where first used + Int:Int closureFirstUse := [:] + + ** Current statement index during emission + Int stmtIndex := 0 + + ** Closure nesting depth - when > 0, we're scanning inside a closure body + ** Nested closures should NOT be extracted to method level + Int closureDepth := 0 + + ** Current for loop update expression - when set, continue statements + ** must emit this expression before the continue to prevent infinite loops + Expr? forLoopUpdate + + ** Look up closure ID by object identity + Int? findClosureId(Obj ce) + { + for (i := 0; i < registeredClosures.size; i++) + { + pair := registeredClosures[i] as Obj[] + if (pair[0] === ce) return pair[1] + } + return null + } + + ** Flag to prevent closure emission during expression processing + Bool collectingClosures := false + + ** Get next closure ID and increment counter + Int nextClosureId() { closureCount++ } + + ** Counter for generating unique switch variable names + Int switchVarCount := 0 + + ** Get next switch variable ID and increment counter + Int nextSwitchVarId() { switchVarCount++ } + + ////////////////////////////////////////////////////////////////////////// + // Nonlocal Variable Tracking (for closure-captured mutable variables) + ////////////////////////////////////////////////////////////////////////// + + ** Maps Wrap$ wrapper variable names to their original variable names + ** Example: "x_Wrapper" -> "x" when x is captured and modified in a closure + ** The Fantom compiler generates Wrap$* classes for these variables; + ** we use Python's nonlocal keyword instead of ObjUtil.cvar() wrappers + Str:Str nonlocalVars := [:] + + ** Record a wrapper->original variable mapping + ** Called when we detect a Wrap$.make() definition in a localDef + Void recordNonlocal(Str wrapperVarName, Str originalVarName) + { + nonlocalVars[wrapperVarName] = originalVarName + } + + ** Get the original variable name for a Wrap$ wrapper variable + ** Returns null if this variable is not a known wrapper + Str? getNonlocalOriginal(Str wrapperVarName) + { + return nonlocalVars.get(wrapperVarName) + } + + ** Get all original variable names that need nonlocal declarations in closures + Str[] getNonlocalNames() + { + return nonlocalVars.vals + } + + ** Clear closure state (call between methods) + Void clearClosures() + { + pendingClosures.clear + registeredClosures.clear + closureFirstUse.clear + stmtIndex = 0 + closureDepth = 0 // Reset nesting depth + nonlocalVars.clear // Clear nonlocal mappings for new method + } + + ////////////////////////////////////////////////////////////////////////// + // Closure Immutability + ////////////////////////////////////////////////////////////////////////// + + ** Determine closure immutability case from ClosureExpr.cls + ** The compiler's ClosureToImmutable step adds synthetic methods: + ** - Always immutable: isImmutable() { return true } + ** - Never immutable: toImmutable() throws NotImmutableErr (no isImmutable override) + ** - Maybe immutable: isImmutable() { return this.immutable } + toImmutable() makes copy + ** Returns: "always", "never", or "maybe" + Str closureImmutability(ClosureExpr e) + { + cls := e.cls + if (cls == null) return "always" // no captures = always immutable + + // Find isImmutable method (added by ClosureToImmutable step) + isImmMethod := cls.methodDefs.find |m| { m.name == "isImmutable" && m.isSynthetic } + + if (isImmMethod == null) + { + // No isImmutable override - check for toImmutable that throws (never immutable) + toImmMethod := cls.methodDefs.find |m| { m.name == "toImmutable" && m.isSynthetic } + if (toImmMethod != null) + { + // Check if toImmutable throws NotImmutableErr (never case) + // The method body contains ThrowStmt if it's never immutable + if (toImmMethod.code?.stmts?.any |s| { s.id == StmtId.throwStmt } ?: false) + return "never" + } + // No isImmutable and no throwing toImmutable = always immutable + return "always" + } + + // Check what isImmutable returns + // Case 1: returns true literal -> always immutable + // Case 2: returns false literal -> never immutable (shouldn't happen, but handle it) + // Case 3: returns field reference -> maybe immutable + retStmt := isImmMethod.code?.stmts?.first as ReturnStmt + if (retStmt?.expr != null) + { + if (retStmt.expr.id == ExprId.trueLiteral) return "always" + if (retStmt.expr.id == ExprId.falseLiteral) return "never" + // Field reference (isImmutable() { return immutable }) = maybe + if (retStmt.expr.id == ExprId.field) return "maybe" + } + + // Couldn't determine - default to maybe (safest) + return "maybe" + } +} diff --git a/src/fanc/fan/py/PyStmtPrinter.fan b/src/fanc/fan/py/PyStmtPrinter.fan new file mode 100644 index 000000000..6786ad9b4 --- /dev/null +++ b/src/fanc/fan/py/PyStmtPrinter.fan @@ -0,0 +1,1493 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using compiler + +** +** Python statement printer +** +class PyStmtPrinter : PyPrinter +{ + new make(PyPrinter parent) : super.make(parent.m.out) + { + this.m = parent.m + this.exprPrinter = PyExprPrinter(this) + } + + private PyExprPrinter exprPrinter + + ** Print an expression + Void expr(Expr e) { exprPrinter.expr(e) } + + ** Print a statement + Void stmt(Stmt s) + { + // Check if we need to emit closures before this statement + emitPendingClosuresForStatement() + + switch (s.id) + { + case StmtId.nop: return // no-op + case StmtId.expr: exprStmt(s) + case StmtId.localDef: localDef(s) + case StmtId.ifStmt: ifStmt(s) + case StmtId.returnStmt: returnStmt(s) + case StmtId.throwStmt: throwStmt(s) + case StmtId.forStmt: forStmt(s) + case StmtId.whileStmt: whileStmt(s) + case StmtId.breakStmt: w("break").eos + case StmtId.continueStmt: continueStmt() + case StmtId.tryStmt: tryStmt(s) + case StmtId.switchStmt: switchStmt(s) + default: + w("# TODO: stmt ${s.id}").eos + } + } + +////////////////////////////////////////////////////////////////////////// +// Method-Level Closure Scanning +////////////////////////////////////////////////////////////////////////// + + ** Scan entire method body for multi-statement closures + ** Also pre-scans for Wrap$ definitions so nonlocal names are known before closures emit + Void scanMethodForClosures(Block b) + { + // Pre-scan: find ALL Wrap$ definitions in the method body (recursively) + // This must happen first so that when closures are emitted, getNonlocalNames() + // returns all nonlocal variables -- including ones defined after the closure + scanBlockForNonlocals(b) + + // Use index to track statement location + b.stmts.each |s, idx| + { + m.stmtIndex = idx + scanStmt(s) + } + } + + ** Recursively scan a block for Wrap$ wrapper definitions + ** Records all nonlocal variable mappings before any closures are emitted + private Void scanBlockForNonlocals(Block b) + { + b.stmts.each |s| { scanStmtForNonlocals(s) } + } + + ** Scan a statement for Wrap$ definitions (and recurse into nested blocks) + private Void scanStmtForNonlocals(Stmt s) + { + switch (s.id) + { + case StmtId.localDef: + localDef := s as LocalDefStmt + prescanNonlocal(localDef) + case StmtId.ifStmt: + ifStmt := s as IfStmt + scanBlockForNonlocals(ifStmt.trueBlock) + if (ifStmt.falseBlock != null) + scanBlockForNonlocals(ifStmt.falseBlock) + case StmtId.whileStmt: + whileStmt := s as WhileStmt + scanBlockForNonlocals(whileStmt.block) + case StmtId.forStmt: + forStmt := s as ForStmt + if (forStmt.init != null) scanStmtForNonlocals(forStmt.init) + if (forStmt.block != null) + scanBlockForNonlocals(forStmt.block) + case StmtId.tryStmt: + tryStmt := s as TryStmt + scanBlockForNonlocals(tryStmt.block) + tryStmt.catches.each |c| { scanBlockForNonlocals(c.block) } + if (tryStmt.finallyBlock != null) + scanBlockForNonlocals(tryStmt.finallyBlock) + case StmtId.switchStmt: + switchStmt := s as SwitchStmt + switchStmt.cases.each |c| { scanBlockForNonlocals(c.block) } + if (switchStmt.defaultBlock != null) + scanBlockForNonlocals(switchStmt.defaultBlock) + } + } + + ** Pre-scan a localDef statement for Wrap$.make() pattern (same logic as detectAndRecordNonlocal) + private Void prescanNonlocal(LocalDefStmt s) + { + if (s.init == null) return + + // Unwrap coerces and assignments + initExpr := s.init + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + if (initExpr.id == ExprId.assign) + { + assign := initExpr as BinaryExpr + initExpr = assign.rhs + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + } + + if (initExpr.id != ExprId.call) return + + call := initExpr as CallExpr + if (call.method.name == "make" && call.target == null && !call.method.isStatic && call.args.size == 1) + { + parentType := call.method.parent + if (parentType.isSynthetic && parentType.name.startsWith("Wrap\$")) + { + arg := call.args.first + while (arg.id == ExprId.coerce) + { + tc := arg as TypeCheckExpr + arg = tc.target + } + + wrapperVarName := s.name + if (arg.id == ExprId.localVar) + { + localArg := arg as LocalVarExpr + m.recordNonlocal(wrapperVarName, localArg.var.name) + } + else + { + m.recordNonlocal(wrapperVarName, wrapperVarName) + } + } + } + } + + ** Recursively scan a statement for closure expressions + private Void scanStmt(Stmt s) + { + switch (s.id) + { + case StmtId.expr: + exprStmt := s as ExprStmt + scanExprForClosures(exprStmt.expr) + case StmtId.localDef: + localDef := s as LocalDefStmt + if (localDef.init != null) + scanExprForClosures(localDef.init) + case StmtId.ifStmt: + ifStmt := s as IfStmt + scanExprForClosures(ifStmt.condition) + scanInnerBlockForClosures(ifStmt.trueBlock) + if (ifStmt.falseBlock != null) + scanInnerBlockForClosures(ifStmt.falseBlock) + case StmtId.returnStmt: + ret := s as ReturnStmt + if (ret.expr != null) + scanExprForClosures(ret.expr) + case StmtId.throwStmt: + throwStmt := s as ThrowStmt + scanExprForClosures(throwStmt.exception) + case StmtId.whileStmt: + whileStmt := s as WhileStmt + scanExprForClosures(whileStmt.condition) + scanInnerBlockForClosures(whileStmt.block) + case StmtId.forStmt: + forStmt := s as ForStmt + if (forStmt.init != null) scanStmt(forStmt.init) + if (forStmt.condition != null) scanExprForClosures(forStmt.condition) + if (forStmt.update != null) scanExprForClosures(forStmt.update) + if (forStmt.block != null) scanInnerBlockForClosures(forStmt.block) + case StmtId.tryStmt: + tryStmt := s as TryStmt + scanInnerBlockForClosures(tryStmt.block) + tryStmt.catches.each |c| { scanInnerBlockForClosures(c.block) } + if (tryStmt.finallyBlock != null) + scanInnerBlockForClosures(tryStmt.finallyBlock) + case StmtId.switchStmt: + switchStmt := s as SwitchStmt + scanExprForClosures(switchStmt.condition) + switchStmt.cases.each |c| { scanInnerBlockForClosures(c.block) } + if (switchStmt.defaultBlock != null) + scanInnerBlockForClosures(switchStmt.defaultBlock) + } + } + + ** Scan inner block (don't increment method stmtIndex) + private Void scanInnerBlockForClosures(Block b) + { + b.stmts.each |s| { scanStmt(s) } + } + + ** Recursively scan an expression for closures + private Void scanExprForClosures(Expr e) + { + // Check if this is a closure that needs extraction + if (e.id == ExprId.closure) + { + ce := e as ClosureExpr + if (isMultiStatementClosure(ce)) + { + // Only register closures at method level (depth == 0) + // Nested closures (depth > 0) will be emitted inside their parent closure. + // If a closure has nested multi-statement closures, isMultiStatementClosure + // returns true, so the parent will be extracted and nested defs can be + // properly emitted inside it. + if (m.closureDepth == 0) + { + // Find existing or register new closure + closureId := m.findClosureId(ce) + if (closureId == null) + { + closureId = m.nextClosureId + m.pendingClosures.add([ce, closureId]) + m.registeredClosures.add([ce, closureId]) + } + + // Record first usage location if not already recorded + if (!m.closureFirstUse.containsKey(closureId)) + { + m.closureFirstUse[closureId] = m.stmtIndex + } + } + } + } + + // Recursively scan child expressions + scanExprChildren(e) + } + + ** Scan children of an expression + private Void scanExprChildren(Expr e) + { + switch (e.id) + { + case ExprId.call: + ce := e as CallExpr + if (ce.target != null) scanExprForClosures(ce.target) + ce.args.each |arg| { scanExprForClosures(arg) } + case ExprId.construction: + // Constructor calls - scan args for closures + ce := e as CallExpr + ce.args.each |arg| { scanExprForClosures(arg) } + case ExprId.listLiteral: + // List literals can contain closures + le := e as ListLiteralExpr + le.vals.each |val| { scanExprForClosures(val) } + case ExprId.mapLiteral: + // Map literals can contain closures in values + me := e as MapLiteralExpr + me.keys.each |key| { scanExprForClosures(key) } + me.vals.each |val| { scanExprForClosures(val) } + case ExprId.shortcut: + se := e as ShortcutExpr + if (se.target != null) scanExprForClosures(se.target) + se.args.each |arg| { scanExprForClosures(arg) } + case ExprId.ternary: + te := e as TernaryExpr + scanExprForClosures(te.condition) + scanExprForClosures(te.trueExpr) + scanExprForClosures(te.falseExpr) + case ExprId.boolOr: + co := e as CondExpr + co.operands.each |op| { scanExprForClosures(op) } + case ExprId.boolAnd: + ca := e as CondExpr + ca.operands.each |op| { scanExprForClosures(op) } + case ExprId.coerce: + tc := e as TypeCheckExpr + scanExprForClosures(tc.target) + case ExprId.assign: + be := e as BinaryExpr + scanExprForClosures(be.lhs) + scanExprForClosures(be.rhs) + case ExprId.elvis: + ee := e as BinaryExpr + scanExprForClosures(ee.lhs) + scanExprForClosures(ee.rhs) + case ExprId.field: + // Field expressions can have targets that contain closures + // e.g., ActorPool() { maxThreads = 2 }.maxThreads + fe := e as FieldExpr + if (fe.target != null) scanExprForClosures(fe.target) + case ExprId.closure: + // Scan INSIDE the closure body for nested closures + // Increment depth so nested closures won't be registered at method level + cl := e as ClosureExpr + Block? codeBlock := null + if (cl.doCall != null && cl.doCall.code != null) + codeBlock = cl.doCall.code + else if (cl.call != null && cl.call.code != null) + codeBlock = cl.call.code + else if (cl.code != null) + codeBlock = cl.code + if (codeBlock != null) + { + m.closureDepth++ + scanInnerBlockForClosures(codeBlock) + m.closureDepth-- + } + // localVar, literals etc have no children with closures + } + } + + ** Scan a closure body for nested multi-statement closures and register them + ** This allows nested closures to be emitted as defs before being used + private Void scanClosureBodyForNestedClosures(ClosureExpr ce) + { + codeBlock := ce.doCall?.code ?: ce.code + if (codeBlock == null) return + + // Scan each statement, tracking index for closure emission + codeBlock.stmts.each |s, idx| + { + m.stmtIndex = idx + scanStmtForNestedClosures(s) + } + } + + ** Scan a statement for nested closures (registers them for emission) + private Void scanStmtForNestedClosures(Stmt s) + { + switch (s.id) + { + case StmtId.expr: + exprStmt := s as ExprStmt + scanExprForNestedClosures(exprStmt.expr) + case StmtId.localDef: + localDef := s as LocalDefStmt + if (localDef.init != null) + scanExprForNestedClosures(localDef.init) + case StmtId.returnStmt: + ret := s as ReturnStmt + if (ret.expr != null) + scanExprForNestedClosures(ret.expr) + case StmtId.ifStmt: + ifStmt := s as IfStmt + scanExprForNestedClosures(ifStmt.condition) + ifStmt.trueBlock.stmts.each |st| { scanStmtForNestedClosures(st) } + if (ifStmt.falseBlock != null) + ifStmt.falseBlock.stmts.each |st| { scanStmtForNestedClosures(st) } + case StmtId.whileStmt: + whileStmt := s as WhileStmt + scanExprForNestedClosures(whileStmt.condition) + whileStmt.block.stmts.each |st| { scanStmtForNestedClosures(st) } + case StmtId.forStmt: + forStmt := s as ForStmt + if (forStmt.init != null) scanStmtForNestedClosures(forStmt.init) + if (forStmt.condition != null) scanExprForNestedClosures(forStmt.condition) + if (forStmt.update != null) scanExprForNestedClosures(forStmt.update) + if (forStmt.block != null) forStmt.block.stmts.each |st| { scanStmtForNestedClosures(st) } + } + } + + ** Scan an expression for nested closures (registers them) + ** Only registers IMMEDIATE nested closures - deeper nesting will be handled + ** recursively when each nested closure writes its own body + private Void scanExprForNestedClosures(Expr e) + { + if (e.id == ExprId.closure) + { + ce := e as ClosureExpr + if (isMultiStatementClosure(ce)) + { + // Register for emission inside parent closure + closureId := m.findClosureId(ce) + if (closureId == null) + { + closureId = m.nextClosureId + m.pendingClosures.add([ce, closureId]) + m.registeredClosures.add([ce, closureId]) + } + if (!m.closureFirstUse.containsKey(closureId)) + { + m.closureFirstUse[closureId] = m.stmtIndex + } + } + + // DON'T recursively scan inside - that will happen when writeClosure + // processes this closure's body and calls scanClosureBodyForNestedClosures + return + } + + // Scan children (but not inside closures - handled above) + scanExprChildrenForNestedClosures(e) + } + + ** Scan children of an expression for nested closures + private Void scanExprChildrenForNestedClosures(Expr e) + { + switch (e.id) + { + case ExprId.call: + ce := e as CallExpr + if (ce.target != null) scanExprForNestedClosures(ce.target) + ce.args.each |arg| { scanExprForNestedClosures(arg) } + case ExprId.construction: + ce := e as CallExpr + ce.args.each |arg| { scanExprForNestedClosures(arg) } + case ExprId.shortcut: + se := e as ShortcutExpr + if (se.target != null) scanExprForNestedClosures(se.target) + se.args.each |arg| { scanExprForNestedClosures(arg) } + case ExprId.ternary: + te := e as TernaryExpr + scanExprForNestedClosures(te.condition) + scanExprForNestedClosures(te.trueExpr) + scanExprForNestedClosures(te.falseExpr) + case ExprId.boolOr: + co := e as CondExpr + co.operands.each |op| { scanExprForNestedClosures(op) } + case ExprId.boolAnd: + ca := e as CondExpr + ca.operands.each |op| { scanExprForNestedClosures(op) } + case ExprId.coerce: + tc := e as TypeCheckExpr + scanExprForNestedClosures(tc.target) + case ExprId.assign: + be := e as BinaryExpr + scanExprForNestedClosures(be.lhs) + scanExprForNestedClosures(be.rhs) + case ExprId.elvis: + ee := e as BinaryExpr + scanExprForNestedClosures(ee.lhs) + scanExprForNestedClosures(ee.rhs) + } + } + + ** Check if a closure requires multi-statement def extraction + private Bool isMultiStatementClosure(ClosureExpr ce) + { + // Check all possible code block locations (matching PyExprPrinter) + Block? codeBlock := null + if (ce.doCall != null && ce.doCall.code != null) + codeBlock = ce.doCall.code + else if (ce.call != null && ce.call.code != null) + codeBlock = ce.call.code + else if (ce.code != null) + codeBlock = ce.code + + if (codeBlock == null) return false + + stmts := codeBlock.stmts + + // Check if closure has local variable declarations + hasLocalVars := stmts.any |s| { s.id == StmtId.localDef } + if (hasLocalVars) return true + + // Check if closure has assignments (can't be in lambda body) + // Must unwrap coerces since assignments are often wrapped in type coercions + hasAssign := stmts.any |s| + { + if (s.id == StmtId.expr) + { + es := s as ExprStmt + return isAssignmentExpr(es.expr) + } + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr != null) + return isAssignmentExpr(ret.expr) + } + return false + } + if (hasAssign) return true + + // Check if closure has control flow statements that can't be in lambda body + // These include if, switch, for, while, try - they have nested blocks + hasControlFlow := stmts.any |s| + { + s.id == StmtId.ifStmt || + s.id == StmtId.switchStmt || + s.id == StmtId.forStmt || + s.id == StmtId.whileStmt || + s.id == StmtId.tryStmt + } + if (hasControlFlow) return true + + // Count real statements (excluding synthetic returns) + realStmtCount := 0 + stmts.each |s| + { + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr != null) realStmtCount++ + } + else if (s.id != StmtId.nop) + { + realStmtCount++ + } + } + + if (realStmtCount > 1) return true + + // Check if this closure contains any nested multi-statement closures. + // If so, this closure must ALSO be extracted as a def so that the nested + // closure can be properly emitted inside it (and capture variables from + // this closure's scope). This is recursive - the containsNestedMultiStatement + // check will propagate up the entire closure tree. + if (containsNestedMultiStatementClosure(codeBlock)) return true + + return false + } + + ** Recursively check if a code block contains any nested multi-statement closures + private Bool containsNestedMultiStatementClosure(Block b) + { + return b.stmts.any |s| { stmtContainsNestedMultiStatementClosure(s) } + } + + ** Check if a statement contains a nested multi-statement closure + private Bool stmtContainsNestedMultiStatementClosure(Stmt s) + { + switch (s.id) + { + case StmtId.expr: + exprStmt := s as ExprStmt + return exprContainsNestedMultiStatementClosure(exprStmt.expr) + case StmtId.localDef: + localDef := s as LocalDefStmt + if (localDef.init != null) + return exprContainsNestedMultiStatementClosure(localDef.init) + return false + case StmtId.returnStmt: + ret := s as ReturnStmt + if (ret.expr != null) + return exprContainsNestedMultiStatementClosure(ret.expr) + return false + default: + return false + } + } + + ** Check if an expression contains or IS a nested multi-statement closure + private Bool exprContainsNestedMultiStatementClosure(Expr e) + { + // Check if this IS a multi-statement closure + if (e.id == ExprId.closure) + { + ce := e as ClosureExpr + // NOTE: Use a non-recursive check here to avoid infinite recursion. + // We only need to check if THIS closure is multi-statement (local vars, etc.) + // The recursive call from isMultiStatementClosure will handle deeper nesting. + if (closureNeedsExtractionDirect(ce)) return true + } + + // Check children + switch (e.id) + { + case ExprId.call: + ce := e as CallExpr + if (ce.target != null && exprContainsNestedMultiStatementClosure(ce.target)) return true + return ce.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) } + case ExprId.construction: + ce := e as CallExpr + return ce.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) } + case ExprId.shortcut: + se := e as ShortcutExpr + if (se.target != null && exprContainsNestedMultiStatementClosure(se.target)) return true + return se.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) } + case ExprId.coerce: + tc := e as TypeCheckExpr + return exprContainsNestedMultiStatementClosure(tc.target) + case ExprId.ternary: + te := e as TernaryExpr + return exprContainsNestedMultiStatementClosure(te.condition) || + exprContainsNestedMultiStatementClosure(te.trueExpr) || + exprContainsNestedMultiStatementClosure(te.falseExpr) + case ExprId.boolOr: + co := e as CondExpr + return co.operands.any |op| { exprContainsNestedMultiStatementClosure(op) } + case ExprId.boolAnd: + ca := e as CondExpr + return ca.operands.any |op| { exprContainsNestedMultiStatementClosure(op) } + case ExprId.closure: + // Already checked above, but need to check INSIDE for deeply nested + cl := e as ClosureExpr + Block? codeBlock := null + if (cl.doCall != null && cl.doCall.code != null) + codeBlock = cl.doCall.code + else if (cl.call != null && cl.call.code != null) + codeBlock = cl.call.code + else if (cl.code != null) + codeBlock = cl.code + if (codeBlock != null) + return containsNestedMultiStatementClosure(codeBlock) + return false + default: + return false + } + } + + ** Check if a closure needs extraction WITHOUT the recursive nested check. + ** This prevents infinite recursion when checking for nested multi-statement closures. + private Bool closureNeedsExtractionDirect(ClosureExpr ce) + { + Block? codeBlock := null + if (ce.doCall != null && ce.doCall.code != null) + codeBlock = ce.doCall.code + else if (ce.call != null && ce.call.code != null) + codeBlock = ce.call.code + else if (ce.code != null) + codeBlock = ce.code + + if (codeBlock == null) return false + + stmts := codeBlock.stmts + + // Check if closure has local variable declarations + if (stmts.any |s| { s.id == StmtId.localDef }) return true + + // Check if closure has assignments + hasAssign := stmts.any |s| + { + if (s.id == StmtId.expr) + { + es := s as ExprStmt + return isAssignmentExpr(es.expr) + } + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr != null) + return isAssignmentExpr(ret.expr) + } + return false + } + if (hasAssign) return true + + // Check for control flow + if (stmts.any |s| + { + s.id == StmtId.ifStmt || + s.id == StmtId.switchStmt || + s.id == StmtId.forStmt || + s.id == StmtId.whileStmt || + s.id == StmtId.tryStmt + }) return true + + // Count real statements + realStmtCount := 0 + stmts.each |s| + { + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr != null) realStmtCount++ + } + else if (s.id != StmtId.nop) + { + realStmtCount++ + } + } + + return realStmtCount > 1 + } + + ** Check if expression is an assignment (can't be in lambda body) + ** Handles coerce wrapping, shortcuts (compound assignments), etc. + private Bool isAssignmentExpr(Expr e) + { + // Unwrap coerce expressions + while (e.id == ExprId.coerce) + { + tc := e as TypeCheckExpr + e = tc.target + } + + // Direct assignment + if (e.id == ExprId.assign) return true + + // Index set (list[i] = x) or compound assignment (x += 5) + if (e.id == ExprId.shortcut) + { + se := e as ShortcutExpr + if (se.op == ShortcutOp.set) return true + // Compound assignment, but NOT increment/decrement (those return values) + if (se.isAssign && se.op != ShortcutOp.increment && se.op != ShortcutOp.decrement) + return true + } + + return false + } + +////////////////////////////////////////////////////////////////////////// +// Closure Emission +////////////////////////////////////////////////////////////////////////// + + ** Emit any pending closures that are first used in the current statement + private Void emitPendingClosuresForStatement() + { + if (m.pendingClosures.isEmpty) return + + // Find closures to emit for this statement + toEmit := [,] + remaining := [,] + + m.pendingClosures.each |item| + { + data := item as Obj[] + closureId := data[1] as Int + firstUse := m.closureFirstUse[closureId] + + // Emit if this is the first use statement, OR if usage wasn't tracked (fallback) + if (firstUse == m.stmtIndex || firstUse == null) + toEmit.add(item) + else + remaining.add(item) + } + + if (toEmit.isEmpty) return + + // Update pending list + m.pendingClosures = remaining + + // Emit closures + toEmit.each |item| + { + data := item as Obj[] + ce := data[0] as ClosureExpr + closureId := data[1] as Int + writeClosure(ce, closureId) + } + } + + ** Write a multi-statement closure as a def function + private Void writeClosure(ClosureExpr ce, Int closureId) + { + // def _closure_N(params, _self=self): + w("def _closure_${closureId}(") + + // Get the signature - this is the EXPECTED type (what the target method wants) + // which may have fewer params than declared in source code (Fantom allows coercion) + sig := ce.signature as FuncType + expectedParamCount := sig?.params?.size ?: 0 + + // Parameters from closure's doCall method - these have the actual names + // from the source code, but LIMIT to expected count from signature + // ALL params get =None default because Python (unlike JS) requires all args + // JS: f(a,b) called as f() gives a=undefined, b=undefined + // Python: f(a,b) called as f() raises TypeError + hasParams := false + if (ce.doCall?.params != null && !ce.doCall.params.isEmpty) + { + // Only output up to expectedParamCount params (or all if signature unavailable) + maxParams := expectedParamCount > 0 ? expectedParamCount : ce.doCall.params.size + actualCount := ce.doCall.params.size.min(maxParams) + + actualCount.times |i| + { + if (i > 0) w(", ") + w(escapeName(ce.doCall.params[i].name)).w("=None") + hasParams = true + } + } + else + { + // Fallback to signature names (for it-blocks with implicit it) + // sig was already defined above + if (sig != null && !sig.params.isEmpty) + { + // Check if this is an it-block (uses implicit it) + if (ce.isItBlock) + { + w("it=None") + hasParams = true + } + else + { + sig.names.each |name, i| + { + if (i > 0) w(", ") + if (name.isEmpty) + w("_p${i}=None") + else + w(escapeName(name)).w("=None") + hasParams = true + } + } + } + } + + // Add _self=self for outer self capture if needed + needsOuter := ce.cls?.fieldDefs?.any |f| { f.name == "\$this" } ?: false + if (needsOuter) + { + if (hasParams) w(", ") + w("_self=self") + } + + w(")").colon + indent + + // Multi-statement closures use _self=self, not _outer=self + // Ensure the flag is false so $this references output _self + m.inClosureWithOuter = false + + // Emit nonlocal declarations for closure-captured mutable variables + // Python requires nonlocal to assign to variables from enclosing scope + nonlocalNames := m.getNonlocalNames + if (!nonlocalNames.isEmpty) + { + w("nonlocal ") + nonlocalNames.each |name, i| + { + if (i > 0) w(", ") + w(escapeName(name)) + } + eos + } + + // Save method-level closure state - nested closures have their own scope + savedPending := m.pendingClosures.dup + savedFirstUse := m.closureFirstUse.dup + savedStmtIndex := m.stmtIndex + m.pendingClosures = [,] + m.closureFirstUse = [:] + + // Scan and register nested closures for emission inside this closure + // This ensures nested defs are written before they're referenced + scanClosureBodyForNestedClosures(ce) + + // Write the closure body + codeBlock := ce.doCall?.code ?: ce.code + hasContent := false + if (codeBlock != null && !codeBlock.stmts.isEmpty) + { + codeBlock.stmts.each |s, idx| + { + // Skip self-referential captured variable assignments (js$0 = js$0 -> js = js) + // Python captures variables automatically from the enclosing scope + if (isCapturedVarSelfAssign(s)) return + + // Track statement index for nested closure emission + m.stmtIndex = idx + + // Note: inClosureWithOuter stays false for multi-statement closures + // because they use _self=self parameter, not _outer=self + stmt(s) + hasContent = true + } + } + + // Restore method-level closure state + m.pendingClosures = savedPending + m.closureFirstUse = savedFirstUse + m.stmtIndex = savedStmtIndex + + if (!hasContent) + { + pass + } + + unindent + nl + + // Wrap the closure with Func.make_closure for proper Fantom Func methods + w("_closure_${closureId} = sys.Func.make_closure({") + + // Returns type + retType := sig?.returns?.signature ?: "sys::Void" + w("\"returns\": ").str(retType).w(", ") + + // Immutability case from compiler analysis + immutCase := m.closureImmutability(ce) + w("\"immutable\": ").str(immutCase).w(", ") + + // Params (sanitize Java FFI type signatures) + w("\"params\": [") + if (ce.doCall?.params != null) + { + maxParams := expectedParamCount > 0 ? expectedParamCount : ce.doCall.params.size + actualCount := ce.doCall.params.size.min(maxParams) + actualCount.times |i| + { + if (i > 0) w(", ") + p := ce.doCall.params[i] + pSig := PyUtil.sanitizeJavaFfi(p.type.signature) + w("{\"name\": ").str(p.name).w(", \"type\": ").str(pSig).w("}") + } + } + else if (sig != null && !sig.params.isEmpty) + { + sig.params.each |p, i| + { + if (i > 0) w(", ") + name := sig.names.getSafe(i) ?: "_p${i}" + pSig := PyUtil.sanitizeJavaFfi(p.signature) + w("{\"name\": ").str(name).w(", \"type\": ").str(pSig).w("}") + } + } + w("]}, _closure_${closureId})").eos + } + + ** Check if statement is a self-referential captured variable assignment + ** These are generated by Fantom compiler like: js$0 = js$0 + ** Python captures variables automatically so we skip these + private Bool isCapturedVarSelfAssign(Stmt s) + { + // Must be expression statement + if (s.id != StmtId.expr) return false + + exprStmt := s as ExprStmt + + // Must be assignment expression + if (exprStmt.expr.id != ExprId.assign) return false + + assign := exprStmt.expr as BinaryExpr + + // Both sides must be field expressions + if (assign.lhs.id != ExprId.field || assign.rhs.id != ExprId.field) return false + + lhsField := assign.lhs as FieldExpr + rhsField := assign.rhs as FieldExpr + + // Both must reference the same captured variable field (pattern: name$N) + lhsName := lhsField.field.name + rhsName := rhsField.field.name + + if (lhsName != rhsName) return false + + // Check if it's a captured variable pattern (name$N where N is digits) + if (!lhsName.contains("\$")) return false + + idx := lhsName.index("\$") + if (idx == null || idx >= lhsName.size - 1) return false + + suffix := lhsName[idx+1..-1] + return !suffix.isEmpty && suffix.all |c| { c.isDigit } + } + +////////////////////////////////////////////////////////////////////////// +// Block +////////////////////////////////////////////////////////////////////////// + + ** Print a block of statements + ** Handles "effectively empty" blocks (all nops or catch vars) by adding pass + Void block(Block? b, Bool isCatchBlock := false) + { + indent + + hasContent := false + if (b != null && !b.stmts.isEmpty) + { + b.stmts.each |s| + { + // Skip nops - they produce no output + if (s.id == StmtId.nop) return + + // In catch blocks, skip catch variable declarations (handled by except...as) + if (isCatchBlock && s.id == StmtId.localDef && (s as LocalDefStmt).isCatchVar) return + + stmt(s) + hasContent = true + } + } + + // Python requires content in blocks - add pass if effectively empty + if (!hasContent) + pass + + unindent + } + +////////////////////////////////////////////////////////////////////////// +// Statements +////////////////////////////////////////////////////////////////////////// + + private Void exprStmt(ExprStmt s) + { + // For statement-level local variable assignments, use = not := + // The walrus operator (:=) should only be used inside expressions + // (e.g., conditions, function arguments, etc.) + e := s.expr + + // Unwrap coerces to find the underlying assignment + while (e.id == ExprId.coerce) + { + tc := e as TypeCheckExpr + e = tc.target + } + + // Check if this is a local variable assignment at statement level + if (e.id == ExprId.assign) + { + assign := e as BinaryExpr + if (assign.lhs.id == ExprId.localVar) + { + // Statement-level local var assignment: use regular = not := + localExpr := assign.lhs as LocalVarExpr + w(escapeName(localExpr.var.name)) + w(" = ") + expr(assign.rhs) + eos + return + } + } + + // For all other expressions, use the normal expression printer + expr(s.expr) + eos + } + + private Void localDef(LocalDefStmt s) + { + // Skip catch vars - handled in tryStmt + if (s.isCatchVar) return + + // Skip captured variable self-assignments (js = js$0 -> js = js) + // Python captures variables automatically from enclosing scope + if (isCapturedVarLocalDef(s)) return + + // Check if this is a Wrap$ wrapper definition (closure-captured mutable variable) + // If so, skip the line entirely -- we use nonlocal instead of cvar wrappers + if (detectAndRecordNonlocal(s)) return + + w(escapeName(s.name)) + if (s.init != null) + { + w(" = ") + // If init is an assignment, only output the RHS + if (s.init.id == ExprId.assign) + { + assign := s.init as BinaryExpr + expr(assign.rhs) + } + else + { + expr(s.init) + } + } + else + { + w(" = None") + } + eos + } + + ** Detect if this is a Wrap$ wrapper definition and record for nonlocal handling + ** Pattern: wrapperVar := Wrap$Type.make(originalVar) + ** Returns true if this line should be skipped (it's a wrapper we handle via nonlocal) + private Bool detectAndRecordNonlocal(LocalDefStmt s) + { + if (s.init == null) return false + + // Unwrap coerces and assignments to get the actual call + initExpr := s.init + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + if (initExpr.id == ExprId.assign) + { + assign := initExpr as BinaryExpr + initExpr = assign.rhs + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + } + + // Check if it's a call expression (for Wrap$.make pattern) + if (initExpr.id != ExprId.call) return false + + call := initExpr as CallExpr + + // Pattern: Wrap$Type.make(arg) -- synthetic wrapper constructor + if (call.method.name == "make" && call.target == null && !call.method.isStatic && call.args.size == 1) + { + parentType := call.method.parent + if (parentType.isSynthetic && parentType.name.startsWith("Wrap\$")) + { + // Extract the argument (the value being wrapped) + arg := call.args.first + while (arg.id == ExprId.coerce) + { + tc := arg as TypeCheckExpr + arg = tc.target + } + + wrapperVarName := s.name + + if (arg.id == ExprId.localVar) + { + // Case 1: Wrap$.make(existingVar) - the original variable already exists + // Skip this line entirely; the original variable is already defined + localArg := arg as LocalVarExpr + originalVarName := localArg.var.name + m.recordNonlocal(wrapperVarName, originalVarName) + return true // Skip this line + } + else + { + // Case 2: Wrap$.make(literal/expr) - no separate original variable + // e.g., params = Wrap$Str.make(null), buf = Wrap$Buf.make(self.make(...)) + // Rewrite as: varName = and record for nonlocal + m.recordNonlocal(wrapperVarName, wrapperVarName) + w(escapeName(wrapperVarName)) + w(" = ") + expr(call.args.first) // Use original arg (with coerces) for proper output + eos + return true // We emitted the rewritten line + } + } + } + return false + } + + ** Check if this localDef is a captured variable initialization + ** Pattern: js := assign(field(js$0)) where js$0 is a captured variable field + private Bool isCapturedVarLocalDef(LocalDefStmt s) + { + if (s.init == null) return false + + // Unwrap coerce expressions to get to the actual content + initExpr := s.init + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + + // Check if init is assignment - get the RHS + if (initExpr.id == ExprId.assign) + { + assign := initExpr as BinaryExpr + initExpr = assign.rhs + // Unwrap coerce on RHS too + while (initExpr.id == ExprId.coerce) + { + tc := initExpr as TypeCheckExpr + initExpr = tc.target + } + } + + // Check if we have a field reference to a captured variable + if (initExpr.id != ExprId.field) return false + + fieldExpr := initExpr as FieldExpr + fieldName := fieldExpr.field.name + + // Check if field name matches pattern: varName$N + if (!fieldName.contains("\$")) return false + + idx := fieldName.index("\$") + if (idx == null || idx >= fieldName.size - 1) return false + + baseName := fieldName[0.. acc[name] = hit; return hit + // Handle return with compound assignment: return x += 5 -> x += 5; return x + if (unwrapped.id == ExprId.shortcut) + { + shortcut := unwrapped as ShortcutExpr + + // Index set (container[key] = value) + if (shortcut.op == ShortcutOp.set) + { + // Execute index set first + expr(s.expr) + eos + // Then return the assigned value (the second arg to set) + w("return ") + expr(shortcut.args[1]) + eos + return + } + + // Compound assignment (x += 5, x *= 2, etc.) + if (shortcut.isAssign) + { + // Execute assignment first + expr(s.expr) + eos + // Then return the target (the updated value) + w("return ") + expr(shortcut.target) + eos + return + } + } + } + + w("return") + if (s.expr != null) + { + w(" ") + expr(s.expr) + } + eos + } + + ** Unwrap coerce expressions + private Expr unwrapCoerce(Expr e) + { + if (e.id == ExprId.coerce) + { + tc := e as TypeCheckExpr + return unwrapCoerce(tc.target) + } + return e + } + + private Void throwStmt(ThrowStmt s) + { + w("raise ") + expr(s.exception) + eos + } + + private Void forStmt(ForStmt s) + { + // Fantom for loop: for (init; cond; update) + // Python equivalent: init; while cond: block; update + // + // IMPORTANT: We track the update expression so that continue statements + // inside the loop body can emit it before jumping. Otherwise continue + // would skip the update and cause an infinite loop. + if (s.init != null) stmt(s.init) + w("while ") + if (s.condition != null) + expr(s.condition) + else + w("True") + colon + indent + + // Set forLoopUpdate so continue statements know to emit it + savedUpdate := m.forLoopUpdate + m.forLoopUpdate = s.update + + if (s.block != null) + s.block.stmts.each |st| { stmt(st) } + + // Restore previous update (for nested for loops) + m.forLoopUpdate = savedUpdate + + if (s.update != null) + { + expr(s.update) + eos + } + unindent + } + + ** Handle continue statement - must emit for loop update expression first + private Void continueStmt() + { + // If we're in a for loop with an update expression, emit it before continue + // This prevents infinite loops where continue skips the i++ update + if (m.forLoopUpdate != null) + { + expr(m.forLoopUpdate) + eos + } + w("continue").eos + } + + private Void whileStmt(WhileStmt s) + { + w("while ") + expr(s.condition) + colon + block(s.block) + } + + private Void tryStmt(TryStmt s) + { + w("try") + colon + block(s.block) + + s.catches.each |c| + { + w("except") + if (c.errType != null) + { + w(" ") + // For sys::Err (base class), catch all Python exceptions + // This ensures Python native exceptions (KeyError, etc.) are also caught + if (c.errType.qname == "sys::Err") + { + w("Exception") + } + else + { + // For specific error types, use the Fantom type + // Also catch corresponding Python native exceptions where applicable + curPod := m.curType?.pod?.name + errPod := c.errType.pod.name + errName := PyUtil.escapeTypeName(c.errType.name) + + // Map Fantom exceptions to Python native exceptions + // These need to catch both Fantom and Python versions + pyNative := pythonNativeException(c.errType.qname) + if (pyNative != null) + { + // Catch both Fantom and Python exceptions: except (sys.IndexErr, IndexError) + w("(") + if (errPod == "sys" && curPod != "sys") + w("sys.") + w(errName) + w(", ") + w(pyNative) + w(")") + } + else + { + if (errPod == "sys" && curPod != "sys") + w("sys.") + w(errName) + } + } + } + else + { + w(" Exception") + } + if (c.errVariable != null) + { + w(" as ").w(escapeName(c.errVariable)) + } + colon + // Wrap native Python exceptions to ensure they have .trace() method + // This is needed because except Exception catches native exceptions + // that don't have Fantom Err methods + if (c.errVariable != null && (c.errType == null || c.errType.qname == "sys::Err")) + { + indent + w(escapeName(c.errVariable)).w(" = sys.Err.wrap(").w(escapeName(c.errVariable)).w(")").eos + unindent + } + block(c.block, true) // isCatchBlock=true for catch variable handling + } + + if (s.finallyBlock != null) + { + w("finally") + colon + block(s.finallyBlock) + } + } + + private Void switchStmt(SwitchStmt s) + { + // Python doesn't have switch, use if/elif/else + // IMPORTANT: Evaluate condition once to avoid side effects being repeated + // (e.g., switch(i++) must only increment i once) + switchVarId := m.nextSwitchVarId + w("_switch_${switchVarId} = ") + expr(s.condition) + eos + + first := true + s.cases.each |c| + { + if (first) + { + w("if ") + first = false + } + else + { + w("elif ") + } + + // Match any of the case values against the cached condition + c.cases.each |e, i| + { + if (i > 0) w(" or ") + w("(") + w("_switch_${switchVarId}") + w(" == ") + expr(e) + w(")") + } + colon + block(c.block) + } + + if (s.defaultBlock != null) + { + if (!first) w("else") + else w("if True") + colon + block(s.defaultBlock) + } + } + + ** Map Fantom exception types to corresponding Python native exceptions + ** Returns the Python exception name if there's a mapping, null otherwise + private Str? pythonNativeException(Str qname) + { + switch (qname) + { + case "sys::IndexErr": return "IndexError" + case "sys::ArgErr": return "ValueError" + case "sys::IOErr": return "IOError" + case "sys::UnknownKeyErr": return "KeyError" + default: return null + } + } +} diff --git a/src/fanc/fan/py/PyTypePrinter.fan b/src/fanc/fan/py/PyTypePrinter.fan new file mode 100644 index 000000000..2aab3969e --- /dev/null +++ b/src/fanc/fan/py/PyTypePrinter.fan @@ -0,0 +1,2339 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using compiler + +** +** PyTypePrinter generates Python class files from Fantom TypeDefs. +** +** Output Structure (per file): +** 1. Header comment (auto-generated notice) +** 2. Imports: +** - sys path setup +** - typing imports (Optional, Callable, etc.) +** - pod namespace imports (from fan import sys) +** - base class and mixin direct imports +** - exception types for catch clauses +** 3. Class definition: +** - Static fields (class-level storage + lazy getters) +** - Constructor (__init__ + factory methods like make()) +** - Field accessors (combined getter/setter pattern) +** - Methods (instance and static) +** - Python operator methods (__add__, __getitem__, etc.) +** 4. Type metadata registration (for reflection) +** +** Key Design Decisions: +** - Enums use factory pattern with _make_enum() and lazy vals() +** - Static fields use _static_init() with re-entry guard +** - Named constructors use _ctorName_body() pattern +** - See design.md in this directory for full documentation +** +class PyTypePrinter : PyPrinter +{ + new make(OutStream out) : super(out) {} + + ** Print a complete type definition + Void type(TypeDef t) + { + m.curType = t + + // File header + header(t) + + // Imports + imports(t) + + // Class definition + classDef(t) + + // Type metadata registration (for reflection) + typeMetadata(t) + + // Generate if __name__ == "__main__" block at module level for main() methods + // This MUST be after the class definition ends (unindented to module level) + mainMethod := t.methodDefs.find |method| { isMain(method) } + if (mainMethod != null) + pyMain(mainMethod) + + m.curType = null + } + +////////////////////////////////////////////////////////////////////////// +// Header +////////////////////////////////////////////////////////////////////////// + + private Void header(TypeDef t) + { + w("#").nl + w("# ${t.qname}").nl + w("# Auto-generated by fanc py - do not edit").nl + w("#").nl + // Import global sentinel from sys for field getter/setter disambiguation + w("from fan.sys.ObjUtil import _UNSET").nl + nl + } + +////////////////////////////////////////////////////////////////////////// +// Imports +////////////////////////////////////////////////////////////////////////// + + private Void imports(TypeDef t) + { + // System path setup + w("import sys as sys_module").nl + w("sys_module.path.insert(0, '.')").nl + nl + + // Type hint imports (Python 3.9+ uses native types, but we use typing for compatibility) + w("from typing import Optional, Callable, List as TypingList, Dict as TypingDict").nl + nl + + // For types NOT in sys pod: Import sys pod as namespace (lazy loader) + // For types IN sys pod: Import Type directly (needed for metadata registration) + if (t.pod.name != "sys") + { + w("from fan import sys").nl + } + else + { + // For sys pod types, import Type directly for metadata registration + if (t.qname != "sys::Type") + w("from fan.sys.Type import Type").nl + + // For sys pod enums, import List directly (used in vals() method) + if (t.isEnum) + w("from fan.sys.List import List").nl + } + + // Import base class directly (needed for Python class definition) + if (t.qname != "sys::Obj") + { + if (t.base != null && !t.base.isObj) + { + podPath := PyUtil.podImport(t.base.pod.name) + w("from ${podPath}.${t.base.name} import ${t.base.name}").nl + } + else + { + w("from fan.sys.Obj import Obj").nl + } + } + + // Import ObjUtil directly (used everywhere) + w("from fan.sys.ObjUtil import ObjUtil").nl + + // Import mixins directly (needed for Python class definition) + t.mixins.each |m| + { + if (m.qname == "sys::Obj") return // Skip Obj + podPath := PyUtil.podImport(m.pod.name) + w("from ${podPath}.${m.name} import ${m.name}").nl + } + + // Import dependent pods as namespaces + t.pod.depends.each |depend| + { + podName := depend.name + if (podName == "sys") return // Already imported above (or skipped for sys pod) + if (podName == t.pod.name) return // Skip same pod + if (podName.startsWith("[")) return // Skip FFI + if (PyUtil.isJavaFfi(podName)) return + + // Escape reserved pod names (e.g., "def" -> "def_") + escapedPod := PyUtil.escapePodName(podName) + w("from fan import ${escapedPod}").nl + } + + // Exception types used in catch clauses need explicit imports + // because Python's except clause needs the class in local scope + samePodCatchTypes := collectSamePodCatchTypes(t) + samePodCatchTypes.each |typeName| + { + if (typeName == t.name) return // Skip self-import + podPath := PyUtil.podImport(t.pod.name) + w("from ${podPath}.${typeName} import ${typeName}").nl + } + + // Cross-pod exception types used in catch clauses also need explicit imports + crossPodCatchTypes := collectCrossPodCatchTypes(t) + crossPodCatchTypes.each |typeNames, podName| + { + podPath := PyUtil.podImport(podName) + typeNames.each |typeName| + { + w("from ${podPath}.${typeName} import ${typeName}").nl + } + } + + nl + } + +////////////////////////////////////////////////////////////////////////// +// Import Scanning +////////////////////////////////////////////////////////////////////////// + + // These methods scan the AST to collect type references for imports. + // Python requires explicit imports (unlike JS which bundles everything). + // Key patterns handled: + // - Cross-pod exception types used in catch clauses + // - Same-pod exception types (Python except clause needs local scope) + + ** Collect cross-pod exception types used in catch clauses + ** Returns a map of podName -> list of type names + private Str:Str[] collectCrossPodCatchTypes(TypeDef t) + { + result := Str:Str[][:] + + // Scan all methods for catch clauses with cross-pod exception types + t.methodDefs.each |m| + { + if (m.code != null) + scanBlockForCrossPodCatchTypes(m.code, t.pod.name, result) + } + + return result + } + + ** Scan a block for cross-pod catch types + private Void scanBlockForCrossPodCatchTypes(Block block, Str curPodName, Str:Str[] result) + { + block.stmts.each |stmt| + { + if (stmt.id == StmtId.tryStmt) + { + tryStmt := stmt as TryStmt + tryStmt.catches.each |c| + { + if (c.errType != null) + { + errPod := c.errType.pod.name + errName := c.errType.name + // Cross-pod: not sys (already imported) and not same pod + if (errPod != "sys" && errPod != curPodName) + { + if (result[errPod] == null) result[errPod] = Str[,] + if (!result[errPod].contains(errName)) + result[errPod].add(errName) + } + } + } + // Recurse into try block + scanBlockForCrossPodCatchTypes(tryStmt.block, curPodName, result) + tryStmt.catches.each |c| { scanBlockForCrossPodCatchTypes(c.block, curPodName, result) } + if (tryStmt.finallyBlock != null) + scanBlockForCrossPodCatchTypes(tryStmt.finallyBlock, curPodName, result) + } + else if (stmt.id == StmtId.ifStmt) + { + ifStmt := stmt as IfStmt + scanBlockForCrossPodCatchTypes(ifStmt.trueBlock, curPodName, result) + if (ifStmt.falseBlock != null) scanBlockForCrossPodCatchTypes(ifStmt.falseBlock, curPodName, result) + } + else if (stmt.id == StmtId.forStmt) + { + forStmt := stmt as ForStmt + if (forStmt.block != null) scanBlockForCrossPodCatchTypes(forStmt.block, curPodName, result) + } + else if (stmt.id == StmtId.whileStmt) + { + whileStmt := stmt as WhileStmt + scanBlockForCrossPodCatchTypes(whileStmt.block, curPodName, result) + } + else if (stmt.id == StmtId.switchStmt) + { + switchStmt := stmt as SwitchStmt + switchStmt.cases.each |c| { scanBlockForCrossPodCatchTypes(c.block, curPodName, result) } + if (switchStmt.defaultBlock != null) scanBlockForCrossPodCatchTypes(switchStmt.defaultBlock, curPodName, result) + } + } + } + + ** Collect same-pod exception types used in catch clauses + ** These need explicit imports because they're used at statement level + private Str[] collectSamePodCatchTypes(TypeDef t) + { + result := Str[,] + + // Scan all methods for catch clauses + t.methodDefs.each |m| + { + if (m.code != null) + scanBlockForCatchTypes(m.code, t.pod.name, result) + } + + return result + } + + ** Scan a block for same-pod catch types + private Void scanBlockForCatchTypes(Block block, Str podName, Str[] result) + { + block.stmts.each |stmt| + { + if (stmt.id == StmtId.tryStmt) + { + tryStmt := stmt as TryStmt + tryStmt.catches.each |c| + { + if (c.errType != null && c.errType.pod.name == podName) + { + typeName := c.errType.name + if (!result.contains(typeName)) + result.add(typeName) + } + } + // Recurse into try block + scanBlockForCatchTypes(tryStmt.block, podName, result) + tryStmt.catches.each |c| { scanBlockForCatchTypes(c.block, podName, result) } + if (tryStmt.finallyBlock != null) + scanBlockForCatchTypes(tryStmt.finallyBlock, podName, result) + } + else if (stmt.id == StmtId.ifStmt) + { + ifStmt := stmt as IfStmt + scanBlockForCatchTypes(ifStmt.trueBlock, podName, result) + if (ifStmt.falseBlock != null) scanBlockForCatchTypes(ifStmt.falseBlock, podName, result) + } + else if (stmt.id == StmtId.forStmt) + { + forStmt := stmt as ForStmt + if (forStmt.block != null) scanBlockForCatchTypes(forStmt.block, podName, result) + } + else if (stmt.id == StmtId.whileStmt) + { + whileStmt := stmt as WhileStmt + scanBlockForCatchTypes(whileStmt.block, podName, result) + } + else if (stmt.id == StmtId.switchStmt) + { + switchStmt := stmt as SwitchStmt + switchStmt.cases.each |c| { scanBlockForCatchTypes(c.block, podName, result) } + if (switchStmt.defaultBlock != null) scanBlockForCatchTypes(switchStmt.defaultBlock, podName, result) + } + } + } + +////////////////////////////////////////////////////////////////////////// +// Class +////////////////////////////////////////////////////////////////////////// + + private Void classDef(TypeDef t) + { + // class ClassName(Mixin1, Mixin2, BaseClass): + // Python supports multiple inheritance - include all mixins plus base class + w("class ${PyUtil.escapeTypeName(t.name)}") + w("(") + + bases := Str[,] + + // Collect all mixins that aren't inherited by other mixins + // This prevents Python MRO conflicts like class X(Dict, LibDepend) + // where LibDepend already extends Dict + mixinsToInclude := CType[,] + t.mixins.each |m| + { + if (m.qname == "sys::Obj") return // Skip Obj + + // Check if this mixin is inherited by another mixin in the list + alreadyInherited := t.mixins.any |other| + { + if (other.qname == m.qname) return false // Skip self + // Check if 'other' mixin extends 'm' + return other.mixins.any |mm| { mm.qname == m.qname } + } + + if (!alreadyInherited) + mixinsToInclude.add(m) + } + + // Add filtered mixins + mixinsToInclude.each |m| + { + bases.add(m.name) + } + + // Add base class if it's not Obj and not already inherited via mixins + baseAlreadyInherited := t.base != null && !t.base.isObj && + mixinsToInclude.any |m| { + if (m.base?.qname == t.base.qname) return true + return m.mixins.any |mm| { mm.qname == t.base.qname } + } + + if (t.base != null && !t.base.isObj && !baseAlreadyInherited) + bases.add(t.base.name) + else if (bases.isEmpty) + bases.add("Obj") + + // Write the bases + w(bases.join(", ")) + w(")") + colon + + indent + + // Check if this is an enum type + if (t.isEnum) + { + enumClassDef(t) + } + else + { + // Static fields (class-level attributes) - must come first + staticFields(t) + + // Constructor + ctor(t) + + // Field getters/setters (instance fields only) + t.fieldDefs.each |f| + { + if (f.isStatic) return + fieldAccessors(f) + } + + // Methods - track which field accessors we've already emitted + emittedAccessors := Str:Bool[:] + t.methodDefs.each |m| + { + if (m.isCtor) return + if (m.isInstanceInit) return + if (m.isStaticInit) return + // Allow once helper methods (synthetic but needed) - they end with $Once + // Allow checkFields$ methods - they validate non-nullable fields were set + if (m.isSynthetic && !m.name.endsWith("\$Once") && !m.name.startsWith("checkFields\$")) return + // Skip abstract methods in mixins - they must be implemented by concrete class + // Python MRO would find the mixin's pass-returning method before the base class implementation + if (m.isAbstract && t.isMixin) return + + // For field accessors, emit combined getter/setter only once + if (m.isFieldAccessor) + { + fieldName := m.accessorFor.name + if (emittedAccessors[fieldName] == true) return // Already emitted + emittedAccessors[fieldName] = true + combinedFieldAccessor(m.accessorFor) + return + } + + method(m) + } + + // Generate Python operator methods for Fantom operator methods + operatorMethods(t) + + // If no methods, add pass + if (t.methodDefs.isEmpty) + pass + } + + unindent + nl + } + + ** Generate Python operator methods (__add__, __sub__, etc.) + ** for Fantom methods with @Operator facet + private Void operatorMethods(TypeDef t) + { + // Map Fantom operator methods to Python special methods + operatorMap := Str:Str[ + "plus": "__add__", + "minus": "__sub__", + "mult": "__mul__", + "div": "__truediv__", + "mod": "__mod__", + "negate": "__neg__", + "get": "__getitem__", + "set": "__setitem__" + ] + + t.methodDefs.each |m| + { + // Skip methods without @Operator facet + hasOperator := m.facets?.any |f| { f.type.qname == "sys::Operator" } ?: false + if (!hasOperator) return + + pyMethod := operatorMap[m.name] + if (pyMethod == null) return + + // Generate Python operator method + // Handle different parameter counts: + // - 0 params: __neg__ (negate) + // - 1 param: __add__, __sub__, __getitem__, etc. + // - 2 params: __setitem__ (set) + nl + w("def ${pyMethod}(self") + if (m.params.size == 1) + { + w(", other") + } + else if (m.params.size == 2) + { + // For __setitem__, Python uses (key, value) + w(", key, value") + } + w(")").colon + indent + if (m.params.size == 0) + { + w("return self.${escapeName(m.name)}()").eos + } + else if (m.params.size == 1) + { + w("return self.${escapeName(m.name)}(other)").eos + } + else if (m.params.size == 2) + { + w("return self.${escapeName(m.name)}(key, value)").eos + } + unindent + } + } + +////////////////////////////////////////////////////////////////////////// +// Enum +////////////////////////////////////////////////////////////////////////// + + ** Generate enum class definition + private Void enumClassDef(TypeDef t) + { + enumFields := t.fieldDefs.findAll |f| { f.enumDef != null } + .sort |a, b| { a.enumDef.ordinal <=> b.enumDef.ordinal } + + // For enums, only generate static fields for non-enum fields (like once methods) + // Skip the standard _static_init() for enums since vals()/_make_enum() handles enum initialization + enumStaticFields(t) + + // Private cache field for vals + nl + w("_vals = None").nl + + // Static accessor for each enum value: A(), B(), C() + enumFields.each |f| + { + def := f.enumDef + nl + w("@staticmethod").nl + w("def ${escapeName(def.name)}()").colon + indent + w("return ${PyUtil.escapeTypeName(t.name)}.vals().get(${def.ordinal})").eos + unindent + } + + // vals() method - creates and caches enum instances + nl + w("@staticmethod").nl + w("def vals()").colon + indent + w("if ${PyUtil.escapeTypeName(t.name)}._vals is None").colon + indent + // Add sys. prefix only when NOT inside the sys pod + prefix := t.pod.name != "sys" ? "sys." : "" + w("${PyUtil.escapeTypeName(t.name)}._vals = ${prefix}List.to_immutable(${prefix}List.from_list([").nl + indent + enumFields.each |f, i| + { + def := f.enumDef + w("${PyUtil.escapeTypeName(t.name)}._make_enum(${def.ordinal}, ${def.name.toCode}") + if (!def.ctorArgs.isEmpty) + { + def.ctorArgs.each |arg| + { + w(", ") + PyExprPrinter(this).expr(arg) + } + } + w(")") + if (i < enumFields.size - 1) w(",") + nl + } + unindent + w("]))").eos + unindent + w("return ${PyUtil.escapeTypeName(t.name)}._vals").eos + unindent + + // from_str() method + nl + w("@staticmethod").nl + w("def from_str(name, checked=True)").colon + indent + w("for v in ${PyUtil.escapeTypeName(t.name)}.vals()").colon + indent + w("if v.name() == name").colon + indent + w("return v").eos + unindent + unindent + w("if checked").colon + indent + // Add sys. prefix only when NOT inside the sys pod + parseErrPrefix := t.pod.name != "sys" ? "sys." : "" + w("raise ${parseErrPrefix}ParseErr.make(\"Unknown enum: \" + name)").eos + unindent + w("return None").eos + unindent + + // _make_enum() internal factory + nl + w("@staticmethod").nl + w("def _make_enum(_ordinal, _name") + // Add ctor params if enum has custom constructor (skip first 2: ordinal, name) + ctorMethod := t.methodDefs.find |m| { m.isCtor } + if (ctorMethod != null && ctorMethod.params.size > 2) + { + ctorMethod.params[2..-1].each |p| + { + w(", ") + w(escapeName(p.name)) + // Add default value if param has one + if (p.hasDefault) + { + w("=") + PyExprPrinter(this).expr(p->def) + } + } + } + w(")").colon + indent + w("inst = object.__new__(${PyUtil.escapeTypeName(t.name)})").eos + w("inst._ordinal = _ordinal").eos + w("inst._name = _name").eos + // Initialize custom fields from ctor params (skip first 2: ordinal, name) + if (ctorMethod != null && ctorMethod.params.size > 2) + { + ctorMethod.params[2..-1].each |p| + { + w("inst._${escapeName(p.name)} = ${escapeName(p.name)}").eos + } + } + // Run constructor body to compute derived fields + if (ctorMethod != null && ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty) + { + // Need to bind 'self' for the constructor body + w("self = inst").eos + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(ctorMethod.code) + ctorMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return statements + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null || ret.isSynthetic) return + } + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + w("return inst").eos + unindent + + // ordinal() method + nl + w("def ordinal(self)").colon + indent + w("return self._ordinal").eos + unindent + + // name() method + nl + w("def name(self)").colon + indent + w("return self._name").eos + unindent + + // to_str() returns the name + nl + w("def to_str(self)").colon + indent + w("return self._name").eos + unindent + + // __str__ for Python + nl + w("def __str__(self)").colon + indent + w("return self._name").eos + unindent + + // equals - compare by ordinal for enum equality + // NOTE: Using ordinal comparison because enum values may be stored/retrieved + // from different contexts (e.g., LogRec._level vs LogLevel.debug()) + nl + w("def equals(self, other)").colon + indent + w("if not isinstance(other, ${PyUtil.escapeTypeName(t.name)})").colon + indent + w("return False").eos + unindent + w("return self._ordinal == other._ordinal").eos + unindent + + // compare by ordinal + nl + w("def compare(self, other)").colon + indent + w("return self._ordinal - other._ordinal").eos + unindent + + // Python comparison operators + nl + w("def __lt__(self, other)").colon + indent + w("return self._ordinal < other._ordinal").eos + unindent + + nl + w("def __le__(self, other)").colon + indent + w("return self._ordinal <= other._ordinal").eos + unindent + + nl + w("def __gt__(self, other)").colon + indent + w("return self._ordinal > other._ordinal").eos + unindent + + nl + w("def __ge__(self, other)").colon + indent + w("return self._ordinal >= other._ordinal").eos + unindent + + nl + w("def __eq__(self, other)").colon + indent + w("if not isinstance(other, ${PyUtil.escapeTypeName(t.name)})").colon + indent + w("return False").eos + unindent + w("return self._ordinal == other._ordinal").eos + unindent + + nl + w("def __hash__(self)").colon + indent + w("return hash(self._ordinal)").eos + unindent + + // Field getters for custom fields (skip enum values and vals field) + t.fieldDefs.each |f| + { + if (f.enumDef != null) return // Skip enum value fields (A, B, C) + if (f.name == "vals") return // Skip vals - we generate our own static vals() + if (f.isStatic) return // Skip other static fields + fieldAccessors(f) + } + + // Additional methods (like negOrdinal in EnumAbc) + // Also allows $Once helper methods for once methods on enums + t.methodDefs.each |m| + { + if (m.isCtor) return + if (m.isInstanceInit) return + if (m.isStaticInit) return + // Allow once helper methods (synthetic but needed) - they end with $Once + // Allow checkFields$ methods - they validate non-nullable fields were set + if (m.isSynthetic && !m.name.endsWith("\$Once") && !m.name.startsWith("checkFields\$")) return + if (m.name == "fromStr") return // Already generated + + method(m) + } + } + +////////////////////////////////////////////////////////////////////////// +// Constructor +////////////////////////////////////////////////////////////////////////// + + ** Generate Python constructor code from Fantom constructors. + ** + ** Python Pattern: + ** - Static factory methods: make(), makeFrom(), etc. (all ctors) + ** - Single __init__(): uses primary ctor's signature + ** - Named ctor bodies: _makeFoo_body() for non-primary ctors + ** - Field init helper: _ctor_init() for named ctors to share field setup + ** + ** Fantom -> Python mapping: + ** - `new make(a, b)` -> `make(a, b)` factory + `__init__(self, a, b)` + ** - `new makeFoo(x)` -> `makeFoo(x)` factory + `_makeFoo_body(self, x)` + ** - `static new make()` -> `make()` factory only (no __init__ body) + ** - Constructor chaining (: super, : this) handled in body methods + ** + private Void ctor(TypeDef t) + { + // Find ALL constructors + ctorMethods := t.methodDefs.findAll |m| { m.isCtor } + + // Separate static ctors (factories) from instance ctors + // Static ctor: `static new make(...)` - just a factory, body should NOT go in __init__ + // Instance ctor: `new make(...)` - body goes in __init__ + instanceCtors := ctorMethods.findAll |m| { !m.isStatic } + staticCtors := ctorMethods.findAll |m| { m.isStatic } + + // If no constructors, use a simple default + if (ctorMethods.isEmpty) + { + // Generate static make() factory method with no params + nl + w("@staticmethod").nl + w("def make()").colon + indent + w("return ${PyUtil.escapeTypeName(t.name)}()").eos + unindent + + // Generate __init__ constructor + // For mixins, use *args, **kwargs to support cooperative multiple inheritance + nl + if (t.isMixin) + { + w("def __init__(self, *args, **kwargs)").colon + indent + w("super().__init__(*args, **kwargs)").eos + } + else + { + w("def __init__(self)").colon + indent + w("super().__init__()").eos + } + // Instance field initialization + t.fieldDefs.each |f| + { + if (f.isStatic) return + fieldInit(f) + } + unindent + nl + return + } + + // Find the primary constructor for __init__: + // - Prefer instance ctor named "make", else first instance ctor + // - If no instance ctors, use first static ctor but __init__ will have minimal body + primaryCtor := instanceCtors.find |m| { m.name == "make" } ?: instanceCtors.first + hasInstanceCtor := primaryCtor != null + if (primaryCtor == null) + primaryCtor = staticCtors.find |m| { m.name == "make" } ?: staticCtors.first + + // Generate static factory methods for ALL constructors + ctorMethods.each |ctorMethod| + { + ctorName := escapeName(ctorMethod.name) // "make", "make_def", etc. (snake_case) + isStaticFactory := ctorMethod.isStatic // static new make(...) vs new make(...) + + nl + w("@staticmethod").nl + w("def ${ctorName}(") + ctorMethod.params.each |p, i| + { + if (i > 0) w(", ") + w(escapeName(p.name)) + if (p.hasDefault) + { + w("=None") + } + } + w(")") + colon + indent + + // For static factories: run the factory body code (they call other ctors internally) + // For instance ctors: create instance and run body + if (isStaticFactory) + { + // Static factory - run the body code which creates/returns instance + // The body will call other ctors like makeImpl or makeSegs + emitDefaultParamChecks(ctorMethod) + if (ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty) + { + // Mark static context + this.m.inStaticContext = true + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(ctorMethod.code) + ctorMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + stmtPrinter.stmt(s) + } + this.m.clearClosures() + this.m.inStaticContext = false + } + else + { + pass + } + } + else if (ctorName != "make") + { + // Named instance constructor (not "make") - create instance and run ctor body + w("inst = object.__new__(${PyUtil.escapeTypeName(t.name)})").eos + + // Check if this ctor chains to this.make() - if so, skip _ctor_init + // because __init__() will handle field initialization + chainsToThisMake := ctorMethod.ctorChain != null && + (ctorMethod.ctorChain.target == null || ctorMethod.ctorChain.target.id == ExprId.thisExpr) && + ctorMethod.ctorChain.method.name == "make" + + if (!chainsToThisMake) + { + w("inst._ctor_init()").eos // Initialize fields only if not chaining to make() + } + + // Call the instance body method with params + w("inst._${ctorName}_body(") + ctorMethod.params.each |p, i| + { + if (i > 0) w(", ") + w(escapeName(p.name)) + } + w(")").eos + w("return inst").eos + } + else + { + // Primary instance make() just delegates to __init__ + w("return ${PyUtil.escapeTypeName(t.name)}(") + ctorMethod.params.each |p, i| + { + if (i > 0) w(", ") + w(escapeName(p.name)) + } + w(")").eos + } + unindent + } + + // Generate instance body methods for named constructors + ctorMethods.each |ctorMethod| + { + if (ctorMethod.name == "make") return // Primary ctor uses __init__ + + nl + w("def _${escapeName(ctorMethod.name)}_body(self") + ctorMethod.params.each |p| + { + w(", ") + w(escapeName(p.name)) + if (p.hasDefault) + { + w("=None") + } + } + w(")").colon + indent + + // Emit default parameter value checks at start of body method + // This ensures parameters like `unit := hr` get their defaults applied + emitDefaultParamChecks(ctorMethod) + + emittedStmts := false + + // Handle constructor chaining (: this.make(...) or : super(...)) + if (ctorMethod.ctorChain != null) + { + chain := ctorMethod.ctorChain + // Check if chaining to this or super + isThisChain := chain.target == null || chain.target.id == ExprId.thisExpr + isSuperChain := chain.target != null && chain.target.id == ExprId.superExpr + + if (isThisChain) + { + // this.make(...) - call another constructor on this type + chainCtorName := chain.method.name + if (chainCtorName == "make") + { + // Call __init__ directly to chain to primary constructor + w("self.__init__(") + chain.args.each |arg, i| + { + if (i > 0) w(", ") + PyExprPrinter(this).expr(arg) + } + w(")").eos + } + else + { + // Chain to another named constructor's body + w("self._${escapeName(chainCtorName)}_body(") + chain.args.each |arg, i| + { + if (i > 0) w(", ") + PyExprPrinter(this).expr(arg) + } + w(")").eos + } + } + else if (isSuperChain) + { + // super(...) - call parent constructor with chain arguments + // This re-calls parent's __init__ with actual arguments to set up parent fields + chainCtorName := chain.method.name + if (chainCtorName == "make") + { + // Call parent's __init__ with arguments + w("super().__init__(") + chain.args.each |arg, i| + { + if (i > 0) w(", ") + PyExprPrinter(this).expr(arg) + } + w(")").eos + } + else + { + // Call parent's named constructor body + w("super()._${escapeName(chainCtorName)}_body(") + chain.args.each |arg, i| + { + if (i > 0) w(", ") + PyExprPrinter(this).expr(arg) + } + w(")").eos + } + } + emittedStmts = true + } + + if (ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(ctorMethod.code) + ctorMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return statements + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null || ret.isSynthetic) return + } + // Skip synthetic instance init calls and enterCtor/exitCtor (field-not-set checks) + if (s.id == StmtId.expr) + { + exprStmt := s as ExprStmt + if (exprStmt.expr.id == ExprId.call) + { + call := exprStmt.expr as CallExpr + if (call.method.name.contains("\$init\$")) return + // Skip Fantom's enterCtor/exitCtor field-not-set checking (not needed in Python) + if (call.method.name == "enterCtor" || call.method.name == "exitCtor") return + } + } + stmtPrinter.stmt(s) + emittedStmts = true + } + this.m.clearClosures() + } + if (!emittedStmts) + { + pass + } + unindent + } + + // Generate _ctor_init helper for field initialization (used by named ctors) + // This method ONLY initializes THIS class's fields - it does NOT call parent. + // Named constructor body methods (like _makeResolved_body) are responsible + // for calling the appropriate parent constructor with proper arguments. + if (ctorMethods.size > 1 || (ctorMethods.size == 1 && ctorMethods.first.name != "make")) + { + nl + w("def _ctor_init(self)").colon + indent + + // Check if parent will have _ctor_init (multiple ctors or named ctors) + parentHasMultipleCtors := t.base != null && !t.base.isObj && t.base.ctors.size > 1 + parentHasNamedCtors := t.base != null && !t.base.isObj && + t.base.ctors.any |ctor| { ctor.name != "make" } + parentWillHaveCtorInit := parentHasMultipleCtors || parentHasNamedCtors + + hasFieldsToInit := t.fieldDefs.any |f| { !f.isStatic } + hasInstanceInit := t.methodDefs.any |m| { m.isInstanceInit && m.code != null } + + if (parentWillHaveCtorInit) + { + // Parent has _ctor_init, so we can call it to initialize parent fields + w("super()._ctor_init()").eos + } + else if (!hasFieldsToInit && !hasInstanceInit) + { + // No parent call, no fields, no instance init - need pass + pass + } + // Otherwise, DO NOT call parent - the named constructor body will handle + // the parent chain with proper arguments + + // Instance field initialization (THIS class's fields only) + t.fieldDefs.each |f| + { + if (f.isStatic) return + fieldInit(f) + } + // Instance init block + instanceInitMethod := t.methodDefs.find |m| { m.isInstanceInit } + if (instanceInitMethod != null && instanceInitMethod.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(instanceInitMethod.code) + instanceInitMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + unindent + } + + // Generate __init__ constructor (uses primary ctor's signature) + // For mixins, use *args, **kwargs to support cooperative multiple inheritance + nl + if (t.isMixin) + { + w("def __init__(self, *args, **kwargs)") + } + else + { + w("def __init__(self") + + // Only emit params if we have an actual instance constructor + // Static factories (static new) don't define instance constructor params + if (hasInstanceCtor) + { + // In Python, once we emit a default parameter, ALL following params must have defaults + // Find the first index where we should start emitting defaults: + // - explicit hasDefault, OR + // - nullable type AND all following params also have defaults or are nullable + firstDefaultIdx := primaryCtor.params.size + for (i := primaryCtor.params.size - 1; i >= 0; i--) + { + p := primaryCtor.params[i] + if (p.hasDefault || p.type.isNullable) + firstDefaultIdx = i + else + break // Found a required param, stop + } + + primaryCtor.params.each |p, i| + { + w(", ") + w(escapeName(p.name)) + // Only add =None if at or after firstDefaultIdx + if (i >= firstDefaultIdx) + { + w("=None") + } + } + } + w(")") + } + colon + + indent + + // Emit default parameter value checks at start of constructor body + // Only do this if we have an actual instance constructor with params + if (hasInstanceCtor) + emitDefaultParamChecks(primaryCtor) + + // Call super().__init__() with constructor chain arguments + // For mixins, pass through *args, **kwargs to support cooperative multiple inheritance + if (t.isMixin) + { + w("super().__init__(*args, **kwargs)").eos + } + else + { + w("super().__init__(") + // Only pass chain args to super().__init__() if chain target is super (not this) + // For this.makeFields(...) chains, the args are for the sibling constructor, not parent + if (hasInstanceCtor && primaryCtor.ctorChain != null) + { + chain := primaryCtor.ctorChain + isSuperChain := chain.target != null && chain.target.id == ExprId.superExpr + if (isSuperChain) + { + chain.args.each |arg, i| + { + if (i > 0) w(", ") + PyExprPrinter(this).expr(arg) + } + } + } + w(")").eos + } + + // Instance field initialization + t.fieldDefs.each |f| + { + if (f.isStatic) return + fieldInit(f) + } + + // Instance init block + instanceInitMethod := t.methodDefs.find |m| { m.isInstanceInit } + if (instanceInitMethod != null && instanceInitMethod.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(instanceInitMethod.code) + instanceInitMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return statements (same filtering as primary ctor body) + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null || ret.isSynthetic) return + } + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + + // Primary constructor body + // ONLY emit body if primaryCtor is an instance ctor (not a static factory) + // Static factories (static new make(...)) should NOT have their body in __init__ + // because they return newly constructed instances, not modify `self` + if (hasInstanceCtor && primaryCtor.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(primaryCtor.code) + + primaryCtor.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return statements + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null || ret.isSynthetic) return + // Skip return statements that return construction calls (factory pattern) + // These are static factory returns, not instance initialization + if (ret.expr.id == ExprId.call || ret.expr.id == ExprId.construction) return + } + // Skip synthetic instance init calls and enterCtor/exitCtor (field-not-set checks) + if (s.id == StmtId.expr) + { + exprStmt := s as ExprStmt + if (exprStmt.expr.id == ExprId.call) + { + call := exprStmt.expr as CallExpr + if (call.method.name.contains("\$init\$")) return + // Skip Fantom's enterCtor/exitCtor field-not-set checking (not needed in Python) + if (call.method.name == "enterCtor" || call.method.name == "exitCtor") return + } + } + stmtPrinter.stmt(s) + } + } + + // Clear closure state + this.m.clearClosures() + + unindent + nl + } + + ** Write field storage initialization with type default value. + ** Matches ES compiler pattern: field storage gets type defaults (false, 0, null), + ** actual initialization happens in instance$init which has properly resolved expressions. + private Void fieldInit(FieldDef f) + { + w("self._${escapeName(f.name)} = ") + w(defaultVal(f.type)) + eos + } + + private Void fieldAccessors(FieldDef f) + { + // Skip if field has custom get/set accessors + // Custom accessors are generated via combinedFieldAccessor() call below + if (f.hasGet || f.hasSet) return + + name := escapeName(f.name) + // Return type reflects Fantom's actual type: non-nullable Str -> 'str', + // nullable Str? -> 'Optional[str]'. Setter returns the value so both + // code paths have a matching return type ("set is always just a get too"). + retHint := pyTypeHint(f.type) + + // Generate combined getter/setter: def fieldName(self, _val_=_UNSET) -> T: + // This is required because PyExprPrinter.assign() uses method call syntax + // for field assignments on transpiled types: target.fieldName(value) + nl + w("def ${name}(self, _val_=_UNSET) -> ${retHint}").colon + indent + w("if _val_ is _UNSET").colon + indent + w("return self._${name}").eos + unindent + w("else").colon + indent + w("self._${name} = _val_").eos + w("return _val_").eos + unindent + unindent + } + + ** Default value for uninitialized fields. + ** In Fantom: + ** - Nullable types (?) always default to null + ** - Non-nullable primitives (Int, Float, Bool) have type defaults (0, 0.0, false) + ** - Non-nullable reference types (Str, List, etc.) start as null, must be initialized + private Str defaultVal(CType t) + { + // Nullable types always default to None + if (t.isNullable) return "None" + + sig := t.signature + // Non-nullable primitives have type-specific defaults + if (sig == "sys::Bool") return "False" + if (sig == "sys::Int") return "0" + if (sig == "sys::Float") return "0.0" + + // Non-nullable reference types (Str, List, etc.) start as None + // They must be initialized by constructor - Fantom's type system enforces this + return "None" + } + + ** Type-specific default for static field getter fallback + ** Used only when static field getter finds None after _static_init() + private Str typeDefaultVal(CType t) + { + if (t.isNullable) return "None" + sig := t.signature + if (sig == "sys::Bool") return "False" + if (sig == "sys::Int") return "0" + if (sig == "sys::Float") return "0.0" + if (sig == "sys::Str") return "\"\"" + return "None" + } + +////////////////////////////////////////////////////////////////////////// +// Static Fields +////////////////////////////////////////////////////////////////////////// + + ** Generate static fields for enum types + ** Handles once storage fields, regular computed static fields, but skips vals and enum values + ** since those are handled by the enum-specific code generation + private Void enumStaticFields(TypeDef t) + { + // Collect once storage field names + onceStorageFieldNames := Str:Bool[:] + t.methodDefs.each |m| + { + if (m.isOnce) + onceStorageFieldNames["${m.name}\$Store"] = true + } + + // Find static fields for enums (once storage fields AND regular computed static fields) + staticFieldDefs := t.fieldDefs.findAll |f| + { + if (!f.isStatic) return false + if (f.enumDef != null) return false // Skip enum value fields + if (f.name == "vals") return false // Skip vals - handled by enum code + // Include once storage fields + if (f.isOnce) return true + if (onceStorageFieldNames.containsKey(f.name)) return true + // Also include regular static fields (like 'keywords' map) + if (!f.isSynthetic) return true + return false + } + if (staticFieldDefs.isEmpty) return + + // Check if there's a staticInit method + staticInitMethod := t.methodDefs.find |m| { m.isStaticInit } + + // Generate class-level storage for each static field + nl + staticFieldDefs.each |f| + { + isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name) + if (isOnceStorage) + w("_${escapeName(f.name)} = \"_once_\"").nl + else + w("_${escapeName(f.name)} = None").nl + } + + // Generate static getter for each static field + staticFieldDefs.each |f| + { + isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name) + staticFieldGetter(t, f, staticInitMethod != null && !isOnceStorage, isOnceStorage) + } + + // Generate _static_init() method if there's a staticInit block or fields need init + if (staticInitMethod != null || staticFieldDefs.any |f| { f.init != null && !f.isOnce && !onceStorageFieldNames.containsKey(f.name) }) + { + enumStaticInit(t, staticInitMethod, staticFieldDefs, onceStorageFieldNames) + } + } + + ** Generate _static_init() for enum types - handles computed static fields + private Void enumStaticInit(TypeDef t, MethodDef? staticInitMethod, FieldDef[] staticFieldDefs, Str:Bool onceStorageFieldNames) + { + typeName := PyUtil.escapeTypeName(t.name) + + nl + w("@staticmethod").nl + w("def _static_init()").colon + indent + + // Add re-entry guard + w("if hasattr(${typeName}, '_static_init_in_progress') and ${typeName}._static_init_in_progress").colon + indent + w("return").eos + unindent + w("${typeName}._static_init_in_progress = True").eos + + m.inStaticContext = true + + // Collect enum field names for filtering + enumFieldNames := Str:Bool[:] + t.fieldDefs.each |f| + { + if (f.enumDef != null) + enumFieldNames[f.name] = true + } + + if (staticInitMethod != null && staticInitMethod.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(staticInitMethod.code) + staticInitMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null) return + } + // Skip enum value field assignments + if (isEnumFieldAssignment(s, enumFieldNames)) + return + // Skip once field assignments (they use sentinel pattern) + if (isOnceFieldAssignment(s, onceStorageFieldNames)) + return + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + else + { + // No static block - initialize fields with inline initializers + staticFieldDefs.each |f| + { + if (f.init == null) return + if (f.isOnce || onceStorageFieldNames.containsKey(f.name)) return + name := escapeName(f.name) + w("if ${typeName}._${name} is None").colon + indent + w("${typeName}._${name} = ") + PyExprPrinter(this).expr(f.init) + eos + unindent + } + } + + w("${typeName}._static_init_in_progress = False").eos + m.inStaticContext = false + + unindent + } + + ** Check if a statement assigns to a once storage field + private Bool isOnceFieldAssignment(Stmt s, Str:Bool onceFieldNames) + { + if (s.id != StmtId.expr) return false + exprStmt := s as ExprStmt + if (exprStmt.expr.id != ExprId.assign) return false + + assignExpr := exprStmt.expr as BinaryExpr + if (assignExpr.lhs.id != ExprId.field) return false + + fieldExpr := assignExpr.lhs as FieldExpr + if (!fieldExpr.field.isStatic) return false + + return onceFieldNames.containsKey(fieldExpr.field.name) + } + + ** Generate static fields with class-level storage and lazy-init getters + ** Follows the JavaScript transpiler pattern from JsType.writeStaticField() + private Void staticFields(TypeDef t) + { + // Collect names of once storage fields from once methods + // When Fantom compiles a `once` method, it creates: + // 1. A storage field: methodName$Store (synthetic) + // 2. A helper method: methodName$Once (synthetic, contains original body) + // 3. The original method modified to check storage and call helper + // We identify once storage fields by finding methods with isOnce=true + onceStorageFieldNames := Str:Bool[:] + t.methodDefs.each |m| + { + if (m.isOnce) + onceStorageFieldNames["${m.name}\$Store"] = true + } + + // Find all static fields: + // - Non-synthetic fields (normal static fields) + // - Synthetic fields that are once storage (identified by method analysis above) + // - Fields with isOnce flag set (compiler may also set this) + staticFieldDefs := t.fieldDefs.findAll |f| + { + if (!f.isStatic) return false + if (f.enumDef != null) return false // Skip enum value fields - handled by vals()/_make_enum() + if (!f.isSynthetic) return true // Normal static field + if (f.isOnce) return true // Compiler marked as once + if (onceStorageFieldNames.containsKey(f.name)) return true // Backing storage for once method + return false + } + if (staticFieldDefs.isEmpty) return + + // Check if there's a staticInit method + staticInitMethod := t.methodDefs.find |m| { m.isStaticInit } + + // Generate class-level storage for each static field + nl + staticFieldDefs.each |f| + { + // Once fields use "_once_" as sentinel value, regular fields use None + isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name) + if (isOnceStorage) + w("_${escapeName(f.name)} = \"_once_\"").nl + else + w("_${escapeName(f.name)} = None").nl + } + + // Generate static getter for each static field + staticFieldDefs.each |f| + { + // Determine if this is a once storage field (synthetic backing for once method) + isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name) + staticFieldGetter(t, f, staticInitMethod != null, isOnceStorage) + } + + // Generate _static_init() method if there's a staticInit block or fields need init + if (staticInitMethod != null || staticFieldDefs.any |f| { f.init != null }) + { + staticInit(t, staticInitMethod, staticFieldDefs) + } + } + + ** Generate static getter method for a static field + ** isOnceStorage: true if this field is backing storage for a once method + private Void staticFieldGetter(TypeDef t, FieldDef f, Bool hasStaticInit, Bool isOnceStorage) + { + name := escapeName(f.name) + typeName := PyUtil.escapeTypeName(t.name) + + nl + w("@staticmethod").nl + w("def ${name}()").colon + indent + + // Special handling for 'once' fields (either compiler-marked or identified as once storage) + // Once fields use "_once_" as sentinel and call the $Once helper method + if (isOnceStorage) + { + // The field name is like "specRef$Store", helper method is "specRef$Once" + // We need to extract the base name and generate the helper call + // Field name: specRef_Store (escaped from specRef$Store) + // Helper method: specRef_Once (escaped from specRef$Once) + baseName := f.name + if (baseName.endsWith("\$Store")) + baseName = baseName[0..<-6] // Remove "$Store" suffix + helperName := escapeName(baseName + "\$Once") + + w("if ${typeName}._${name} == \"_once_\"").colon + indent + w("${typeName}._${name} = ${typeName}.${helperName}()").eos + unindent + w("return ${typeName}._${name}").eos + } + else + { + // Regular static field - check if uninitialized, then initialize + w("if ${typeName}._${name} is None").colon + indent + if (hasStaticInit || f.init != null) + { + w("${typeName}._static_init()").eos + } + // If still None after static init, use type-specific default value + w("if ${typeName}._${name} is None").colon + indent + w("${typeName}._${name} = ${typeDefaultVal(f.type)}").eos + unindent + unindent + + w("return ${typeName}._${name}").eos + } + unindent + } + + ** Generate _static_init() method that initializes all static fields + ** Follows Fantom's source order: static blocks run before fields declared after them + private Void staticInit(TypeDef t, MethodDef? staticInitMethod, FieldDef[] staticFieldDefs) + { + typeName := PyUtil.escapeTypeName(t.name) + + nl + w("@staticmethod").nl + w("def _static_init()").colon + indent + + // Add re-entry guard to prevent infinite recursion + // This handles circular dependencies between static fields + w("if hasattr(${typeName}, '_static_init_in_progress') and ${typeName}._static_init_in_progress").colon + indent + w("return").eos + unindent + w("${typeName}._static_init_in_progress = True").eos + + // Mark that we're in a static context (no 'self' available) + m.inStaticContext = true + + // Collect enum field names for filtering (only for enum types) + enumFieldNames := Str:Bool[:] + if (t.isEnum) + { + t.fieldDefs.each |f| + { + if (f.enumDef != null) + enumFieldNames[f.name] = true + } + } + + // If there's a static init method, it contains EVERYTHING (Fantom compiler combines + // all static initialization into one method). Just emit it, skipping the final return + // and enum value assignments (which are handled by vals()/_make_enum()). + if (staticInitMethod != null && staticInitMethod.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(staticInitMethod.code) + staticInitMethod.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + // Skip synthetic return at end - we'll handle cleanup after + if (s.id == StmtId.returnStmt) + { + ret := s as ReturnStmt + if (ret.expr == null) return // Skip void return + } + // Skip enum value field assignments - they're handled by vals()/_make_enum() + if (t.isEnum && isEnumFieldAssignment(s, enumFieldNames)) + return + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + else + { + // No static block - just initialize fields with inline initializers + staticFieldDefs.each |f| + { + if (f.init == null) return + name := escapeName(f.name) + w("if ${typeName}._${name} is None").colon + indent + w("${typeName}._${name} = ") + PyExprPrinter(this).expr(f.init) + eos + unindent + } + } + + // Clear the re-entry guard + w("${typeName}._static_init_in_progress = False").eos + + // Reset static context flag + m.inStaticContext = false + + unindent + } + + ** Generate combined getter/setter method for fields with custom accessors + ** In Python, we combine them into one method with optional parameter + private Void combinedFieldAccessor(FieldDef f) + { + name := escapeName(f.name) + // Return type reflects Fantom's actual type. Setter returns the value + // so both code paths have a matching return type. + retHint := pyTypeHint(f.type) + + // Generate: def fieldName(self, _val_=_UNSET) -> T: + // if _val_ is _UNSET: + // # getter body + // else: + // # setter body (returns _val_) + nl + w("def ${name}(self, _val_=_UNSET) -> ${retHint}").colon + indent + nl + + w("if _val_ is _UNSET").colon + indent + // Getter body + if (f.get != null && f.get.code != null) + { + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(f.get.code) + f.get.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + else + { + w("return self._${name}").eos + } + unindent + + w("else").colon + indent + // Setter body - need to replace 'it' param with '_val_' + if (f.set != null && f.set.code != null) + { + // Map 'it' to '_val_' for the setter body + w("it = _val_").eos + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(f.set.code) + f.set.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + stmtPrinter.stmt(s) + } + this.m.clearClosures() + } + else + { + w("self._${name} = _val_").eos + } + w("return _val_").eos + unindent + + unindent + } + +////////////////////////////////////////////////////////////////////////// +// Methods +////////////////////////////////////////////////////////////////////////// + + private Void method(MethodDef m) + { + this.m.curMethod = m + + // Set static context flag for static methods + // This ensures calls like self.make() become ClassName.make() + if (m.isStatic) + this.m.inStaticContext = true + + nl + + // Static decorator + if (m.isStatic) + w("@staticmethod").nl + + // def method_name(self, params...) -> ReturnType: + w("def ${escapeName(m.name)}(") + if (!m.isStatic) w("self") + + m.params.each |p, i| + { + if (i > 0 || !m.isStatic) w(", ") + w(escapeName(p.name)) + w(": ${pyTypeHint(p.type)}") // Type hint for parameter + if (p.hasDefault) + { + w(" = None") // Default params simplified for bootstrap + } + } + w(") -> ${pyTypeHint(m.returns)}") // Return type hint + colon + + // Method body + if (m.code == null || m.code.stmts.isEmpty) + { + indent + // Even empty methods need default parameter handling + // Count how many default checks will actually be emitted + // Must match the logic in emitDefaultParamChecks + paramNames := Str[,] + m.params.each |p| { paramNames.add(p.name) } + checksEmitted := m.params.any |p| + { + if (!p.hasDefault) return false + defExpr := p->def as Expr + isNullDefault := defExpr != null && defExpr.id == ExprId.nullLiteral + // Skip nullable params with non-null defaults unless they reference other params + if (p.type.isNullable && !isNullDefault && !exprReferencesParams(defExpr, paramNames)) + return false + return true + } + emitDefaultParamChecks(m) + if (!checksEmitted) + pass + unindent + } + else + { + indent + + // Emit default parameter value checks at start of method body + // This follows the JavaScript transpiler pattern from JsType.doWriteMethod() + emitDefaultParamChecks(m) + + // Scan method for closures to track usage + stmtPrinter := PyStmtPrinter(this) + stmtPrinter.scanMethodForClosures(m.code) + + // Emit statements (closures emitted lazily) + m.code.stmts.each |s, idx| + { + this.m.stmtIndex = idx + stmtPrinter.stmt(s) + } + + unindent + } + + // Clear closure state for next method + this.m.clearClosures() + + // NOTE: pyMain is now called from type() method after class ends + // to ensure if __name__ block is at module level, not inside class + + // Reset static context flag + if (m.isStatic) + this.m.inStaticContext = false + + this.m.curMethod = null + } + + ** Emit default parameter value checks at start of method body + ** Follows JS transpiler pattern: if (param === undefined) param = defaultExpr; + ** For Python: if param is None: param = defaultExpr + ** + ** IMPORTANT: For nullable params (Type?) with non-null defaults, we ONLY emit the check + ** if the default expression references another parameter (like `val.typeof`). + ** This preserves the "passing null" semantic for methods that use null to mean "no value" + ** (e.g., Duration? timeout := 30sec where null means no timeout). + private Void emitDefaultParamChecks(MethodDef m) + { + // Collect param names for reference checking + paramNames := Str[,] + m.params.each |p| { paramNames.add(p.name) } + + m.params.each |p| + { + if (!p.hasDefault) return + + defExpr := p->def as Expr + isNullDefault := defExpr != null && defExpr.id == ExprId.nullLiteral + + // For nullable params with non-null defaults, only emit check if default + // references another param (like val.typeof). Otherwise, skip to preserve + // the "passing null means null" semantic. + if (p.type.isNullable && !isNullDefault) + { + if (!exprReferencesParams(defExpr, paramNames)) + return // Skip - let body code handle null + } + + name := escapeName(p.name) + + // Generate: if param is None: param = defaultValue + w("if ${name} is None").colon + indent + w("${name} = ") + // p.def_ is the default value expression - transpile it + PyExprPrinter(this).expr(p->def) + eos + unindent + } + } + + ** Check if an expression references any of the given parameter names + ** Used to detect defaults like `val.typeof` that depend on other params + private Bool exprReferencesParams(Expr? e, Str[] paramNames) + { + if (e == null) return false + + // Check if this is a local variable reference to a param + if (e.id == ExprId.localVar) + { + localVar := e as LocalVarExpr + return paramNames.contains(localVar.var.name) + } + + // Check call target and args + if (e.id == ExprId.call) + { + call := e as CallExpr + if (call.target != null && exprReferencesParams(call.target, paramNames)) + return true + return call.args.any |arg| { exprReferencesParams(arg, paramNames) } + } + + // Check binary expr operands + if (e is BinaryExpr) + { + bin := e as BinaryExpr + return exprReferencesParams(bin.lhs, paramNames) || exprReferencesParams(bin.rhs, paramNames) + } + + // Check unary expr + if (e is UnaryExpr) + { + unary := e as UnaryExpr + return exprReferencesParams(unary.operand, paramNames) + } + + // Check ternary + if (e is TernaryExpr) + { + ternary := e as TernaryExpr + return exprReferencesParams(ternary.condition, paramNames) || + exprReferencesParams(ternary.trueExpr, paramNames) || + exprReferencesParams(ternary.falseExpr, paramNames) + } + + // Check shortcut expr + if (e is ShortcutExpr) + { + shortcut := e as ShortcutExpr + if (exprReferencesParams(shortcut.target, paramNames)) + return true + return shortcut.args.any |arg| { exprReferencesParams(arg, paramNames) } + } + + // Check coerce/type check + if (e.id == ExprId.coerce || e.id == ExprId.isExpr || e.id == ExprId.asExpr) + { + typeCheck := e as TypeCheckExpr + return exprReferencesParams(typeCheck.target, paramNames) + } + + return false + } + + ** Check if this is a main(Str[] args) method + private Bool isMain(MethodDef m) + { + m.name == "main" && m.params.size == 1 && m.params[0].type.isList + } + + ** Generate Python main block + private Void pyMain(MethodDef m) + { + nl + nl + w("if __name__ == \"__main__\":").nl + indent + typeName := PyUtil.escapeTypeName(m.parent.name) + w("import sys as sys_mod").nl + w("from fan.sys.List import List").nl + w("args = List.from_literal(sys_mod.argv[1:], 'sys::Str')").nl + w("exit_code = ${typeName}.main(args)").nl + w("sys_mod.exit(exit_code if exit_code is not None else 0)").nl + unindent + } + +////////////////////////////////////////////////////////////////////////// +// Type Metadata (Reflection) +////////////////////////////////////////////////////////////////////////// + + ** Generate type metadata registration for reflection + ** This is similar to how JsPod.writeTypeInfo() works for JavaScript + private Void typeMetadata(TypeDef t) + { + // Skip synthetic types + if (t.isSynthetic) return + + // Check if we have any slots to register + hasSlots := t.fieldDefs.any |f| { !f.isSynthetic } || + t.methodDefs.any |m| { !m.isSynthetic && !m.isInstanceInit && !m.isStaticInit } + + // Always need to emit tf_() for type-level metadata (facets, mixins, base type) + // even if there are no slots (e.g., mixins with only inherited members) + hasFacets := t.facets != null && !t.facets.isEmpty + hasMixins := !t.mixins.isEmpty && t.mixins.any |m| { m.qname != "sys::Obj" } + hasBase := t.base != null && !t.base.isObj + + // Skip only if there's nothing to register + if (!hasSlots && !hasFacets && !hasMixins && !hasBase) return + + nl + w("# Type metadata registration for reflection").nl + w("from fan.sys.Param import Param").nl + w("from fan.sys.Slot import FConst").nl + // For sys pod types, Type is already imported directly (no sys. prefix) + // For other pods, use sys.Type.find() via namespace import + if (t.pod.name == "sys") + w("_t = Type.find('${t.qname}')").nl + else + w("_t = sys.Type.find('${t.qname}')").nl + + // Calculate type flags + typeFlags := typeFlags(t) + + // Build mixin list + mixinList := Str[,] + t.mixins.each |m| + { + if (m.qname != "sys::Obj") + mixinList.add("'${m.qname}'") + } + mixinsStr := "[" + mixinList.join(", ") + "]" + + // Register type-level facets with flags, mixins, and base type + typeFacets := facetDict(t.facets) + // Enums implicitly have @Serializable{simple=true} + if (t.isEnum) + { + if (typeFacets == "{}") + typeFacets = "{'sys::Serializable': {'simple': True}}" + else if (!typeFacets.contains("sys::Serializable")) + typeFacets = typeFacets[0..-2] + ", 'sys::Serializable': {'simple': True}}" + } + // Determine base type (non-Obj) + baseStr := "None" + if (t.base != null && !t.base.isObj) + baseStr = "'${t.base.qname}'" + // Always emit tf_() with flags, mixins, and base + w("_t.tf_(${typeFacets}, ${typeFlags}, ${mixinsStr}, ${baseStr})").nl + + // Register fields (including enum value fields) + t.fieldDefs.each |f| + { + if (f.isSynthetic) return + + flags := fieldFlags(f) + typeSig := PyUtil.sanitizeJavaFfi(f.type.signature) + fieldFacets := "{}" + + // Enum value fields get special flag handling and may have facets + if (f.enumDef != null) + { + // Enum values are public static const using canonical FConst values + enumFlags := FConst.Public.or(FConst.Static).or(FConst.Const).or(FConst.Enum) + fieldFacets = facetDict(f.enumDef.facets) + // Use original Fantom name (f.name) for enum fields - NOT escapeName() + // Enum field names like "A", "B", "C" must stay uppercase for reflection + // Field.name() should return "A" not "a" + w("_t.af_('${f.name}', ${enumFlags}, '${typeSig}', ${fieldFacets})").nl + } + else + { + fieldFacets = facetDict(f.facets) + setterFlags := setterFlags(f) + // Use escapeName() for regular fields - converts to snake_case to match Python storage + // The Python code stores fields as _const_x not _constX + // Only emit setter flags if they differ from field flags + if (setterFlags != flags) + w("_t.af_('${escapeName(f.name)}', ${flags}, '${typeSig}', ${fieldFacets}, ${setterFlags})").nl + else + w("_t.af_('${escapeName(f.name)}', ${flags}, '${typeSig}', ${fieldFacets})").nl + } + } + + // Register methods + t.methodDefs.each |m| + { + if (m.isSynthetic) return + if (m.isInstanceInit) return + if (m.isStaticInit) return + if (m.isFieldAccessor) return + + flags := methodFlags(m) + retSig := PyUtil.sanitizeJavaFfi(m.returns.signature) + methodFacets := facetDict(m.facets) + + // Build params list + // For sys pod types, Type is already imported directly + // For other pods, use sys.Type.find() + typeFind := (t.pod.name == "sys") ? "Type.find" : "sys.Type.find" + if (m.params.isEmpty) + { + w("_t.am_('${m.name}', ${flags}, '${retSig}', [], ${methodFacets})").nl + } + else + { + w("_t.am_('${m.name}', ${flags}, '${retSig}', [") + m.params.each |p, i| + { + if (i > 0) w(", ") + pType := PyUtil.sanitizeJavaFfi(p.type.signature) + hasDefault := p.hasDefault ? "True" : "False" + w("Param('${escapeName(p.name)}', ${typeFind}('${pType}'), ${hasDefault})") + } + w("], ${methodFacets})").nl + } + } + + // Register implicit default constructor if no explicit (non-synthetic) ctors defined + // The Fantom compiler adds a synthetic default ctor to methodDefs, so we check for non-synthetic + // Types without explicit constructors get an implicit make() that needs reflection registration + explicitCtors := t.methodDefs.findAll |m| { m.isCtor && !m.isSynthetic } + if (explicitCtors.isEmpty && !t.isEnum && !t.isMixin) + { + // Implicit make() is public static constructor returning This + implicitCtorFlags := FConst.Public.or(FConst.Ctor).or(FConst.Static) + w("_t.am_('make', ${implicitCtorFlags}, '${t.qname}', [], {})").nl + } + } + + ** Serialize facets to Python dict format: {'sys::Serializable': {'simple': True}} + private Str facetDict(FacetDef[]? facets) + { + if (facets == null || facets.isEmpty) return "{}" + + s := StrBuf() + s.addChar('{') + facets.each |f, i| + { + if (i > 0) s.add(", ") + s.addChar('\'').add(f.type.qname).add("': ") + + // Build the facet value dict + if (f.names.isEmpty) + { + s.add("{}") + } + else + { + s.addChar('{') + f.names.each |name, j| + { + if (j > 0) s.add(", ") + s.addChar('\'').add(name).add("': ") + // Serialize the value - convert to Python literal + val := f.vals[j] + s.add(exprToPython(val)) + } + s.addChar('}') + } + } + s.addChar('}') + return s.toStr + } + + ** Convert a Fantom expression to a Python literal string + private Str exprToPython(Expr e) + { + if (e is LiteralExpr) + { + lit := e as LiteralExpr + val := lit.val + if (val == null) return "None" + if (val is Bool) return val == true ? "True" : "False" + if (val is Int) return val.toStr + if (val is Float) return val.toStr + if (val is Str) return val.toStr.toCode + return val.toStr.toCode + } + // Fallback - use Fantom's serialization + return e.serialize.toCode + } + + ** Calculate field flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan + private Int fieldFlags(FieldDef f) + { + flags := 0 + if (f.isPublic) flags = flags.or(FConst.Public) + if (f.isPrivate) flags = flags.or(FConst.Private) + if (f.isProtected) flags = flags.or(FConst.Protected) + if (f.isInternal) flags = flags.or(FConst.Internal) + if (f.isStatic) flags = flags.or(FConst.Static) + if (f.isVirtual) flags = flags.or(FConst.Virtual) + if (f.isOverride) flags = flags.or(FConst.Override) + if (f.isConst) flags = flags.or(FConst.Const) + return flags + } + + ** Calculate setter-specific flags - uses canonical FConst values + ** For fields like "Int x { private set }", the setter has different visibility + ** than the field itself. Returns setter flags if f.set exists, otherwise field flags. + private Int setterFlags(FieldDef f) + { + // If field has no setter accessor method, use field flags + if (f.set == null) return fieldFlags(f) + + // Use the setter's flags directly - FConst values from compiler + setFlags := f.set.flags + + // Check setter's protection using canonical FConst values + isSetterPublic := setFlags.and(FConst.Public) != 0 + isSetterPrivate := setFlags.and(FConst.Private) != 0 + isSetterProtected := setFlags.and(FConst.Protected) != 0 + isSetterInternal := setFlags.and(FConst.Internal) != 0 + + // If setter has no protection flags, inherit from field + if (!isSetterPublic && !isSetterPrivate && !isSetterProtected && !isSetterInternal) + return fieldFlags(f) + + // Build output flags using canonical FConst values + flags := 0 + if (isSetterPublic) flags = flags.or(FConst.Public) + if (isSetterPrivate) flags = flags.or(FConst.Private) + if (isSetterProtected) flags = flags.or(FConst.Protected) + if (isSetterInternal) flags = flags.or(FConst.Internal) + + // Non-protection flags from field + if (f.isStatic) flags = flags.or(FConst.Static) + if (f.isVirtual) flags = flags.or(FConst.Virtual) + if (f.isOverride) flags = flags.or(FConst.Override) + if (f.isConst) flags = flags.or(FConst.Const) + + return flags + } + + ** Calculate method flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan + private Int methodFlags(MethodDef m) + { + flags := 0 + if (m.isPublic) flags = flags.or(FConst.Public) + if (m.isPrivate) flags = flags.or(FConst.Private) + if (m.isProtected) flags = flags.or(FConst.Protected) + if (m.isInternal) flags = flags.or(FConst.Internal) + if (m.isCtor) flags = flags.or(FConst.Ctor) + if (m.isStatic) flags = flags.or(FConst.Static) + if (m.isVirtual) flags = flags.or(FConst.Virtual) + if (m.isAbstract) flags = flags.or(FConst.Abstract) + if (m.isOverride) flags = flags.or(FConst.Override) + return flags + } + + ** Calculate type flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan + private Int typeFlags(TypeDef t) + { + flags := 0 + if (t.isPublic) flags = flags.or(FConst.Public) + if (t.isInternal) flags = flags.or(FConst.Internal) + if (t.isAbstract) flags = flags.or(FConst.Abstract) + if (t.isConst) flags = flags.or(FConst.Const) + if (t.isFinal) flags = flags.or(FConst.Final) + if (t.isMixin) flags = flags.or(FConst.Mixin) + if (t.isEnum) flags = flags.or(FConst.Enum) + if (t.isFacet) flags = flags.or(FConst.Facet) + return flags + } + + ** Check if a statement assigns to an enum value field + ** Used to filter out enum value initialization from _static_init() since + ** enum values are handled by vals()/_make_enum() instead + private Bool isEnumFieldAssignment(Stmt s, Str:Bool enumFieldNames) + { + // Check for expression statement with assignment + if (s.id != StmtId.expr) return false + exprStmt := s as ExprStmt + if (exprStmt.expr.id != ExprId.assign) return false + + // Check if LHS is a field reference + assignExpr := exprStmt.expr as BinaryExpr + if (assignExpr.lhs.id != ExprId.field) return false + + fieldExpr := assignExpr.lhs as FieldExpr + if (!fieldExpr.field.isStatic) return false + + // Check if it's an enum field + return enumFieldNames.containsKey(fieldExpr.field.name) + } + +////////////////////////////////////////////////////////////////////////// +// Python Type Hints +////////////////////////////////////////////////////////////////////////// + + ** Convert Fantom type to Python type hint string + ** Returns a forward-reference string in quotes like 'Optional[Unit]' + ** All type hints use forward references (strings) to avoid import order issues + Str pyTypeHint(CType t) + { + // Build the type hint string without quotes, then wrap in quotes at the end + inner := pyTypeHintInner(t) + // None is a special case - don't quote it + if (inner == "None") return "None" + return "'${inner}'" + } + + ** Inner type hint builder - returns unquoted type string + private Str pyTypeHintInner(CType t) + { + // Handle nullable types -> Optional[T] + if (t.isNullable) + { + inner := pyTypeHintInner(t.toNonNullable) + return "Optional[${inner}]" + } + + sig := t.signature + + // Map Fantom primitives to Python built-in types + if (sig == "sys::Bool") return "bool" + if (sig == "sys::Int") return "int" + if (sig == "sys::Float") return "float" + if (sig == "sys::Str") return "str" + if (sig == "sys::Void") return "None" + if (sig == "sys::Obj") return "Obj" + if (sig == "sys::This") return "Self" // Python 3.11+ Self type + + // Handle List[T] - sys::Obj?[] -> List[Obj] + if (t.isList) + { + // Get the value type (V in List) + listType := t as ListType + if (listType != null) + { + inner := pyTypeHintInner(listType.v) + return "List[${inner}]" + } + return "List" + } + + // Handle Map[K,V] - [Str:Int] -> Dict[str, int] + if (t.isMap) + { + mapType := t as MapType + if (mapType != null) + { + k := pyTypeHintInner(mapType.k) + v := pyTypeHintInner(mapType.v) + return "Dict[${k}, ${v}]" + } + return "Dict" + } + + // Handle Func types -> Callable + if (t.isFunc) + { + funcType := t as FuncType + if (funcType != null) + { + // Build Callable[[Param1, Param2], ReturnType] + params := StrBuf() + params.addChar('[') + funcType.params.each |p, i| + { + if (i > 0) params.add(", ") + params.add(pyTypeHintInner(p)) + } + params.addChar(']') + retType := pyTypeHintInner(funcType.returns) + return "Callable[${params.toStr}, ${retType}]" + } + return "Callable" + } + + // Default: use simple class name + return t.name + } + +} diff --git a/src/fanc/fan/py/PyUtil.fan b/src/fanc/fan/py/PyUtil.fan new file mode 100644 index 000000000..4bd849144 --- /dev/null +++ b/src/fanc/fan/py/PyUtil.fan @@ -0,0 +1,237 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using compiler + +** +** Python transpiler utilities +** +class PyUtil +{ + ** Python reserved words that are used as pod names and need directory escaping + static const Str[] reservedPodNames := ["def", "class", "import", "from", "if", "else", + "for", "while", "try", "except", "finally", + "with", "as", "in", "is", "not", "and", "or", + "True", "False", "None", "lambda", "return", + "yield", "raise", "pass", "break", "continue", + "global", "nonlocal", "async", "await"] + + ** Escape pod name if it conflicts with Python reserved words + static Str escapePodName(Str podName) + { + reservedPodNames.contains(podName) ? "${podName}_" : podName + } + + ** Python keywords that cannot be used as class names or attribute accesses + static const Str[] reservedTypeNames := ["None", "True", "False"] + + ** Escape type name if it conflicts with Python keywords + ** e.g., xeto::None -> None_ (because "class None" and ".None" are syntax errors) + static Str escapeTypeName(Str typeName) + { + reservedTypeNames.contains(typeName) ? "${typeName}_" : typeName + } + + ** Get output file for a type + ** Uses fan/{podName}/ namespace to avoid Python built-in conflicts + static File typeFile(File outDir, TypeDef t) + { + escapedPod := escapePodName(t.pod.name) + escapedType := escapeTypeName(t.name) + return outDir + `fan/${escapedPod}/${escapedType}.py` + } + + ** Get output directory for a pod + ** Uses fan/{podName}/ namespace to avoid Python built-in conflicts + static File podDir(File outDir, Str podName) + { + escapedPod := escapePodName(podName) + return outDir + `fan/${escapedPod}/` + } + + ** Convert pod name to Python import path + ** e.g., "sys" -> "fan.sys", "testSys" -> "fan.testSys", "def" -> "fan.def_" + static Str podImport(Str podName) + { + escapedPod := escapePodName(podName) + return "fan.${escapedPod}" + } + + ** Check if a type signature is a Java FFI type + ** e.g., "[java]java.lang.management::ThreadMXBean" + static Bool isJavaFfi(Str? name) + { + if (name == null) return false + return name.contains("[java]") + } + + ** Sanitize Java FFI type references for Python + ** In JS transpiler, these become parseable but fail at runtime if invoked + ** For Python, we use a similar pattern: [java]x.y -> java_ffi_fail.x.y + static Str sanitizeJavaFfi(Str name) + { + if (name.contains(".[java].")) return name.replace(".[java].", ".") + if (name.contains("[java]")) return name.replace("[java]", "java_ffi_fail.") + return name + } + + ** Python reserved words that need to be escaped + static const Str:Str reservedWords + static + { + m := Str:Str[:] + // Python keywords + [ + "False", "None", "True", "and", "as", "assert", "async", "await", + "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", + "while", "with", "yield", "match", "case", + // Built-in functions that could conflict + "type", "hash", "id", "list", "map", "str", "int", "float", "bool", + "self", + // Additional builtins that conflict with Fantom method names + "abs", "all", "any", "min", "max", "pow", "round", "set", "dir", + "oct", "open", "vars", "print", + // Module name that conflicts with pod namespace import (from fan import sys) + "sys" + ].each |name| { m[name] = "${name}_" } + reservedWords = m.toImmutable + } + + ** Convert camelCase to snake_case + ** Examples: + ** toStr -> to_str + ** isEmpty -> is_empty + ** XMLParser -> xml_parser + ** getHTTPResponse -> get_http_response + ** utf16BE -> utf16_be + ** toBase64Uri -> to_base64_uri + static Str toSnakeCase(Str name) + { + // Fast path: if all lowercase and no uppercase, return as-is + hasUpper := false + name.each |ch| { if (ch.isUpper) hasUpper = true } + if (!hasUpper) return name + + buf := StrBuf() + prev := 0 + name.each |ch, i| + { + if (ch.isUpper) + { + // Check if this is start of acronym or end of acronym + next := (i + 1 < name.size) ? name[i + 1] : 0 + prevIsLower := prev.isLower + prevIsDigit := prev.isDigit + nextIsLower := next != 0 && next.isLower + + // Add underscore before uppercase if: + // 1. Previous char was lowercase (camelCase boundary): toStr -> to_str + // 2. We're in an acronym and next char is lowercase (end of acronym): XMLParser -> xml_parser + // 3. Previous char was a digit (number to uppercase): utf16BE -> utf16_be + if (i > 0 && (prevIsLower || prevIsDigit || (prev.isUpper && nextIsLower))) + { + buf.addChar('_') + } + buf.addChar(ch.lower) + } + else + { + buf.addChar(ch) + } + prev = ch + } + return buf.toStr + } + + ** Escape Python reserved words and invalid characters + ** Also converts camelCase to snake_case for Pythonic naming + static Str escapeName(Str name) + { + // First replace $ with _ (Fantom synthetic names use $) + escaped := name.replace("\$", "_") + // Convert camelCase to snake_case + snake := toSnakeCase(escaped) + // Then check for reserved words + return reservedWords.get(snake, snake) + } + + ** Convert Fantom boolean literal to Python + static Str boolLiteral(Bool val) + { + val ? "True" : "False" + } + + ** Convert Fantom null literal to Python + static Str nullLiteral() + { + "None" + } + + ** Is this a native Python type (uses static method dispatch) + static Bool isPyNative(CType t) + { + t.isObj || t.isStr || t.isVal + } + + ** Map of method qname to unary operators + static once Str:Str unaryOperators() + { + [ + "sys::Bool.not": "not ", + "sys::Int.negate": "-", + "sys::Float.negate": "-", + ].toImmutable + } + + ** Map of method qname to binary operators + static once Str:Str binaryOperators() + { + [ + "sys::Str.plus": "+", + + "sys::Int.plus": "+", + "sys::Int.minus": "-", + "sys::Int.mult": "*", + // Int.div intentionally NOT mapped - Python // has floor division semantics + // but Fantom uses truncated division (toward zero) + // Handled by ObjUtil.div() in PyExprPrinter.divOp() + // Int.mod intentionally NOT mapped - Python % has different semantics + // for negative numbers (floor division vs truncated division) + // Handled by ObjUtil.mod() in PyExprPrinter.modOp() + "sys::Int.plusFloat": "+", + "sys::Int.minusFloat": "-", + "sys::Int.multFloat": "*", + "sys::Int.divFloat": "/", + + "sys::Float.plus": "+", + "sys::Float.minus": "-", + "sys::Float.mult": "*", + "sys::Float.div": "/", + "sys::Float.plusInt": "+", + "sys::Float.minusInt": "-", + "sys::Float.multInt": "*", + "sys::Float.divInt": "/", + ].toImmutable + } + + ** The instance side method name for a constructor + static Str ctorImplName(CMethod x) + { + "${x.name}_init_" + } + + ** Handle special method names + static Str methodName(CMethod x) + { + n := x.name + if (n.startsWith("instance\$init\$")) return "instance_init_" + return escapeName(n) + } +} diff --git a/src/fanc/fan/py/PythonCmd.fan b/src/fanc/fan/py/PythonCmd.fan new file mode 100644 index 000000000..f11e2e28a --- /dev/null +++ b/src/fanc/fan/py/PythonCmd.fan @@ -0,0 +1,469 @@ +// +// Copyright (c) 2025, Brian Frank and Andy Frank +// Licensed under the Academic Free License version 3.0 +// +// History: +// 25 Feb 2026 Trevor Adelman Creation +// + +using build +using compiler +using util + +** +** Python transpiler command +** +internal class PythonCmd : TranspileCmd +{ + override Str name() { "py" } + + override Str summary() { "Transpile to Python" } + + override Int usage(OutStream out := Env.cur.out) + { + ret := super.usage(out) + out.printLine("Examples:") + out.printLine(" fanc py foo // generate Python source for 'foo' pod and its depends") + return ret + } + + override Void genPod(PodDef pod) + { + // Create fan/__init__.py namespace package if it doesn't exist + fanDir := outDir + `fan/` + fanDir.create + fanInit := fanDir + `__init__.py` + if (!fanInit.exists) + { + fanInit.out.printLine("# Fantom Python namespace package").printLine("# Auto-generated by fanc py").close + } + + // Create lazy loader __init__.py for the pod package + initFile := PyUtil.podDir(outDir, pod.name) + `__init__.py` + initFile.parent.create + initOut := initFile.out + + // Check for hand-written natives directory + nativeDir := findNativeDir(pod.name) + + // Generate all types and collect names for lazy loader in one pass + typeNames := Str[,] + pod.typeDefs.each |t| + { + if (t.isSynthetic) return + genTypeWithNative(t, nativeDir) + typeNames.add(PyUtil.escapeTypeName(t.name)) + } + + // Also include extra native types (ObjUtil, etc.) + if (nativeDir != null) + { + nativeDir.list.each |f| + { + if (f.ext != "py") return + typeName := PyUtil.escapeTypeName(f.basename) + if (!typeNames.contains(typeName)) + typeNames.add(typeName) + } + } + + // Write lazy loader __init__.py + writeLazyLoader(initOut, pod.name, typeNames) + initOut.close + + // Copy runtime files (fanx module) for sys pod + if (pod.name == "sys") + { + copyRuntime(pod.name) + } + + // Copy any extra native files that don't have corresponding types + // (e.g., ObjUtil.py, SyntheticFile.py, etc.) + if (nativeDir != null) + { + copyExtraNatives(pod, nativeDir) + } + + // Extract web assets (JS, CSS, resources) from the pre-built pod file + // Assets go into fan/_assets/ to keep them co-located with the serving code + extractAssets(pod.name) + } + +////////////////////////////////////////////////////////////////////////// +// Asset Extraction +////////////////////////////////////////////////////////////////////////// + + ** + ** Extract all web assets (JS, CSS, resources) from the pre-built pod file. + ** Assets are placed in fan/_assets/ following Python packaging best practices, + ** keeping static files co-located with the code that serves them (fan/web/FilePack.py). + ** + ** Package Size Notes: + ** ------------------- + ** Current extraction includes ALL JS files, resulting in ~10MB uncompressed assets. + ** For production releases, consider adding a flag to skip test JS files: + ** + ** test*.js files (~2.6MB uncompressed): + ** - testSys.js (1.4MB) - Fantom sys tests for browser JS runtime + ** - testXeto.js, testHaystack.js, testAxon.js, testDomkit.js, testGraphics.js + ** + ** These test JS files are for browser-based testing of the JavaScript runtime, + ** not for testing the Python transpilation. To reduce wheel size for production: + ** + ** 1. Add a --production flag to fanc py + ** 2. In extractJs(), skip files where pod.name.startsWith("test") + ** 3. This would reduce the wheel from ~2.2MB to ~1.4MB compressed + ** + private Void extractAssets(Str podName) + { + podObj := Pod.find(podName, false) + if (podObj == null) return + + extractJs(podObj) + extractResources(podObj) + } + + ** Extract JavaScript and source maps from pod + private Void extractJs(Pod pod) + { + // Place JS in fan/_assets/js/ (co-located with fan/web/FilePack.py) + jsDir := outDir + `fan/_assets/js/` + jsDir.create + + // Try ES6 first (/js/{pod}.js), then legacy (/{pod}.js) + jsFile := pod.file(`/js/${pod.name}.js`, false) + ?: pod.file(`/${pod.name}.js`, false) + if (jsFile != null) + { + outFile := jsDir + `${pod.name}.js` + jsFile.copyTo(outFile, ["overwrite": true]) + info(" Extracted ${pod.name}.js") + } + + // Also extract fan.js for sys pod (ES6 module system bootstrap) + if (pod.name == "sys") + { + fanJs := pod.file(`/js/fan.js`, false) + if (fanJs != null) + { + outFile := jsDir + `fan.js` + fanJs.copyTo(outFile, ["overwrite": true]) + info(" Extracted fan.js") + } + } + + // Source map (try ES6 location first, then legacy) + mapFile := pod.file(`/js/${pod.name}.js.map`, false) + ?: pod.file(`/${pod.name}.js.map`, false) + if (mapFile != null) + { + outFile := jsDir + `${pod.name}.js.map` + mapFile.copyTo(outFile, ["overwrite": true]) + } + } + + ** Extract all resources (CSS, images, data files) from pod + private Void extractResources(Pod pod) + { + // Place resources in fan/_assets/res/{podName}/ to avoid collisions + // between pods that have files with the same name (e.g., style.css) + resDir := outDir + `fan/_assets/res/${pod.name}/` + + // Get all files in the pod and filter for res/ + // URIs are like: fan://podName/res/css/file.css + pod.files.each |file| + { + // Get the path part of the URI (e.g., "/res/css/domkit.css") + path := file.uri.pathStr + if (path.startsWith("/res/")) + { + // Preserve directory structure under res/{podName}/ + relPath := path[5..-1] // Strip "/res/" + outFile := resDir + relPath.toUri + outFile.parent.create + file.copyTo(outFile, ["overwrite": true]) + + // Log CSS files specifically + if (file.ext == "css") + info(" Extracted ${pod.name}/${file.name}") + } + } + } + + ** Copy native files that don't have corresponding type definitions + ** These are utility modules like ObjUtil.py that are hand-written only + private Void copyExtraNatives(PodDef pod, File nativeDir) + { + // Get set of type names that have type definitions (escaped for Python keyword safety) + typeNames := Str:Bool[:] + pod.typeDefs.each |t| { typeNames[PyUtil.escapeTypeName(t.name)] = true } + + // Copy any .py files that don't have corresponding types + nativeDir.list.each |f| + { + if (f.ext != "py") return + typeName := f.basename + + // Special handling for __init__.py: prepend native content to the + // generated lazy loader rather than overwriting it. The lazy loader + // contains the _types dict needed by Pod.types() for type discovery. + if (typeName == "__init__") + { + initFile := PyUtil.podDir(outDir, pod.name) + `__init__.py` + existingContent := initFile.readAllStr + nativeContent := f.readAllStr + out := initFile.out + try + { + out.print(nativeContent) + out.printLine("") + out.printLine("# ---- Generated lazy loader (do not edit below) ----") + out.printLine("") + out.print(existingContent) + } + finally out.close + return + } + + if (typeNames[PyUtil.escapeTypeName(typeName)] != true) + { + // This is an extra native file - copy it directly + outFile := PyUtil.podDir(outDir, pod.name) + `${f.name}` + f.copyTo(outFile, ["overwrite": true]) + } + } + } + + ** Find the native Python directory for a pod + ** Uses pyFiles from compiler input (via dynamic access for bootstrap compatibility) + private File? findNativeDir(Str podName) + { + // Use pyFiles from compiler input (dynamic access for bootstrap compatibility) + try + { + input := compiler?.input + pyFiles := input?->pyFiles as Uri[] + if (pyFiles != null && !pyFiles.isEmpty) + { + baseDir := input.baseDir + // pyFiles are relative paths like `py/` + // Return the first one that exists + return pyFiles.eachWhile |uri| + { + dir := baseDir.plus(uri) + return dir.exists ? dir : null + } + } + } + catch (Err e) { /* pyFiles not available in bootstrap - use fallback */ } + + // Fallback: look in standard locations + // Try py/fan/ first (sys pod pattern), then py/ (other pods) + File? result := null + Env.cur.path.each |path| + { + if (result != null) return + + // Try py/fan/ (sys pod) + nativeDir := path + `src/${podName}/py/fan/` + if (nativeDir.exists) + { + result = nativeDir + return + } + + // Try py/ directly (concurrent, util, etc.) + nativeDir = path + `src/${podName}/py/` + if (nativeDir.exists) + result = nativeDir + } + return result + } + + ** Generate a type, merging with native if available + private Void genTypeWithNative(TypeDef t, File? nativeDir) + { + outFile := PyUtil.typeFile(outDir, t) + outFile.parent.create + + // Check for hand-written native file + nativeFile := nativeDir != null ? nativeDir + `${t.name}.py` : null + + if (nativeFile != null && nativeFile.exists) + { + // Merge: native file + transpiled metadata + mergeNativeWithMetadata(t, nativeFile, outFile) + } + else + { + // No native - use fully transpiled output + out := outFile.out + try + { + PyTypePrinter(out).type(t) + } + finally + { + out.close + } + } + } + + ** Merge hand-written native file with transpiled metadata + private Void mergeNativeWithMetadata(TypeDef t, File nativeFile, File outFile) + { + // First generate the transpiled output to a buffer to extract metadata + buf := StrBuf() + PyTypePrinter(buf.out).type(t) + transpiled := buf.toStr + + // Extract metadata section (# Type metadata... to end of file) + metadataStart := transpiled.index("# Type metadata") + metadata := metadataStart != null ? transpiled[metadataStart..-1] : null + + // Write merged output + out := outFile.out + try + { + // Copy native file content + out.print(nativeFile.readAllStr) + + // Append metadata if we have it + if (metadata != null) + { + out.printLine("") + // For sys pod: metadata uses bare Type.find() since Type is in the same pod + // For other pods: metadata uses sys.Type.find() via qualified import + if (t.pod.name == "sys") + out.printLine("from fan.sys.Type import Type") + else + out.printLine("import fan.sys as sys") + out.print(metadata) + } + } + finally + { + out.close + } + } + + ** Copy runtime files (fanx module, etc.) + private Void copyRuntime(Str podName) + { + // Look for fanx module in the pod's source tree + Env.cur.path.each |path| + { + fanxDir := path + `src/${podName}/py/fanx/` + if (fanxDir.exists) + { + // Create output fanx directory + outFanxDir := outDir + `fanx/` + outFanxDir.create + + // Copy all .py files from fanx module + fanxDir.list.each |f| + { + if (f.ext == "py") + { + outFile := outFanxDir + `${f.name}` + f.copyTo(outFile, ["overwrite": true]) + } + } + return + } + } + } + + ** Legacy method - kept for compatibility but not used + override Void genType(TypeDef t) + { + if (t.isSynthetic) return + + outFile := PyUtil.typeFile(outDir, t) + outFile.parent.create + + out := outFile.out + try + { + PyTypePrinter(out).type(t) + } + finally + { + out.close + } + } + + ** Write lazy loader __init__.py for a pod + ** This creates a module that lazily imports types on first access, + ** avoiding circular import issues and reducing startup time. + ** Uses module-level __getattr__ (Python 3.7+) to preserve package semantics. + private Void writeLazyLoader(OutStream out, Str podName, Str[] typeNames) + { + // Build the _types dict entries + typesDict := StrBuf() + typeNames.each |name, i| + { + comma := i < typeNames.size - 1 ? "," : "" + typesDict.add(" '${name}': '${name}'${comma}\n") + } + + // Template with {{POD}} and {{TYPES_DICT}} placeholders. + // Reads as the actual Python output for easy maintenance. + // Content is left-aligned because Str<|...|> preserves whitespace literally. + template := + Str<|""" + {{POD}} pod - Lazy loader module + Auto-generated by fanc py + + This module lazily imports types on first access to avoid circular imports + and reduce startup time. Access types via: from fan import {{POD}}; {{POD}}.TypeName + """ + + import importlib + + # Cache for loaded types + _cache = {} + _loading = set() # Prevent circular import loops + + # Known types in this pod + _types = { + {{TYPES_DICT}}} + + def __getattr__(name): + """Module-level __getattr__ for lazy type loading (Python 3.7+).""" + if name.startswith('_'): + raise AttributeError(name) + + # Check cache first + if name in _cache: + return _cache[name] + + # Prevent circular imports + if name in _loading: + return None # Return None during circular load + + # Check if this is a known type + if name not in _types: + raise AttributeError(f"module 'fan.{{POD}}' has no attribute '{name}'") + + _loading.add(name) + try: + # Import the module + module = importlib.import_module(f'fan.{{POD}}.{name}') + # Get the class from the module + cls = getattr(module, name, None) + if cls is not None: + _cache[name] = cls + return cls + raise AttributeError(f"fan.{{POD}}.{name} has no class {name}") + finally: + _loading.discard(name) + + def __dir__(): + """Return list of available types for IDE completion.""" + return list(_types.keys()) + |> + + out.print(template.replace("{{POD}}", podName).replace("{{TYPES_DICT}}", typesDict.toStr)) + } +} diff --git a/src/fanc/fan/py/design.md b/src/fanc/fan/py/design.md new file mode 100644 index 000000000..6f95ee9fe --- /dev/null +++ b/src/fanc/fan/py/design.md @@ -0,0 +1,1074 @@ +This is a "living" document covering many aspects of the design and implementation +for the mapping from Fantom to Python. + +--- + +## Table of Contents + +1. [Getting Started](#getting-started) - Usage and build integration +2. [Porting Native Code](#porting-native-code) - Adding Python natives to a pod +3. [Design](#design) - How Fantom constructs map to Python + - [Python Classes](#python-classes) + - [Fields](#fields) / [Static Fields](#static-fields) + - [Enums](#enums) + - [Funcs and Closures](#funcs-and-closures) + - [Primitives](#primitives) + - [List and Map Architecture](#list-and-map-architecture) + - [Import Architecture](#import-architecture) + - [Type Metadata](#type-metadata-reflection) + - [Generated Code Conventions](#generated-code-conventions) + - [ObjUtil Helper Methods](#objutil-helper-methods) + - [Exception Mapping](#exception-mapping) +4. [Naming](#naming) - Identifier conventions (snake_case, escaping) +5. [Python-Specific Considerations](#python-specific-considerations) - GIL, overloading, type hints +6. [Performance Optimizations](#performance-optimizations) - Runtime caching strategies + +--- + +## How to Read This Document + +**If you want to understand what Python code gets generated:** +- Start with [Design](#design) for the core patterns (classes, fields, closures) +- See [Naming](#naming) for how identifiers are transformed (snake_case, escaping) +- Check [Generated Code Conventions](#generated-code-conventions) for internal naming patterns + +**If you want to understand the runtime:** +- [ObjUtil Helper Methods](#objutil-helper-methods) documents the contract between transpiled code and runtime +- [List and Map Architecture](#list-and-map-architecture) explains collection design +- [Performance Optimizations](#performance-optimizations) covers caching strategies + +**If you're debugging generated code:** +- [Generated Code Conventions](#generated-code-conventions) explains patterns like `_closure_N`, `_switch_N` +- [Import Architecture](#import-architecture) explains why imports are structured the way they are +- [Exception Mapping](#exception-mapping) shows how catch clauses are generated + +**If you're adding Python support to a pod:** +- Start with [Getting Started](#getting-started) for build integration +- See [Porting Native Code](#porting-native-code) for the process +- Reference [Design](#design) for patterns to follow + +**Key Insight:** The transpiler generates Python code that calls `ObjUtil` and `Func` methods. +The [ObjUtil Helper Methods](#objutil-helper-methods) section documents this contract - if a +method is listed there, the transpiler generates calls to it and the runtime must implement it. + +--- + +# Getting Started + +The Python implementation of the `sys` pod is in `src/sys/py/fan/`. Unlike JavaScript +which bundles into a single file, Python uses individual `.py` files that match Python's +module import system. + +The Python transpiler lives in the `fanc` pod at `src/fanc/fan/py/`. It generates Python +code from Fantom source and outputs to `gen/py/`. + +## fanc py (Python Transpiler) + +The Python transpiler is invoked via `fanc py `. It serves the same purpose as +`compilerEs` for JavaScript, but emits Python 3 code. + +```bash +# Transpile testSys pod to Python +fanc py testSys + +# Transpile haystack pod +fanc py haystack +``` + +Generated code is output to `/gen/py/fan//` (where `work_dir` is +typically `fan_home`, determined by `Env.cur.workDir`). + +## Build Integration (pyDirs/pyFiles) + +The Python transpiler is integrated into Fantom's build system using the same pattern as +JavaScript's `jsDirs`: + +### BuildPod.fan +Pods declare native Python directories in their `build.fan`: + +```fantom +class Build : BuildPod +{ + pyDirs = [`py/`] // Directory containing Python natives +} +``` + +### CompilerInput.fan +The `pyFiles` field passes resolved native directories to the compiler: + +```fantom +class CompilerInput +{ + Uri[]? pyFiles // Resolved Python native directories +} +``` + +### PythonCmd.fan +The transpiler reads `pyFiles` from the compiler input: + +```fantom +// Uses compiler.input.pyFiles to find native directory +pyFiles := compiler?.input?.pyFiles +``` + +### Native Directory Locations + +| Pod | Native Directory | build.fan | +|-----|------------------|-----------| +| sys | `src/sys/py/fan/` | (special handling) | +| concurrent | `src/concurrent/py/` | `pyDirs = [\`py/\`]` | +| util | `src/util/py/` | `pyDirs = [\`py/\`]` | +| crypto | `src/crypto/py/` | `pyDirs = [\`py/\`]` | + +The sys pod uses `py/fan/` to match its source structure. Other pods use `py/` directly. + +## Native Code Merging + +When transpiling, the Python transpiler: +1. Checks if a hand-written native file exists in the pod's `py/` directory +2. If found, copies the native file and appends type metadata from the transpiled output +3. If not found, uses the fully transpiled output + +This allows hand-written runtime code (like `Actor.py`, `List.py`) to coexist with +transpiled code, similar to how JavaScript natives work. + +## Standard Build (Packaging into Pods) + +To package Python natives into `.pod` files (for distribution): + +```bash +fan src/sys/py/build.fan compile # Package natives into sys.pod +fan src/concurrent/py/build.fan compile # Package into concurrent.pod +fan src/util/py/build.fan compile # Package into util.pod +``` + +This packages Python natives inside `.pod` files at `/py/fan//`, matching the +JavaScript pattern of `/esm/` and `/cjs/` directories. + +# Porting Native Code + +If you have a pod with native Python implementations, follow these steps: + +1. Create a `/py/` directory in the root of your pod (peer to any existing `/js/` or `/es/`) +2. For `sys` pod specifically, create `/py/fan/` subdirectory (matches import path) +3. Port native code into this directory following the patterns below +4. Use the existing code in `sys/py/fan/` or `concurrent/py/` as reference + +Native files are named to match the Fantom type they implement (e.g., `List.py` for `sys::List`). + +## Native `__init__.py` Merging + +Each pod gets a generated `__init__.py` containing a lazy loader with a `_types` dict +that `Pod.types()` uses for type discovery. Some pods also have a hand-written +`__init__.py` in their native directory with module initialization code (import caching, +eager imports, etc.). + +When a native `__init__.py` exists, the transpiler **merges** it with the generated +lazy loader: native content is prepended, generated content is appended. This preserves +both the native infrastructure and the `_types` registry. + +**Pods with native `__init__.py`:** + +| Pod | Purpose | +|-----|---------| +| sys | Import caching optimization, module class fix for submodule shadowing, eager base type imports | +| concurrent | Eager imports of native types | +| util | Eager imports of native types | +| inet | Eager imports of native types | + +**Rules for native `__init__.py`:** +- Keep module initialization code that genuinely needs to run at import time +- Do NOT put monkey-patches for transpiled types here -- fix the transpiler or runtime instead +- The `_types` dict and `__getattr__` lazy loader are always appended by the transpiler + +# Design + +This section details the design decisions and implementation patterns for Python code. + +## Python Classes + +All Fantom types are implemented as Python classes extending `Obj`. + +Fantom: +```fantom +class Foo { } +``` + +Python (simplified): +```python +from fan.sys.Obj import Obj + +class Foo(Obj): + def __init__(self): + super().__init__() +``` + +**Note:** The example above is simplified for clarity. Actual generated files include +additional imports (`sys_module`, `ObjUtil`, pod namespace), a static `make()` factory +method, and type metadata registration at the end. See [Import Architecture](#import-architecture) +below for details on how imports are structured to avoid circular dependencies while +maintaining a clean API. + +## Fields + +All Fantom fields are generated with private storage using the `_fieldName` convention. +The compiler generates a combined getter/setter method using Python's optional parameter pattern. + +Fantom: +```fantom +class Foo +{ + Int a := 0 + Int b := 1 { private set } + private Int c := 2 +} +``` + +Python: +```python +class Foo(Obj): + def __init__(self): + super().__init__() + self._a = 0 + self._b = 1 + self._c = 2 + + # Public getter/setter with type hints + def a(self, _val_: 'int' = None) -> 'int': + if _val_ is None: + return self._a + else: + self._a = _val_ + + # Public getter only + def b(self) -> 'int': + return self._b + + # No method for _c (private getter/setter) +``` + +Usage: +```python +f = Foo() +f.a(100) # Set +print(f.a()) # Get: 100 +``` + +Note: Unlike JavaScript's `#private` fields, Python uses the `_fieldName` convention by +agreement rather than enforcement. + +## Static Fields + +Static fields use class-level storage with static getter methods. Lazy initialization +follows the same pattern as JavaScript to handle circular dependencies. + +Fantom: +```fantom +class Foo +{ + static const Int max := 100 +} +``` + +Python: +```python +class Foo(Obj): + _max = None + + @staticmethod + def max(): + if Foo._max is None: + Foo._static_init() + return Foo._max + + @staticmethod + def _static_init(): + if hasattr(Foo, '_static_init_in_progress') and Foo._static_init_in_progress: + return + Foo._static_init_in_progress = True + Foo._max = 100 + Foo._static_init_in_progress = False +``` + +## Enums + +Enums follow a factory pattern with lazy singleton initialization. Enum classes extend +`sys::Enum` (not `Obj` directly). + +Fantom: +```fantom +enum class Color { red, green, blue } +``` + +Python: +```python +class Color(Enum): + _vals = None + + @staticmethod + def red(): + return Color.vals().get(0) + + @staticmethod + def green(): + return Color.vals().get(1) + + @staticmethod + def blue(): + return Color.vals().get(2) + + @staticmethod + def vals(): + if Color._vals is None: + # For non-sys pods, uses sys.List prefix + Color._vals = sys.List.to_immutable(sys.List.from_list([ + Color._make_enum(0, "red"), + Color._make_enum(1, "green"), + Color._make_enum(2, "blue") + ])) + return Color._vals + + @staticmethod + def _make_enum(_ordinal, _name): + inst = object.__new__(Color) + inst._ordinal = _ordinal + inst._name = _name + return inst + + def ordinal(self): + return self._ordinal + + def name(self): + return self._name +``` + +## Funcs and Closures + +Fantom closures are generated as Python lambdas wrapped in `Func.make_closure()` to provide +Fantom's Func API (`bind()`, `params()`, `returns()`, etc.). + +Fantom: +```fantom +list.each |item, i| { echo(item) } +``` + +Python: +```python +List.each(list, Func.make_closure({ + "returns": "sys::Void", + "params": [{"name": "item", "type": "sys::Obj"}, {"name": "i", "type": "sys::Int"}] +}, (lambda item=None, i=None: print(item)))) +``` + +For simple single-expression closures, the lambda is inlined. For multi-statement closures, +a named function is emitted before the usage point. + +Unlike JavaScript where closures are invoked directly (`f(args)`), Python closures through +`Func.make_closure()` are also callable directly - the wrapper implements `__call__`. + +### Closure Immutability + +When a closure needs to be made immutable (typically for Actor message passing), the runtime +must create a "snapshot" of all captured values. This is handled by `Func.to_immutable()`. + +The transpiler analyzes each closure and sets an `immutable` case in the closure spec: +- `"always"` - Closure captures only const types (already immutable) +- `"never"` - Closure captures non-const types like `InStream` (cannot be made immutable) +- `"maybe"` - Closure captures types that can be made immutable at runtime + +For the `"maybe"` case, `to_immutable()` uses Python's `types.CellType` (Python 3.8+) to +create new closure cells with immutable copies of captured values: + +```python +# Runtime code in Func.to_immutable() +import types + +# Get the original function's closure cells +for i, cell in enumerate(original_func.__closure__): + val = cell.cell_contents + immutable_val = ObjUtil.to_immutable(val) # Snapshot the value + new_cell = types.CellType(immutable_val) # Create new cell + immutable_cells.append(new_cell) + +# Create new function with new closure cells +new_func = types.FunctionType( + original_func.__code__, + original_func.__globals__, + original_func.__name__, + original_func.__defaults__, + tuple(immutable_cells) # Attach new cells! +) +``` + +This ensures that when a closure is sent to an Actor: +1. Each captured variable gets its own frozen copy +2. The original variables in the sending thread are unaffected +3. No race conditions can occur on shared mutable state + +For closures using default parameter capture (`_outer=self` pattern), the same approach +rebinds the `__defaults__` tuple with immutable copies. + +**Python version compatibility:** The `types.CellType` approach requires Python 3.8+. For +older Python versions or closures without `__closure__`, the runtime falls back to rebinding +`__defaults__` (for default parameter captures) or simply marking the closure as immutable +if it has no captures. See `Func.to_immutable()` in `src/sys/py/fan/Func.py` for the full +implementation. + +## Primitives + +Python's type system differs significantly from JavaScript's. Fantom primitives map as follows: + +| Fantom | Python | Notes | +|--------|--------|-------| +| `Int` | `int` | Python int is arbitrary precision | +| `Float` | `float` | IEEE 754 double | +| `Bool` | `bool` | `True`/`False` | +| `Str` | `str` | Unicode string | +| `Decimal` | `float` | Uses Python float (not decimal.Decimal) | + +Since Python primitives don't have methods, instance method calls on primitives are converted +to static method calls: + +Fantom: +```fantom +x.toStr +s.size +``` + +Python: +```python +Int.to_str(x) +Str.size(s) +``` + +**Important:** `List` and `Map` are **NOT** primitives. They use normal instance method +dispatch like any other Fantom class. This matches the JavaScript transpiler's design. + +The `ObjUtil` class provides dispatch for methods that may be called on any type (`equals`, +`compare`, `hash`, `typeof`, etc.). + +## List and Map Architecture + +**List** extends `Obj` and implements Python's `MutableSequence` ABC: +- Uses `self._values` for internal storage (not inheriting from Python list) +- All methods are instance methods: `list.each(f)`, `list.map_(f)`, etc. +- `isinstance(fantom_list, list)` returns `False` +- Supports Python protocols: `len()`, `[]`, `in`, iteration + +**Map** extends `Obj` and implements Python's `MutableMapping` ABC: +- Uses `self._map` for internal storage (not inheriting from Python dict) +- All methods are instance methods: `map.get(k)`, `map.each(f)`, etc. +- `isinstance(fantom_map, dict)` returns `False` +- Supports Python protocols: `len()`, `[]`, `in`, iteration + +Fantom: +```fantom +list.each |item| { echo(item) } +map.get("key") +``` + +Python: +```python +list.each(lambda item: print(item)) +map.get("key") +``` + +## Import Architecture + +Python's import system requires careful handling to avoid circular `ImportError` exceptions. +The transpiler uses a **hybrid approach** combining lazy loader modules, namespace imports, +and targeted direct imports. + +### Pod-Level Lazy Loaders + +Each pod gets a generated `__init__.py` that lazily imports types on first access: + +```python +# fan/testSys/__init__.py (auto-generated) +import importlib + +_cache = {} +_loading = set() # Prevent circular import loops + +_types = { + 'BoolTest': 'BoolTest', + 'IntTest': 'IntTest', + # ... all types in pod +} + +def __getattr__(name): + """Module-level __getattr__ for lazy type loading (Python 3.7+).""" + if name in _cache: + return _cache[name] + if name in _loading: + return None # Circular import protection + + _loading.add(name) + try: + module = importlib.import_module(f'fan.testSys.{name}') + cls = getattr(module, name) + _cache[name] = cls + return cls + finally: + _loading.discard(name) +``` + +This allows code to reference types via `testSys.BoolTest` without importing all types +at module initialization time. + +### Import Structure per File + +Each generated Python file has this import structure: + +```python +# 1. System path setup +import sys as sys_module +sys_module.path.insert(0, '.') + +# 2. Type hints import +from typing import Optional, Callable, List as TypingList, Dict as TypingDict + +# 3. Pod namespace import (for non-sys pods) +from fan import sys + +# 4. Direct imports for class definition +from fan.sys.Obj import Obj # Base class +from fan.sys.ObjUtil import ObjUtil # Always needed +from fan.somePod.SomeMixin import SomeMixin # Mixins + +# 5. Dependent pods as namespaces +from fan import concurrent +from fan import haystack + +# 6. Exception types for catch clauses (Python requires class in local scope) +from fan.myPod.MyException import MyException +``` + +### Namespace-Qualified Type References + +In expressions, sys types are accessed via the `sys.` namespace prefix: + +```python +# List literal +sys.List.from_literal([1, 2, 3], 'sys::Int') + +# Map literal +sys.Map.from_literal(['a'], [1], 'sys::Str', 'sys::Int') + +# Type literal +sys.Type.find('sys::Bool') + +# Static method call +sys.Int.from_str("42") +``` + +The transpiler automatically adds `sys.` prefix when: +- The current type is NOT in the sys pod +- The target type IS in the sys pod + +This eliminates the need for 30+ direct imports of sys types at the top of each file. + +### Same-Pod Type References + +For types in the same pod, dynamic imports avoid circular dependencies: + +```python +# Direct reference would cause circular import: +# from fan.testSys.ObjWrapper import ObjWrapper # BAD + +# Dynamic import at point of use: +__import__('fan.testSys.ObjWrapper', fromlist=['ObjWrapper']).ObjWrapper +``` + +**Performance Note:** This pattern can result in millions of `__import__()` calls during +heavy operations like xeto namespace creation. See [__import__() Caching](#__import__-caching) +in Performance Optimizations for the runtime cache that makes this pattern efficient. + +### Cross-Pod Type References + +Cross-pod types use the namespace import pattern: + +```python +# Pod imported as namespace at top of file +from fan import haystack + +# Used in expressions +haystack.Coord.make(lat, lng) +``` + +### Exception Types in Catch Clauses + +Python's `except` clause requires the exception class in local scope - namespace +references don't work: + +```python +# This DOES NOT work in Python: +try: + ... +except sys.ParseErr: # SyntaxError: invalid syntax + ... + +# Must have direct import: +from fan.sys.ParseErr import ParseErr +try: + ... +except ParseErr: # Works + ... +``` + +The transpiler scans for catch clauses and generates direct imports for any exception +types used. This is the one case where AST scanning is still required. + +### Why This Design? + +The import architecture balances several constraints: + +1. **Avoid circular imports** - Lazy loaders and dynamic imports prevent initialization loops +2. **Minimize import lines** - Namespace imports instead of 30+ direct imports +3. **Python language requirements** - Exception classes must be in local scope +4. **Match JavaScript pattern** - Similar to JS pod bundling conceptually +5. **Support reflection** - Type metadata uses lazy string resolution + +## Type Metadata (Reflection) + +Each transpiled type registers metadata for Fantom's reflection system using `af_()` for +fields and `am_()` for methods. **Type signatures are stored as strings and lazily resolved +on first access** to avoid circular imports during module initialization: + +```python +# Type metadata registration - note: type signatures are STRINGS +from fan.sys.Param import Param +_t = Type.find('testSys::Foo') +_t.af_('name', 1, 'sys::Str', {}) # 'sys::Str' is a string, not Type.find() +_t.am_('doSomething', 1, 'sys::Void', [Param('arg', 'sys::Int', False)], {}) + +# Type resolution happens lazily when reflection is used: +# - Method.returns() resolves the return type string on first call +# - Field.type() resolves the field type string on first call +# - Param.type() resolves the param type string on first call +``` + +### Why Lazy Resolution? + +When Python imports a module, it executes all top-level code immediately. If `am_()` called +`Type.find()` during module initialization, it would trigger cascading imports: + +``` +Str.py loads -> am_() calls Type.find('sys::Int[]') -> Int.py loads -> + am_() calls Type.find('sys::Float') -> Float.py loads -> + am_() calls Type.find('sys::Decimal') -> ... circular crash +``` + +By storing type signatures as strings and resolving them lazily on first access, the module +initialization completes without triggering cross-type imports. This matches the JavaScript +transpiler's approach and is critical for the runtime to load without circular import errors. + +## Generated Code Conventions + +The transpiler generates several internal names and patterns. These are implementation +details but important for understanding generated code during debugging or review. + +### Variable Naming Patterns + +| Pattern | Purpose | Example | +|---------|---------|---------| +| `_closure_N` | Multi-statement closure functions | `def _closure_0(x=None): ...` | +| `_switch_N` | Switch condition cache variable | `_switch_0 = condition` | +| `_safe_` | Safe navigation lambda parameter | `(lambda _safe_: ... if _safe_ is not None else None)(target)` | +| `_val_` | Field setter parameter | `def name(self, _val_=None):` | +| `_old_x` | Post-increment/decrement temp | `((_old_x := x, x := x + 1, _old_x)[2])` | +| `_self` | Outer self in multi-statement closures | `def _closure_0(..., _self=self):` | +| `_outer` | Outer self in inline lambdas | `lambda x, _outer=self: ...` | + +### Sentinel Values + +| Value | Purpose | Location | +|-------|---------|----------| +| `"_once_"` | Uninitialized once field marker | `_fieldName_Store = "_once_"` | + +### Internal Methods + +| Method | Purpose | +|--------|---------| +| `_static_init()` | Initialize static fields (lazy) | +| `_static_init_in_progress` | Re-entry guard for static init | +| `_ctor_init()` | Initialize instance fields (for named ctors) | +| `_make_enum(ordinal, name)` | Create enum instance | +| `_ctorName_body(...)` | Named constructor body | + +### Why Closures Are Extracted + +Python lambdas cannot contain statements - only single expressions. When a Fantom closure +contains multiple statements, local variable declarations, or control flow, the transpiler +extracts it to a named `def` function emitted before the usage point: + +```python +# Multi-statement closure: |x| { y := x + 1; return y * 2 } +# Cannot be: lambda x: (y := x + 1, y * 2) # Invalid - walrus in tuple doesn't work right + +# Instead, extracted as: +def _closure_0(x=None, _self=self): + y = x + 1 + return y * 2 + +# Used as: +list.map_(_closure_0) +``` + +The closure is emitted immediately before the statement that first uses it, ensuring +captured variables are in scope. + +## ObjUtil Helper Methods + +The transpiler generates calls to `ObjUtil` methods for operations that don't map directly +to Python syntax or require Fantom-specific semantics. These must be implemented in the +`sys` runtime (PR2). + +### Comparison Methods + +| Method | Purpose | Fantom | Generated Python | +|--------|---------|--------|------------------| +| `ObjUtil.equals(a, b)` | NaN-aware equality | `a == b` | `ObjUtil.equals(a, b)` | +| `ObjUtil.compare(a, b)` | Fantom comparison | `a <=> b` | `ObjUtil.compare(a, b)` | +| `ObjUtil.compare_lt(a, b)` | Less than | `a < b` | `ObjUtil.compare_lt(a, b)` | +| `ObjUtil.compare_le(a, b)` | Less or equal | `a <= b` | `ObjUtil.compare_le(a, b)` | +| `ObjUtil.compare_gt(a, b)` | Greater than | `a > b` | `ObjUtil.compare_gt(a, b)` | +| `ObjUtil.compare_ge(a, b)` | Greater or equal | `a >= b` | `ObjUtil.compare_ge(a, b)` | +| `ObjUtil.compare_ne(a, b)` | Not equal | `a != b` | `ObjUtil.compare_ne(a, b)` | +| `ObjUtil.same(a, b)` | Identity comparison | `a === b` | `ObjUtil.same(a, b)` | + +**Why not Python operators?** Fantom's `==` uses `equals()` which handles NaN correctly +(NaN == NaN is true in Fantom). Python's `is` operator has interning issues with literals. + +### Arithmetic Methods + +| Method | Purpose | Why Needed | +|--------|---------|------------| +| `ObjUtil.div(a, b)` | Truncated integer division | Python `//` uses floor division (rounds toward -inf), Fantom uses truncated (toward zero) | +| `ObjUtil.mod(a, b)` | Truncated modulo | Same floor vs truncated difference | + +Example: `-7 / 4` in Fantom = -1 (truncated), but `-7 // 4` in Python = -2 (floor). + +### Type Methods + +| Method | Purpose | +|--------|---------| +| `ObjUtil.typeof(obj)` | Get Fantom Type of any object | +| `ObjUtil.is_(obj, type)` | Fantom `is` type check | +| `ObjUtil.as_(obj, type)` | Fantom `as` safe cast | +| `ObjUtil.coerce(obj, type)` | Fantom type coercion | +| `ObjUtil.to_immutable(obj)` | Make object immutable | +| `ObjUtil.is_immutable(obj)` | Check if immutable | + +### Expression Helpers + +| Method | Purpose | Use Case | +|--------|---------|----------| +| `ObjUtil.setattr_return(obj, name, val)` | Assignment as expression | `return x = 5` -> `return ObjUtil.setattr_return(self, '_x', 5)` | +| `ObjUtil.throw_(err)` | Raise as expression | Used in lambdas: `lambda: ObjUtil.throw_(Err())` | +| `ObjUtil.trap(obj, name, args)` | Dynamic call | `obj->method(args)` -> `ObjUtil.trap(obj, 'method', [args])` | + +### Increment/Decrement Helpers + +| Method | Purpose | +|--------|---------| +| `ObjUtil.inc_field(obj, name)` | Pre-increment field: `++x` | +| `ObjUtil.inc_field_post(obj, name)` | Post-increment field: `x++` | +| `ObjUtil.dec_field(obj, name)` | Pre-decrement field: `--x` | +| `ObjUtil.dec_field_post(obj, name)` | Post-decrement field: `x--` | +| `ObjUtil.inc_index(container, key)` | Pre-increment index: `++list[i]` | +| `ObjUtil.inc_index_post(container, key)` | Post-increment index: `list[i]++` | +| `ObjUtil.dec_index(container, key)` | Pre-decrement index: `--list[i]` | +| `ObjUtil.dec_index_post(container, key)` | Post-decrement index: `list[i]--` | + +### Closure Variable Wrapper + +| Method | Purpose | +|--------|---------| +| `ObjUtil.cvar(val)` | Wrap closure-captured variable for mutation | + +When a local variable is captured by a closure AND modified, the Fantom compiler generates +a wrapper class. The transpiler converts these to `ObjUtil.cvar()` calls which return a +mutable container object. + +## Exception Mapping + +Catch clauses map Fantom exceptions to Python native exceptions. When catching a Fantom +exception type, the transpiler also catches the corresponding Python native exception +to ensure interoperability. + +| Fantom Exception | Python Native | Generated Catch | +|------------------|---------------|-----------------| +| `sys::Err` | `Exception` | `except Exception` | +| `sys::IndexErr` | `IndexError` | `except (IndexErr, IndexError)` | +| `sys::ArgErr` | `ValueError` | `except (ArgErr, ValueError)` | +| `sys::IOErr` | `IOError` | `except (IOErr, IOError)` | +| `sys::UnknownKeyErr` | `KeyError` | `except (UnknownKeyErr, KeyError)` | + +### Exception Wrapping + +When catching `sys::Err` (which catches all Python exceptions), the runtime wraps native +Python exceptions to ensure they have Fantom's `Err` API (`.trace()`, `.msg()`, etc.): + +```python +try: + some_code() +except Exception as err: + err = sys.Err.wrap(err) # Ensures .trace() method exists + # ... handle err +``` + +### Exceptions Not Yet Mapped + +The following Fantom exceptions don't have Python native equivalents and are caught +directly: + +- `sys::ParseErr` - No direct Python equivalent +- `sys::CastErr` - Could map to `TypeError` but semantics differ +- `sys::NullErr` - Could map to `TypeError` for None access +- `sys::NotImmutableErr` - Fantom-specific +- `sys::ReadonlyErr` - Fantom-specific +- `sys::UnsupportedErr` - Could map to `NotImplementedError` + +# Naming + +All class names are preserved when going from Fantom to Python. + +## Snake_Case Conversion + +Method and field names are converted from camelCase to snake_case for a Pythonic API: + +| Fantom | Python | +|--------|--------| +| `toStr` | `to_str` | +| `fromStr` | `from_str` | +| `isEmpty` | `is_empty` | +| `findAll` | `find_all` | +| `containsKey` | `contains_key` | +| `XMLParser` | `xml_parser` | +| `utf16BE` | `utf16_be` | + +The conversion is handled by `PyUtil.toSnakeCase()` and applied through `escapeName()`. + +## Reserved Word Escaping + +Names that conflict with Python keywords or builtins get a trailing underscore `_`: + +**Keywords:** `class`, `def`, `return`, `if`, `else`, `for`, `while`, `import`, `from`, +`is`, `in`, `not`, `and`, `or`, `True`, `False`, `None`, `lambda`, `global`, `nonlocal`, +`pass`, `break`, `continue`, `raise`, `try`, `except`, `finally`, `with`, `as`, `assert`, +`yield`, `del`, `elif`, `match`, `case` + +**Builtins:** `type`, `hash`, `id`, `list`, `map`, `str`, `int`, `float`, `bool`, `abs`, +`all`, `any`, `min`, `max`, `pow`, `round`, `set`, `dir`, `oct`, `open`, `vars`, `print` + +``` +# Fantom +Void class(Str is) { ... } +Int hash() { ... } +Bool any(|V| f) { ... } + +# Python +def class_(self, is_): + ... +def hash_(self): + ... +def any_(self, f): + ... +``` + +Internal methods and fields used for compiler support use double-underscore prefix patterns +like `_static_init`, `_ctor_init`, etc. These should be considered private implementation +details. + +# Python-Specific Considerations + +## The GIL + +Python's Global Interpreter Lock (GIL) means true parallelism isn't possible with threads. +The `concurrent` pod's Actor model implementation uses Python's `concurrent.futures` but +won't achieve the same parallelism as JVM or JavaScript runtimes. + +**Free-Threaded Python (3.13+):** Starting with Python 3.13 (PEP 703), CPython offers an +experimental free-threaded build that disables the GIL entirely. This can be enabled at +runtime with `python3 -X gil=0` or by setting the `PYTHON_GIL=0` environment variable. +The `concurrent` pod's implementation already uses proper threading primitives +(`threading.Lock`, `threading.RLock`, `concurrent.futures.ThreadPoolExecutor`) throughout +Actor, ActorPool, ConcurrentMap, and the Atomic types, so **no code changes are required** +to benefit from true parallelism under the free-threaded build. CPU-bound actor message +processing will achieve real multi-core parallelism when the GIL is disabled. Note that +some third-party C extensions may not yet be compatible with free-threaded mode, so users +should verify their full dependency chain before enabling it in production. + +## No Method Overloading + +Python doesn't support method overloading by signature. Fantom constructors with different +signatures are handled via factory methods (`make`, `make1`, etc.) that all delegate to a +single `__init__`. + +## Type Hints + +The transpiler generates Python type hints for all public APIs: + +```python +def from_str(s: 'str', checked: 'bool' = None) -> 'Optional[Number]': + ... + +def unit(self, _val_: 'Optional[Unit]' = None) -> 'Optional[Unit]': + ... + +def to_float(self) -> 'float': + ... +``` + +Type mapping: +| Fantom | Python Type Hint | +|--------|------------------| +| `Bool` | `'bool'` | +| `Int` | `'int'` | +| `Float` | `'float'` | +| `Str` | `'str'` | +| `Void` | `None` | +| `Type?` | `'Optional[Type]'` | +| `Str[]` | `'List[str]'` | +| `[Str:Int]` | `'Dict[str, int]'` | +| `|Int->Bool|` | `'Callable[[int], bool]'` | + +All type hints use forward references (strings) to avoid import order issues. Required +imports are added to each generated file: + +```python +from typing import Optional, Callable, List as TypingList, Dict as TypingDict +``` + +**Note:** Runtime type checks still use `ObjUtil.is_()` and `ObjUtil.as_()` - type hints +are for IDE autocomplete and static analysis, not runtime enforcement. + +## None vs null + +Fantom's `null` maps directly to Python's `None`. Nullable types (`Str?`) don't have special +representation - any variable can hold `None`. + +# Performance Optimizations + +Unlike JavaScript where the V8 JIT compiler handles most performance concerns, Python's +interpreted nature requires explicit runtime optimizations. These were identified through +profiling (`cProfile`) of the test suites and xeto namespace creation. + +The optimizations follow a consistent pattern: identify hot paths through profiling, +cache computed results keyed by immutable signatures, and trade small amounts of +persistent memory (~100KB total) for significant CPU savings. + +## Type.find() Cache-First Pattern + +`Type.find()` is called millions of times during test execution (19M+ calls in testXeto). +The optimization ensures cache hits are as fast as possible: + +```python +@staticmethod +def find(qname, checked=True): + # CRITICAL: Check cache FIRST before any imports + # This saves ~0.6us per call (8.3x faster than doing import first) + cached = Type._cache.get(qname) + if cached is not None: + return cached + + # Imports only needed for cache misses and error handling + from .Err import ArgErr, UnknownTypeErr, UnknownPodErr + # ... rest of lookup logic +``` + +**Why this matters:** Python's `from .Err import ...` statement has ~0.6us overhead per call, +even when the modules are already in `sys.modules`. Moving the cache check before imports +reduces cache hit time from 0.66us to 0.12us (5.4x faster). + +**Memory impact:** Zero - just reordered existing code. + +## Func.make_closure() Param Caching + +Closures are created millions of times per test run (4.2M+ in testXeto). Each closure +specification includes parameter metadata that previously created new `Param` objects +every time: + +```python +# Module-level cache +_param_cache = {} # (name, type_sig) -> Param + +@staticmethod +def make_closure(spec, func): + # ... parse spec ... + for p in spec.get('params', []): + name = p['name'] + type_sig = p['type'] + cache_key = (name, type_sig) + + # Check cache first + cached = Func._param_cache.get(cache_key) + if cached is not None: + params.append(cached) + continue + + # Create and cache on miss + param = Param(name, Type.find(type_sig), False) + Func._param_cache[cache_key] = param + params.append(param) +``` + +**Why this matters:** Closures with the same parameter signature (e.g., `|Int->Bool|`) +share cached `Param` objects instead of creating 4.2M short-lived objects. + +**Memory impact:** ~50-100 KB (100-200 unique signatures × ~500 bytes/entry). +Actually reduces GC pressure by avoiding millions of allocations/deallocations. + +## __import__() Caching + +The transpiler generates `__import__('fan.pod.Type', fromlist=['Type']).Type` for same-pod +type references to avoid circular imports (see [Import Architecture](#import-architecture)). +During heavy operations like xeto namespace creation, this results in 3.6 million +`__import__()` calls. + +**Implementation:** `src/sys/py/fan/__init__.py` + +The optimization intercepts `__import__()` for `fan.*` modules: + +```python +# Pseudocode - see __init__.py for full implementation +cache_key = (module_name, tuple(fromlist)) +if cache_key in _fan_import_cache: + return _fan_import_cache[cache_key] +result = original_import(...) +_fan_import_cache[cache_key] = result +return result +``` + +**Why this matters:** Python's `__import__()` has overhead even for cached modules. +Caching the fully-resolved result (module + fromlist attribute) eliminates repeated +lookups. Xeto namespace creation drops from ~25s to ~6s (75% faster). + +**Memory impact:** ~10KB (245 unique module/fromlist combinations). + +**Design notes:** +- Cache stored in `builtins` to survive module reloading during test runs +- Only caches `fan.*` modules with explicit `fromlist` (transpiler pattern) +- Triggered early via `import fan.sys` in `haystack/__init__.py` + +## Performance Results + +These optimizations together deliver 28-32% speedup on testXeto: + +| Test Suite | Before | After | Improvement | +|------------|--------|-------|-------------| +| testXeto::AxonTest | 83.5s | 59.7s | 28% faster | +| testXeto::ValidateTest | 45.8s | 31.3s | 32% faster | + +Xeto namespace creation specifically: + +| Operation | Before | After | Improvement | +|-----------|--------|-------|-------------| +| create_namespace(['sys','ph']) | ~25s | ~6s | 75% faster | + +**Design principles:** +1. Optimize the hottest paths first (Type.find, closure creation, __import__) +2. Cache objects keyed by immutable signatures, not call count +3. Trade ~100KB memory for avoiding millions of allocations +4. Keep caches bounded by unique signatures in codebase diff --git a/src/fanc/fan/py/quickstart.md b/src/fanc/fan/py/quickstart.md new file mode 100644 index 000000000..27a3f8cac --- /dev/null +++ b/src/fanc/fan/py/quickstart.md @@ -0,0 +1,145 @@ +# Python Transpiler - Quick Start + +Build and test the Fantom-to-Python transpiler in 4 steps. + +**Requirements:** Fantom build environment, Python 3.12+ + +--- + +## Python Environment + +Python 3.12 or later is required. The `py` test runner discovers Python in this order: + +1. `FAN_PYTHON` environment variable (if set, uses that path directly) +2. `uv python find` (if [uv](https://docs.astral.sh/uv/) is installed) +3. `pyenv which python` (if pyenv is installed) +4. `python3` on PATH (macOS/Linux) or `python` on PATH (Windows) + +If your default `python3` is 3.12+, no configuration is needed. Otherwise: + +```bash +export FAN_PYTHON=/path/to/python3.12 +``` + +The test runner checks the version at startup and prints a clear error if it's too old. + +--- + +## Build and Run + +### 1. Build the transpiler + +```bash +bin/fan src/fanc/build.fan +``` + +### 2. Build the test runner + +```bash +bin/fan src/py/build.fan +``` + +### 3. Transpile pods to Python + +```bash +bin/fanc py sys testSys util +``` + +Output goes to `gen/py/` under `Env.cur.workDir` (the first entry in your PathEnv path, or FAN_HOME if not using PathEnv). The transpiler automatically resolves dependencies (`concurrent` is pulled in by `testSys`). + +**Important:** Each `fanc py` invocation regenerates the output directory. Transpile all pods you need in a single command. + +**Note:** You may see `ERROR: invalid fanc.cmd nodeJs::JsCmd` -- this is non-fatal. It happens when `fanc` tries to register all transpiler commands and `nodeJs.pod` isn't built. The Python transpiler runs fine regardless. + +### 4. Run tests + +```bash +bin/py test testSys::BoolTest +``` + +``` +-- Run: testSys::BoolTest.testIdentity + Pass: testSys::BoolTest.testIdentity [25] +-- Run: testSys::BoolTest.testDefVal + Pass: testSys::BoolTest.testDefVal [2] +... +*** +*** All tests passed! [1 types, 7 methods, 111 verifies] +*** +``` + +Run a single method: +```bash +bin/py test testSys::BoolTest.testDefVal +``` + +Run an entire pod: +```bash +bin/py test testSys +``` + +--- + +## Transpiling Additional Pods + +```bash +# Foundation pods (with inet, web, crypto, email) +bin/fanc py sys testSys util concurrent inet web crypto email + +# Run all testSys tests +bin/py test testSys +``` + +The transpiler resolves and transpiles dependencies automatically. + +--- + +## What Gets Generated + +`fanc py ` produces Python source in `gen/py/fan//`: + +- **Pure Fantom types** -- fully transpiled to Python classes +- **Types with natives** -- hand-written Python file merged with transpiled metadata +- **Extra natives** -- utility files (e.g., `ObjUtil.py`) copied directly +- **Lazy loader `__init__.py`** -- module-level `__getattr__` for lazy imports + +Each pod with Python natives declares them in `build.fan`: +```fantom +pyDirs = [`py/`] +``` + +The transpiler reads these via `compiler.input.pyFiles` and merges them with transpiled output. + +--- + +## File Layout + +``` +src/fanc/fan/py/ # Transpiler source + PythonCmd.fan # Entry point (fanc py ) + PyTypePrinter.fan # Class/type generation + PyExprPrinter.fan # Expression generation + PyStmtPrinter.fan # Statement generation + PyPrinter.fan # Base printer + PyUtil.fan # Utilities and operator maps + design.md # Technical reference + quickstart.md # This file + +src/sys/py/fan/ # sys pod natives (79 files) +src/sys/py/fanx/ # Serialization module (5 files) +src/concurrent/py/ # concurrent pod natives (12 files) +src/util/py/ # util pod natives (7 files) +src/inet/py/ # inet pod natives (9 files) +src/web/py/ # web pod natives (5 files) +src/crypto/py/ # crypto pod natives (2 files) + +src/py/ # py pod -- Python CLI tools + fan/Main.fan # CLI entry point (command dispatch) + fan/PyCmd.fan # Base command (Python discovery, version check) + fan/cmd/TestCmd.fan # Test runner (util::TestRunner) + fan/cmd/FanCmd.fan # Run Fantom main programs (py fan ) + fan/cmd/HelpCmd.fan # Help and command listing + fan/cmd/InitCmd.fan # Initialize Python environment +``` + +See `design.md` for the full technical reference on how Fantom constructs map to Python.