Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/leekscript/common/Error.java
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,5 @@ public enum Error {
ANNOTATION_TODO, // 157
ANNOTATION_OVERRIDE_NO_PARENT, // 158
OVERRIDDEN_METHOD_NARROWER_VISIBILITY, // 159
ANNOTATION_NOT_PURE, // 160
}
128 changes: 128 additions & 0 deletions src/main/java/leekscript/compiler/PurityChecker.java
Original file line number Diff line number Diff line change
@@ -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:
* <ul>
* <li>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);</li>
* <li>calls a built-in system function that mutates its arguments or performs
* I/O (see {@link #IMPURE_SYSTEM_FUNCTIONS});</li>
* <li>calls another user function that is not itself {@code @pure}.</li>
* </ul>
* 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<String> 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 }));
}
}
77 changes: 77 additions & 0 deletions src/main/java/leekscript/compiler/WordCompiler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AbstractLeekBlock> mImpureFunctions = new HashSet<>();
private final Map<AbstractLeekBlock, Set<AbstractLeekBlock>> mFunctionCallees = new HashMap<>();
private final ArrayList<PureCall> mDeferredPureCalls = new ArrayList<>();
private record PureCall(AbstractLeekBlock callee, Location location) {}
private int mLine;
private AIFile mAI = null;
private final int version;
Expand Down Expand Up @@ -356,6 +367,7 @@ public void analyze() throws LeekCompilerException {
setCurrentFunction(mMain);
mMain.preAnalyze(this);
mMain.analyze(this);
resolvePurity();
}

private void compileWord() throws LeekCompilerException {
Expand Down Expand Up @@ -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<AbstractLeekBlock, Boolean>();
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<AbstractLeekBlock, Boolean> memo, Set<AbstractLeekBlock> 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) {
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/leekscript/compiler/expression/LeekExpression.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/leekscript/compiler/expression/LeekFunctionCall.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/leekscript/runner/LeekFunctions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading