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:
+ *
+ * - assigns to / increments a variable that lives outside the function:
+ * a global, an object field, a static field, {@code this}, or a variable
+ * captured from an enclosing scope (mutating the function's own locals and
+ * parameters is pure);
+ * - calls a built-in system function that mutates its arguments or performs
+ * I/O (see {@link #IMPURE_SYSTEM_FUNCTIONS});
+ * - calls another user function that is not itself {@code @pure}.
+ *
+ * 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