diff --git a/src/main/java/leekscript/common/Error.java b/src/main/java/leekscript/common/Error.java index adebf8ca..ae1ba98b 100644 --- a/src/main/java/leekscript/common/Error.java +++ b/src/main/java/leekscript/common/Error.java @@ -161,4 +161,5 @@ public enum Error { ANNOTATION_TODO, // 157 ANNOTATION_OVERRIDE_NO_PARENT, // 158 OVERRIDDEN_METHOD_NARROWER_VISIBILITY, // 159 + ANNOTATION_NOT_PURE, // 160 } \ No newline at end of file diff --git a/src/main/java/leekscript/compiler/PurityChecker.java b/src/main/java/leekscript/compiler/PurityChecker.java new file mode 100644 index 00000000..801d03a7 --- /dev/null +++ b/src/main/java/leekscript/compiler/PurityChecker.java @@ -0,0 +1,128 @@ +package leekscript.compiler; + +import java.util.Set; + +import leekscript.common.Error; +import leekscript.compiler.AnalyzeError.AnalyzeErrorLevel; +import leekscript.compiler.bloc.AbstractLeekBlock; +import leekscript.compiler.exceptions.LeekCompilerException; +import leekscript.compiler.expression.Expression; +import leekscript.compiler.expression.LeekArrayAccess; +import leekscript.compiler.expression.LeekExpression; +import leekscript.compiler.expression.LeekFunctionCall; +import leekscript.compiler.expression.LeekObjectAccess; +import leekscript.compiler.expression.LeekParenthesis; +import leekscript.compiler.expression.LeekVariable; +import leekscript.compiler.expression.Operators; +import leekscript.runner.LeekFunctions; + +/** + * Static purity analysis for the {@code @pure} annotation. A function annotated + * {@code @pure} is expected to be free of observable side effects; this checker + * detects the side effects that can be determined statically and emits an + * {@link Error#ANNOTATION_NOT_PURE} warning otherwise. + * + * The checks are performed during the normal {@code analyze()} traversal: each + * relevant node asks {@link WordCompiler#getCurrentPureFunction()} whether it is + * inside a {@code @pure} function and, if so, reports any side effect it + * introduces. A function is considered impure when its body, directly: + * + * Side effects hidden behind dynamic/method calls or aliasing are not detected, + * so the check is intentionally conservative (it may miss impurities but does not + * flag pure code). + */ +public class PurityChecker { + + // Standard-library builtins that mutate one of their arguments in place or + // perform I/O. These are listed explicitly here because the standard library + // registers them without purity metadata (return type is not a reliable signal: + // e.g. pop/setPut mutate but return a value). Extra functions provided by a host + // (e.g. the Leek Wars generator) instead declare their purity directly on the + // LeekFunctions object via setImpure(); see isImpureSystemFunction below. + private static final Set IMPURE_SYSTEM_FUNCTIONS = Set.of( + // I/O + "debug", "debugW", "debugE", "debugC", + // Array mutators + "push", "pushAll", "pop", "shift", "unshift", "insert", + "remove", "removeElement", "removeKey", "fill", "sort", "arraySort", + "shuffle", "reverse", "arrayClear", "arrayRemoveAll", + "assocSort", "assocReverse", "keySort", + // Map mutators + "mapClear", "mapPut", "mapPutAll", "mapRemove", "mapRemoveAll", + "mapReplace", "mapReplaceAll", "mapFill", "mapMerge", + // Set mutators + "setPut", "setRemove", "setClear" + ); + + /** + * Whether calling the builtin {@code function} introduces a side effect, making + * a {@code @pure} caller impure. A builtin is impure when it has explicitly + * declared itself so ({@link LeekFunctions#isPure()} is false — how host-provided + * functions such as the generator's game actions opt in) or when it is one of the + * standard-library mutators/I/O functions listed in {@link #IMPURE_SYSTEM_FUNCTIONS}. + */ + public static boolean isImpureSystemFunction(LeekFunctions function) { + if (function == null) return false; + if (!function.isPure()) return true; + return IMPURE_SYSTEM_FUNCTIONS.contains(function.getName()); + } + + /** + * If an assignment or increment whose target is {@code target} mutates state + * visible outside {@code pureFunction}, returns the name of the mutated + * symbol; otherwise returns null. Mutating the function's own locals or + * parameters is pure and returns null. + */ + public static String externalMutation(Expression target, AbstractLeekBlock pureFunction) { + var root = rootVariable(target); + if (root == null) return null; + switch (root.getVariableType()) { + case GLOBAL: + case FIELD: + case STATIC_FIELD: + case THIS: + return root.getName(); + case LOCAL: + case ARGUMENT: + case ITERATOR: + // Pure if the variable belongs to this function; impure if it is + // captured from an enclosing scope (incl. top-level variables). + var declaration = root.getDeclaration(); + if (declaration != null && declaration.getFunction() != pureFunction) { + return root.getName(); + } + return null; + default: + return null; + } + } + + /** + * Walks an l-value down to the variable whose state it ultimately mutates: + * {@code arr[i]} → {@code arr}, {@code obj.field} → {@code obj}, etc. Returns + * null when no underlying variable can be determined. + */ + private static LeekVariable rootVariable(Expression e) { + while (true) { + if (e instanceof LeekVariable v) return v; + if (e instanceof LeekParenthesis p) { e = p.getExpression(); continue; } + if (e instanceof LeekArrayAccess a) { e = a.getTabular(); continue; } + if (e instanceof LeekObjectAccess o) { e = o.getObject(); continue; } + if (e instanceof LeekFunctionCall fc) { e = fc.getCallExpression(); continue; } + if (e instanceof LeekExpression x && x.getOperator() == Operators.NON_NULL_ASSERTION) { e = x.getExpression2(); continue; } + return null; + } + } + + public static void reportNotPure(WordCompiler compiler, Location location, String detail) throws LeekCompilerException { + compiler.addError(new AnalyzeError(location, AnalyzeErrorLevel.WARNING, Error.ANNOTATION_NOT_PURE, new String[] { detail })); + } +} diff --git a/src/main/java/leekscript/compiler/WordCompiler.java b/src/main/java/leekscript/compiler/WordCompiler.java index 4a74db39..75aac9d4 100644 --- a/src/main/java/leekscript/compiler/WordCompiler.java +++ b/src/main/java/leekscript/compiler/WordCompiler.java @@ -2,8 +2,11 @@ import java.math.BigInteger; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import leekscript.common.Annotation; import leekscript.common.AccessLevel; @@ -65,6 +68,14 @@ public class WordCompiler { private AbstractLeekBlock mCurrentFunction; private ClassDeclarationInstruction mCurrentClass; private LexicalParserTokenStream mTokens; + + // Purity tracking for @pure verification. Side effects and call edges are + // recorded per function during analyze(), then resolved transitively after + // the whole program is analyzed (a callee may be analyzed after its caller). + private final Set mImpureFunctions = new HashSet<>(); + private final Map> mFunctionCallees = new HashMap<>(); + private final ArrayList mDeferredPureCalls = new ArrayList<>(); + private record PureCall(AbstractLeekBlock callee, Location location) {} private int mLine; private AIFile mAI = null; private final int version; @@ -356,6 +367,7 @@ public void analyze() throws LeekCompilerException { setCurrentFunction(mMain); mMain.preAnalyze(this); mMain.analyze(this); + resolvePurity(); } private void compileWord() throws LeekCompilerException { @@ -2471,6 +2483,71 @@ public AbstractLeekBlock getCurrentFunction() { return mCurrentFunction; } + /** + * Returns the function/method currently being analyzed if it is annotated + * {@code @pure}, otherwise null. Used to verify that the body of a {@code @pure} + * function does not perform side effects. Nested anonymous functions reset the + * current function, so side effects inside a lambda defined within a {@code @pure} + * function are not attributed to it. + */ + public AbstractLeekBlock getCurrentPureFunction() { + if (mCurrentFunction instanceof FunctionBlock fb && fb.hasAnnotation(Annotation.PURE)) return fb; + if (mCurrentFunction instanceof ClassMethodBlock cm && cm.hasAnnotation(Annotation.PURE)) return cm; + return null; + } + + /** Marks the function currently being analyzed as having a direct side effect. */ + public void recordSideEffect() { + mImpureFunctions.add(mCurrentFunction); + } + + /** Records that the function currently being analyzed calls {@code callee}. */ + public void recordCallee(AbstractLeekBlock callee) { + mFunctionCallees.computeIfAbsent(mCurrentFunction, k -> new HashSet<>()).add(callee); + } + + /** + * Defers a purity check for a call made from a {@code @pure} function: after the + * whole program is analyzed, {@code callee} is checked for transitive purity and + * a warning is emitted at {@code location} if it turns out to be impure. + */ + public void recordPureCall(AbstractLeekBlock callee, Location location) { + mDeferredPureCalls.add(new PureCall(callee, location)); + } + + /** + * Resolves deferred {@code @pure} call checks once the full call graph is known. + * A call from a pure function is only flagged when the callee is transitively + * impure, so calling an unannotated-but-actually-pure function is allowed. + */ + private void resolvePurity() throws LeekCompilerException { + if (mDeferredPureCalls.isEmpty()) return; + var memo = new HashMap(); + for (var call : mDeferredPureCalls) { + if (isTransitivelyImpure(call.callee(), memo, new HashSet<>())) { + var name = call.callee() instanceof FunctionBlock fb ? fb.getName() : "?"; + PurityChecker.reportNotPure(this, call.location(), name); + } + } + } + + private boolean isTransitivelyImpure(AbstractLeekBlock f, Map memo, Set visiting) { + var cached = memo.get(f); + if (cached != null) return cached; + if (mImpureFunctions.contains(f)) return true; // direct side effect + if (!visiting.add(f)) return false; // recursion cycle: assume pure + boolean impure = false; + var callees = mFunctionCallees.get(f); + if (callees != null) { + for (var callee : callees) { + if (isTransitivelyImpure(callee, memo, visiting)) { impure = true; break; } + } + } + visiting.remove(f); + memo.put(f, impure); + return impure; + } + public void addError(AnalyzeError analyzeError) throws LeekCompilerException { this.mAI.getErrors().add(analyzeError); if (this.mAI.getErrors().size() > 10000) { diff --git a/src/main/java/leekscript/compiler/expression/LeekExpression.java b/src/main/java/leekscript/compiler/expression/LeekExpression.java index 2f69383c..6127877a 100644 --- a/src/main/java/leekscript/compiler/expression/LeekExpression.java +++ b/src/main/java/leekscript/compiler/expression/LeekExpression.java @@ -2,6 +2,7 @@ import leekscript.compiler.AnalyzeError; import leekscript.compiler.NarrowingInfo; +import leekscript.compiler.PurityChecker; import leekscript.compiler.Token; import leekscript.compiler.JavaWriter; import leekscript.compiler.Location; @@ -1167,6 +1168,13 @@ public void analyze(WordCompiler compiler) throws LeekCompilerException { if (mExpression1 instanceof LeekArrayAccess) ((LeekArrayAccess) mExpression1).setLeftValue(true); + + // @pure : assigning to state outside the function is a side effect + var mutated = PurityChecker.externalMutation(mExpression1, compiler.getCurrentFunction()); + if (mutated != null) { + compiler.recordSideEffect(); + if (compiler.getCurrentPureFunction() != null) PurityChecker.reportNotPure(compiler, getLocation(), mutated); + } } if (Operators.isIncrement(mOperator)) { @@ -1191,6 +1199,13 @@ public void analyze(WordCompiler compiler) throws LeekCompilerException { mExpression2.getType().toString() })); } + + // @pure : incrementing state outside the function is a side effect + var mutated = PurityChecker.externalMutation(mExpression2, compiler.getCurrentFunction()); + if (mutated != null) { + compiler.recordSideEffect(); + if (compiler.getCurrentPureFunction() != null) PurityChecker.reportNotPure(compiler, getLocation(), mutated); + } } if (mOperator == Operators.ASSIGN) { diff --git a/src/main/java/leekscript/compiler/expression/LeekFunctionCall.java b/src/main/java/leekscript/compiler/expression/LeekFunctionCall.java index 2d509afe..02f5ec29 100644 --- a/src/main/java/leekscript/compiler/expression/LeekFunctionCall.java +++ b/src/main/java/leekscript/compiler/expression/LeekFunctionCall.java @@ -10,6 +10,7 @@ import leekscript.compiler.Token; import leekscript.compiler.JavaWriter; import leekscript.compiler.Location; +import leekscript.compiler.PurityChecker; import leekscript.compiler.WordCompiler; import leekscript.compiler.AnalyzeError.AnalyzeErrorLevel; import leekscript.compiler.bloc.ClassMethodBlock; @@ -720,6 +721,24 @@ else if (!exprType.canBeCallable()) { })); } } + + // @pure : record side effects and call edges for purity analysis. Calling a + // side-effecting builtin is a direct side effect; calling another user + // function is resolved transitively after the whole program is analyzed, so + // an unannotated-but-actually-pure callee does not make the caller impure. + if (system_function != null) { + if (PurityChecker.isImpureSystemFunction(system_function)) { + compiler.recordSideEffect(); + if (compiler.getCurrentPureFunction() != null) { + PurityChecker.reportNotPure(compiler, getLocation(), system_function.getName()); + } + } + } else if (resolvedFunction != null) { + compiler.recordCallee(resolvedFunction); + if (compiler.getCurrentPureFunction() != null) { + compiler.recordPureCall(resolvedFunction, getLocation()); + } + } } private void verifySystemFunction(WordCompiler compiler, LeekVariable v, LeekFunctions f) throws LeekCompilerException { diff --git a/src/main/java/leekscript/runner/LeekFunctions.java b/src/main/java/leekscript/runner/LeekFunctions.java index 630e6dd7..76c0a45e 100644 --- a/src/main/java/leekscript/runner/LeekFunctions.java +++ b/src/main/java/leekscript/runner/LeekFunctions.java @@ -421,6 +421,12 @@ private static LeekFunctions method(String name, String clazz, int operations, b private int minVersion = 1; private int maxVersion = LeekScript.LATEST_VERSION; private String replacement = null; + // Whether calling this builtin produces no observable side effect. Consulted by + // the @pure annotation check. Defaults to true (most builtins only compute or + // read state); side-effecting builtins (I/O, in-place mutators, and — for the + // generator — game actions such as moveToward/useChip/say) opt out via + // setImpure(). + private boolean pure = true; public LeekFunctions(String standardClass, String name, int operations, boolean isStatic, Type return_type, Type[] arguments) { this(standardClass, name, operations, isStatic, new CallableVersion[] { new CallableVersion(return_type, arguments) }); @@ -543,6 +549,23 @@ public void addOperations(AI leekIA, LeekFunctions function, Object parameters[] public boolean isStatic() { return isStatic; } + + /** Whether this builtin is free of observable side effects (see {@link #pure}). */ + public boolean isPure() { + return pure; + } + + /** + * Declares this builtin as having an observable side effect (I/O, in-place + * mutation of an argument, or — for generator/extra functions — an action on the + * fight state). Calling it from a {@code @pure} function is then reported. + * Fluent so it can be chained on a {@code method(...)} registration. + */ + public LeekFunctions setImpure() { + this.pure = false; + return this; + } + public String getName() { return name; } diff --git a/src/test/java/test/TestAnnotations.java b/src/test/java/test/TestAnnotations.java index 9b85f018..0584cec2 100644 --- a/src/test/java/test/TestAnnotations.java +++ b/src/test/java/test/TestAnnotations.java @@ -66,6 +66,67 @@ public void testPure() throws Exception { // @pure is visible by @unused so both can be combined code_strict_v4_("@pure @unused function helper() { return 0; } return 0").noWarning(); + + // --- Purity checking --- + + // Pure function using only its own locals/parameters → no warning + code_v4_("@pure function f() { var local = 0; local = 1; return local } return f()").noWarning(); + code_v4_("@pure function f() { var local = 0; local = 1; return local } return f()").equals("1"); + + // Mutating a local array/map of its own is pure + code_v4_("@pure function f() { var a = [1, 2]; a[0] = 5; return a } return f()").noWarning(); + + // Writing to a global is a side effect → not pure + code_v4_("global g = 0; @pure function f() { g = 1; return g } return f()").warning(Error.ANNOTATION_NOT_PURE); + + // Incrementing a global is a side effect → not pure + code_v4_("global g = 0; @pure function f() { g++; return g } return f()").warning(Error.ANNOTATION_NOT_PURE); + + // Calling a side-effecting builtin (I/O) → not pure + code_v4_("@pure function f(a) { debug(a); return a } return f(1)").warning(Error.ANNOTATION_NOT_PURE); + + // Calling an array-mutating builtin → not pure + code_v4_("@pure function f(arr) { push(arr, 1); return arr } return f([])").warning(Error.ANNOTATION_NOT_PURE); + + // Calling a pure builtin (no mutation) → still pure + code_v4_("@pure function f(a) { return abs(a) } return f(-3)").noWarning(); + code_v4_("@pure function f(a) { return abs(a) } return f(-3)").equals("3"); + + // Calling another @pure function is fine + code_v4_("@pure function helper(x) { return x * 2 } @pure function f(x) { return helper(x) } return f(2)").noWarning(); + code_v4_("@pure function helper(x) { return x * 2 } @pure function f(x) { return helper(x) } return f(2)").equals("4"); + + // Calling an unannotated but actually-pure function is fine: purity is verified + // transitively, not by requiring the @pure annotation everywhere + code_v4_("function helper(x) { return x * 2 } @pure function f(x) { return helper(x) } return f(2)").noWarning(); + code_v4_("function helper(x) { return x * 2 } @pure function f(x) { return helper(x) } return f(2)").equals("4"); + + // ...even through a chain of unannotated pure functions + code_v4_("function a(x) { return b(x) } function b(x) { return x + 1 } @pure function f(x) { return a(x) } return f(1)").noWarning(); + code_v4_("function a(x) { return b(x) } function b(x) { return x + 1 } @pure function f(x) { return a(x) } return f(1)").equals("2"); + + // Calling an unannotated function that IS impure → warning + code_v4_("global g = 0; function bump() { g = 1; return g } @pure function f() { return bump() } return f()").warning(Error.ANNOTATION_NOT_PURE); + + // ...including impurity reached transitively through another function + code_v4_("function a() { return b() } function b() { debug(1); return 0 } @pure function f() { return a() } return f()").warning(Error.ANNOTATION_NOT_PURE); + + // Recursion on a @pure function is allowed (it calls itself, which is @pure) + code_v4_("@pure function fact(n) { return n <= 1 ? 1 : n * fact(n - 1) } return fact(5)").noWarning(); + code_v4_("@pure function fact(n) { return n <= 1 ? 1 : n * fact(n - 1) } return fact(5)").equals("120"); + + // Side effects inside a lambda defined within a @pure function are NOT attributed + // to it (the lambda is a separate scope): defining it is pure. + code_v4_("global g = 0; @pure function f() { var fn = function() { g = 1 }; return 0 } return f()").noWarning(); + + // @pure on a method: mutating the object (this) is a side effect → not pure + code_v4_(""" + class A { + public integer x = 0 + @pure setX() { this.x = 1; return this.x } + } + return new A().setX() + """).warning(Error.ANNOTATION_NOT_PURE); } @Test