Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,9 @@ of save formats, `ChunkedString`, hashes, or `Serializable`.
* Do not promise Lua support for a method which combines type parameters from its owning generic class with
independent method type parameters. Serialization loaders should be free generic functions, or class methods
parameterized only by their owning class.
* Do not require Lua specialization of generic-construction methods invoked directly on a freshly constructed
generic receiver. Use the free generic loader shape, or bind the receiver to a typed local first.
* A method invoked directly on a freshly constructed generic receiver is supported on Lua. The receiver's
declared type is still generic at that point, so specialization takes the instantiation from the construction.
Binding the receiver to a typed local first is no longer required.
* Lua generic-construction dispatch through multi-parameter generic interfaces is outside the supported loader
shape. The supported generic loader has a single construction type parameter.
* Do not call `wurstNewInstance<T>()` from the constructor of a generic class. Construct the simple state object in the
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
## 1.9 (in progress)

- Added type class bounds for `T:` generics. A bound requires operations of the type it is bound to, so a
generic can do more than store and return values, without giving up static dispatch:

public interface Indexable<T:>
function toIndex(T x) returns int
function fromIndex(int i) returns T

implements Indexable<vec2>
function toIndex(vec2 v) returns int
...
function fromIndex(int i) returns vec2
...

class HashMap<K: Indexable, V: Indexable>
function get(K key) returns V
return V.fromIndex(loadInt(K.toIndex(key)))

A bound names the interface unapplied, so `<K: Indexable>` means "there is an instance of `Indexable<K>`",
and several combine with `and`. Requirements are called on the type parameter (`K.toIndex(key)`), which
keeps operations that produce a value of the type, such as `fromIndex`, in the same form as the rest.

Unlike an interface used as a supertype, a bound is satisfiable by `int`, `real`, `string`, tuples and
handle types, and costs nothing at runtime: after specialisation each requirement is a direct call to the
instance function, on both Jass and Lua.

An instance of `I` for type `X` may only be declared in the package declaring `I` or the one declaring
`X`, and only once, so `I` for `X` means the same thing throughout a program regardless of imports.

- Added new pseudo-natives for debugging memory leaks:

// returns the maximum type id, can be usd to
Expand Down
5 changes: 4 additions & 1 deletion de.peeeq.wurstscript/parserspec/wurstscript.parseq
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ WEntity =
| ModuleDef(@ignoreForEquality de.peeeq.wurstscript.parser.WPos source, Modifiers modifiers, Identifier nameId, TypeParamDefs typeParameters,
ClassDefs innerClasses, FuncDefs methods, GlobalVarDefs vars, ConstructorDefs constructors,
ModuleInstanciations p_moduleInstanciations, ModuleUses moduleUses, OnDestroyDef onDestroy)
// A type class instance: binds one interface to one concrete type.
| InstanceDecl(@ignoreForEquality de.peeeq.wurstscript.parser.WPos source, Modifiers modifiers, TypeExpr implementedInterface, FuncDefs methods)



Expand Down Expand Up @@ -287,6 +289,7 @@ WScope =
| WBlock
| WEntities
| ExprClosure
| InstanceDecl

PackageOrGlobal = WPackage | CompilationUnit

Expand Down Expand Up @@ -315,7 +318,7 @@ Modifier =

// ElementWithBody = FunctionImplementation | InitBlock | ConstructorDef | OnDestroyDef
//ElementWithModifier = NameDef | TypeDef | ModuleDef | ConstructorDef | GlobalVarDef | FunctionDefinition
HasModifier = NameDef | TypeDef | ModuleDef | ConstructorDef | GlobalVarDef | FunctionDefinition
HasModifier = NameDef | TypeDef | ModuleDef | ConstructorDef | GlobalVarDef | FunctionDefinition | InstanceDecl
HasTypeArgs = ExprNewObject | FunctionCall | ModuleUse | StmtCall | TypeExprSimple

AstElementWithFuncName = ExprFunctionCall | ExprMemberMethod | ExprFuncRef
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ entity:
| interfaceDef
| tupleDef
| extensionFuncDef
| instanceDef
;

interfaceDef:
Expand All @@ -132,11 +133,16 @@ classDef:
ENDBLOCK)?
;

typeclassDef:
modifiersWithDoc 'typeclass' name=ID typeParams
('extends' implemented+=typeExpr (',' implemented+=typeExpr)*)?
// A type class instance: binds an interface to one concrete type, e.g.
// implements Indexable<vec2>
// function toIndex(vec2 v) returns int
// ...
// This reuses the existing 'implements' keyword deliberately. Introducing a new one would
// reserve a plausible identifier ('instance' is used as a local in the standard library).
instanceDef:
modifiersWithDoc 'implements' implemented=typeExpr
NL (STARTBLOCK
classSlots
methods+=funcDef*
ENDBLOCK)?
;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -877,8 +877,11 @@ public LuaCompilationUnit transformProgToLua() {

ImAttrType.setWurstClassType(null);
int stage;
if (containsGenericNewCall()) {
beginPhase(2, "Specialize generics for generic construction");
if (containsGenericNewCall() || containsTypeClassDispatch()) {
// Both operations need the concrete type argument, which erasure does not keep. Only
// the paths reaching them are specialised: the full elimination used for Jass is
// followed there by class elimination, and leaves state this backend cannot consume.
beginPhase(2, "Specialize generics for generic construction and type class dispatch");
new EliminateGenerics(getImTranslator(), getImProg()).transformGenericNewOnly();
timeTaker.endPhase();
}
Expand Down Expand Up @@ -969,6 +972,7 @@ public LuaCompilationUnit transformProgToLua() {
return luaCode;
}

/** Whether the program constructs a value of a type parameter, which needs its concrete type. */
private boolean containsGenericNewCall() {
boolean[] found = {false};
getImProg().accept(new de.peeeq.wurstscript.jassIm.Element.DefaultVisitor() {
Expand All @@ -983,4 +987,16 @@ public void visit(ImFunctionCall call) {
});
return found[0];
}

/** Whether the program dispatches on a type class bound anywhere. */
private boolean containsTypeClassDispatch() {
boolean[] found = {false};
getImProg().accept(new de.peeeq.wurstscript.jassIm.Element.DefaultVisitor() {
@Override
public void visit(ImTypeVarDispatch dispatch) {
found[0] = true;
}
});
return found[0];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ public void case_ClassDef(ClassDef classDef) {
case_ClassOrModule(classDef);
}

@Override
public void case_InstanceDecl(InstanceDecl instanceDecl) {
List<DocumentSymbol> children = new ArrayList<>();
add("implements " + instanceDecl.getImplementedInterface(), SymbolKind.Object, children);
for (FuncDef f : instanceDecl.getMethods()) {
addSymbolsForEntity(children, f);
}
}

@Override
public void case_InterfaceDef(InterfaceDef interfaceDef) {
String name = interfaceDef.getName();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import de.peeeq.wurstscript.WLogger;
import de.peeeq.wurstscript.ast.*;
import de.peeeq.wurstscript.attributes.AttrWurstDoc;
import de.peeeq.wurstscript.attributes.DescriptionHtml;
import de.peeeq.wurstscript.attributes.names.FuncLink;
import de.peeeq.wurstscript.attributes.names.NameLink;
import de.peeeq.wurstscript.parser.TriviaIndex;
Expand Down Expand Up @@ -403,6 +404,11 @@ public List<Either<String, MarkedString>> case_InterfaceDef(InterfaceDef interfa
return description(interfaceDef);
}

@Override
public List<Either<String, MarkedString>> case_InstanceDecl(InstanceDecl instanceDecl) {
return string(DescriptionHtml.description(instanceDecl));
}

@Override
public List<Either<String, MarkedString>> case_VisibilityProtected(VisibilityProtected visibilityProtected) {
return string("protected: can be used in subclasses and in the same package");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ public void case_ClassDef(ClassDef classDef) {
}
}

@Override
public void case_InstanceDecl(InstanceDecl instanceDecl) {
String name = "implements " + instanceDecl.getImplementedInterface();
add(name, SymbolKind.Object);
for (FuncDef f : instanceDecl.getMethods()) {
addSymbolsForEntity(result, containerName + "." + name, f);
}
}

@Override
public void case_InterfaceDef(InterfaceDef interfaceDef) {
String name = interfaceDef.getName();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import de.peeeq.wurstscript.attributes.names.NameLink;
import de.peeeq.wurstscript.attributes.names.OtherLink;
import de.peeeq.wurstscript.types.WurstType;
import de.peeeq.wurstscript.types.WurstTypeTypeParam;
import org.eclipse.jdt.annotation.Nullable;

public class AttrImplicitParameter {
Expand Down Expand Up @@ -86,6 +87,21 @@ private static OptExpr getImplicitParameterCaseNormalFunctionCall(FunctionCall e
return getFunctionCallImplicitParameter(e, calledFunc, true);
}

/**
* True for a call whose receiver is a bounded type parameter standing for itself, as in
* {@code T.toIndex(x)}. Such a call resolves through the type class instance chosen for T and
* therefore takes no implicit {@code this}.
*/
public static boolean isTypeClassDispatch(Element e) {
if (!(e instanceof HasReceiver hasReceiver)) {
return false;
}
Expr left = hasReceiver.getLeft();
return left != null
&& left.attrTyp() instanceof WurstTypeTypeParam tp
&& tp.isStaticRef();
}

static OptExpr getFunctionCallImplicitParameter(FunctionCall e, FuncLink calledFunc, boolean showError) {
if (e instanceof HasReceiver) {
HasReceiver hasReceiver = (HasReceiver) e;
Expand All @@ -100,6 +116,11 @@ static OptExpr getFunctionCallImplicitParameter(FunctionCall e, FuncLink calledF
if (calledFunc == null) {
return Ast.NoExpr();
}
if (isTypeClassDispatch(e)) {
// T.f(x): the bound supplies the implementation and the value is an ordinary
// argument, so there is no receiver to pass even though f is declared as a method.
return Ast.NoExpr();
}
if (calledFunc.getDef().attrIsDynamicClassMember()) {
// dynamic function call
if (e.attrIsDynamicContext()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
import de.peeeq.wurstscript.attributes.names.NameLink;
import de.peeeq.wurstscript.attributes.names.OtherLink;
import de.peeeq.wurstscript.attributes.names.Visibility;
import de.peeeq.wurstscript.jassIm.ImFunction;
import de.peeeq.wurstscript.translation.imtranslation.ImTranslator;
import de.peeeq.wurstscript.types.TypeClassConstraints;
import de.peeeq.wurstscript.types.WurstType;
import de.peeeq.wurstscript.types.WurstTypeClassOrInterface;
import de.peeeq.wurstscript.types.WurstTypeTypeParam;
import de.peeeq.wurstscript.types.WurstTypeUnknown;
import de.peeeq.wurstscript.types.WurstTypeEnum;
import de.peeeq.wurstscript.types.WurstTypeModule;
Expand Down Expand Up @@ -79,6 +83,16 @@ public static NameLink calculate(ExprMemberVar term) {
protected static NameLink searchNameInScope(String varName, NameRef node) {
boolean showErrors = !varName.startsWith("gg_");
if (!"it".equals(varName)) {
// A bounded type parameter can stand in receiver position, as in T.toIndex(x). The
// syntactic test comes first so ordinary lookups keep their original cost, and the
// probe below uses the cached form. Anything else falls through to the normal lookup,
// which must stay the last word so that it still reports ambiguity and unknown names.
if (isMethodCallReceiver(node) && node.lookupVar(varName, false) == null) {
NameLink typeParamRef = lookupBoundedTypeParam(varName, node);
if (typeParamRef != null) {
return typeParamRef;
}
}
return node.lookupVar(varName, showErrors);
}

Expand All @@ -105,6 +119,33 @@ protected static NameLink searchNameInScope(String varName, NameRef node) {
return node.lookupVar(varName, true);
}

/** True when this reference is the receiver of a method call, as {@code T} is in {@code T.f(x)}. */
private static boolean isMethodCallReceiver(NameRef node) {
return node.getParent() instanceof ExprMemberMethod call && call.getLeft() == node;
}

/**
* Resolves a name which refers to a type parameter carrying type class bounds, so that the
* parameter can be used as the receiver of a required method: {@code T.toIndex(x)}.
* <p>
* A bare type parameter is not a value, so this is deliberately limited to receiver position;
* everywhere else the ordinary "unknown variable" error is the right answer.
*/
private static @Nullable NameLink lookupBoundedTypeParam(String varName, NameRef node) {
TypeDef typeDef = node.lookupType(varName, false);
if (!(typeDef instanceof TypeParamDef tp) || !TypeClassConstraints.hasBounds(tp)) {
return null;
Comment on lines +135 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve bounded module parameters through expansion

When a generic module declares a bound such as module M<T: Show> and a class uses M<int>, any T.show(x) inside the module method is re-resolved in the copied module-instantiation scope; node.lookupType("T") here returns null, so the compiler emits Could not find variable T before either backend. This makes type-class bounds unusable with existing generic modules; preserve the original bound/type-parameter link or rewrite the type receiver during module expansion.

AGENTS.md reference: AGENTS.md:L217-L221

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and addressed in 50b8413, though not by supporting it. The cause is as you say: using a module copies its body into the class and substitutes the module's type parameters in type positions, but a requirement is called on the parameter as an expression, so it survives the copy and no longer resolves.

Supporting it needs either the copy to rewrite those receivers, or type parameters on ModuleInstanciation, which has none — a parseq change with the matcher fallout that implies. That is more than this change should carry, so bounds on a module type parameter are now rejected with a message that says what is wrong and what to do instead, rather than failing later as an unknown name. Covered by boundOnGenericModule.

Worth doing properly as a follow-up: modules are common enough in container code that the combination will come up.

}
WurstTypeTypeParam typ = new WurstTypeTypeParam(tp).asStaticRef();
return new OtherLink(Visibility.LOCAL, varName, typ) {
@Override
public de.peeeq.wurstscript.jassIm.ImExpr translate(NameRef e, ImTranslator t, ImFunction f) {
throw new CompileError(e.attrSource(),
"Type parameter " + varName + " is not a value; it can only be used to call a method required by its bounds.");
}
};
}

private static @Nullable NameLink lookupImplicitClosureSelf(NameRef node, boolean showErrors) {
ExprClosure closure = node.attrNearestExprClosure();
if (closure == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ public static String description(InitBlock initBlock) {
return "An init block: This block is executed at map start";
}

public static String description(InstanceDecl instanceDecl) {
return "A type class instance for " + instanceDecl.getImplementedInterface()
+ ": it lets this type be used where that interface is required as a type bound.";
}

public static @Nullable String description(
IdentifierWithTypeParamDefs identifierWithTypeParamDefs) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static Modifiers get(HasModifier h) {
case TupleDef t -> t.getModifiers();
case ExtensionFuncDef e -> e.getModifiers();
case TypeParamDef tp -> tp.getModifiers();
case InstanceDecl id -> id.getModifiers();

// If HasModifier ever expands, the compiler will force you to handle new cases here.
case EnumDef enumDef -> enumDef.getModifiers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ public static ImmutableList<NameDef> calculate(ClassDef e) {
return generic(e);
}

public static ImmutableList<NameDef> calculate(InstanceDecl e) {
return generic(e);
}

public static ImmutableList<NameDef> calculate(CompilationUnit e) {
return generic(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ public FuncLink withTypeArgBinding(Element context, VariableBinding binding) {
}
}

/**
* Re-points this link at a different receiver type.
* <p>
* Used for type class dispatch: a requirement is declared as a method of the interface, but a
* bound exposes it on the constrained type parameter itself, so {@code T.f(x)} resolves with
* {@code T} as the receiver rather than an interface instance.
*/
public FuncLink withReceiverType(@Nullable WurstType newReceiverType) {
if (newReceiverType == getReceiverType()) {
return this;
}
return new FuncLink(getVisibility(), getDefinedIn(), getTypeParams(), newReceiverType, def,
parameterNames, parameterTypes, returnType, mapping);
}

@Override
public DefLink withGenericTypeParams(List<TypeParamDef> typeParams) {
if (typeParams.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ private static void reportOverrideErrors(Map<String, Map<FuncLink, OverrideCheck
}
}

/**
* A type class instance only defines the functions written in its body. The instance methods
* are not inherited by anything, so no super-scope merging is required here.
*/
public static ImmutableMultimap<String, DefLink> calculate(InstanceDecl i) {
Multimap<String, DefLink> result = HashMultimap.create();
addDefinedNames(result, i, i.getMethods());
return ImmutableMultimap.copyOf(result);
}

public static ImmutableMultimap<String, DefLink> calculate(InterfaceDef i) {
Multimap<String, DefLink> result = HashMultimap.create();
addDefinedNames(result, i, i.getMethods());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public static ImmutableMultimap<String, TypeLink> calculate(EnumDef e) {
return ImmutableMultimap.of();
}

/** v1 type class instances have no type parameters of their own. */
public static ImmutableMultimap<String, TypeLink> calculate(InstanceDecl i) {
return ImmutableMultimap.of();
}

public static ImmutableMultimap<String, TypeLink> calculate(InterfaceDef i) {
ImmutableMultimap.Builder<String, TypeLink> result = ImmutableSetMultimap.builder();
addTypeParametersIfAny(result, i);
Expand Down
Loading
Loading