+
+
+
+ src/test/resources
+
+
+
+ src/test/R
+
+
@@ -257,6 +267,11 @@
maven-surefire-plugin
3.2.2
+
+
+ all
+ 8
+
diff --git a/src/main/java/org/prlprg/RVersion.java b/src/main/java/org/prlprg/RVersion.java
new file mode 100644
index 000000000..0cb3d1d0c
--- /dev/null
+++ b/src/main/java/org/prlprg/RVersion.java
@@ -0,0 +1,52 @@
+package org.prlprg;
+
+import javax.annotation.Nullable;
+
+/**
+ * Major.Minor.Patch version number with an optional suffix for things like "Beta" and "RC".
+ *
+ * This class needs to support whatever format GNU-R versions can have. But it's not a GNU-R
+ * specific
+ */
+public record RVersion(int major, int minor, int patch, @Nullable String suffix) {
+ /** The latest version we handle. */
+ public static final RVersion LATEST_AWARE = new RVersion(4, 3, 2);
+
+ public static RVersion parse(String textual) {
+ var parts = textual.split("\\.");
+
+ int major;
+ int minor;
+ int patch;
+ try {
+ major = Integer.parseInt(parts[0]);
+ minor = Integer.parseInt(parts[1]);
+ patch = Integer.parseInt(parts[2]);
+ } catch (ArrayIndexOutOfBoundsException e) {
+ throw new IllegalArgumentException("Invalid version number: " + textual, e);
+ }
+
+ String suffix;
+ if (parts.length > 3) {
+ if (parts[3].startsWith("-")) {
+ suffix = parts[3].substring(1);
+ } else {
+ throw new IllegalArgumentException(
+ "Invalid version number: " + textual + " (suffix must start with '-')");
+ }
+ } else {
+ suffix = null;
+ }
+
+ return new RVersion(major, minor, patch, suffix);
+ }
+
+ RVersion(int major, int minor, int patch) {
+ this(major, minor, patch, null);
+ }
+
+ @Override
+ public String toString() {
+ return major + "." + minor + "." + patch + (suffix == null ? "" : "-" + suffix);
+ }
+}
diff --git a/src/main/java/org/prlprg/bc/BcCode.java b/src/main/java/org/prlprg/bc/BcCode.java
index eae3f307d..ddefec229 100644
--- a/src/main/java/org/prlprg/bc/BcCode.java
+++ b/src/main/java/org/prlprg/bc/BcCode.java
@@ -32,13 +32,54 @@ protected List delegate() {
*/
static BcCode fromRaw(ImmutableIntArray bytecodes, ConstPool.MakeIdx makePoolIdx)
throws BcFromRawException {
+ if (bytecodes.isEmpty()) {
+ throw new BcFromRawException("Bytecode is empty, needs at least version number");
+ }
+ if (bytecodes.get(0) != Bc.R_BC_VERSION) {
+ throw new BcFromRawException("Unsupported bytecode version: " + bytecodes.get(0));
+ }
+
+ var labelMap = labelFactoryFromRaw(bytecodes);
+
var builder = new Builder();
- int i = 0;
+ int i = 1;
+ int sanityCheckJ = 0;
while (i < bytecodes.length()) {
try {
- var instrAndI = BcInstrs.fromRaw(bytecodes, i, makePoolIdx);
- builder.add(instrAndI.a());
+ var instrAndI = BcInstrs.fromRaw(bytecodes, i, labelMap, makePoolIdx);
+ var instr = instrAndI.a();
i = instrAndI.b();
+
+ builder.add(instr);
+ sanityCheckJ++;
+
+ try {
+ var sanityCheckJFromI = labelMap.make(i).target;
+ if (sanityCheckJFromI != sanityCheckJ) {
+ throw new AssertionError(
+ "expected target offset " + sanityCheckJ + ", got " + sanityCheckJFromI);
+ }
+ } catch (IllegalArgumentException | AssertionError e) {
+ throw new AssertionError(
+ "BcInstrs.fromRaw and BcInstrs.sizeFromRaw are out of sync, at instruction " + instr,
+ e);
+ }
+ } catch (BcFromRawException e) {
+ throw new BcFromRawException(
+ "malformed bytecode at " + i + "\nBytecode up to this point: " + builder.build(), e);
+ }
+ }
+ return builder.build();
+ }
+
+ static BcLabel.Factory labelFactoryFromRaw(ImmutableIntArray bytecodes) {
+ var builder = new BcLabel.Factory.Builder();
+ int i = 1;
+ while (i < bytecodes.length()) {
+ try {
+ var size = BcInstrs.sizeFromRaw(bytecodes, i);
+ builder.step(size, 1);
+ i += size;
} catch (BcFromRawException e) {
throw new BcFromRawException(
"malformed bytecode at " + i + "\nBytecode up to this point: " + builder.build(), e);
diff --git a/src/main/java/org/prlprg/bc/BcInstr.java b/src/main/java/org/prlprg/bc/BcInstr.java
index 8e3e0e7c2..8071d98fa 100644
--- a/src/main/java/org/prlprg/bc/BcInstr.java
+++ b/src/main/java/org/prlprg/bc/BcInstr.java
@@ -944,21 +944,21 @@ class BcInstrs {
/**
* Create from the raw GNU-R representation.
*
- * @param bytecodes The full list of instruction bytecodes including ones before and after this
- * one
- * @param i The index in the list where this instruction starts
- * @param makePoolIdx A function to create pool indices from raw integers
- * @return The instruction and the index in the list where the next instruction starts
+ * @param bytecodes The full list of GNU-R bytecodes including ones before and after this one.
+ * @param i The index in the list where this instruction starts.
+ * @param Label So we can create labels from GNU-R labels.
+ * @param makePoolIdx A function to create pool indices from raw integers.
+ * @return The instruction and the index in the list where the next instruction starts.
* @apiNote This has to be in a separate class because it's package-private but interface methods
* are public.
*/
static Pair fromRaw(
- ImmutableIntArray bytecodes, int i, ConstPool.MakeIdx makePoolIdx) {
+ ImmutableIntArray bytecodes, int i, BcLabel.Factory Label, ConstPool.MakeIdx makePoolIdx) {
BcOp op;
try {
op = BcOp.valueOf(bytecodes.get(i++));
} catch (IllegalArgumentException e) {
- throw new BcFromRawException("invalid opcode (instruction) " + bytecodes.get(i - 1));
+ throw new BcFromRawException("invalid opcode (instruction) at " + bytecodes.get(i - 1));
}
try {
@@ -967,16 +967,15 @@ static Pair fromRaw(
case BCMISMATCH ->
throw new BcFromRawException("invalid opcode " + BcOp.BCMISMATCH.value());
case RETURN -> new BcInstr.Return();
- case GOTO -> new BcInstr.Goto(new BcLabel(bytecodes.get(i++)));
+ case GOTO -> new BcInstr.Goto(Label.make(bytecodes.get(i++)));
case BRIFNOT ->
new BcInstr.BrIfNot(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case POP -> new BcInstr.Pop();
case DUP -> new BcInstr.Dup();
case PRINTVALUE -> new BcInstr.PrintValue();
case STARTLOOPCNTXT ->
- new BcInstr.StartLoopCntxt(
- bytecodes.get(i++) != 0, new BcLabel(bytecodes.get(i++)));
+ new BcInstr.StartLoopCntxt(bytecodes.get(i++) != 0, Label.make(bytecodes.get(i++)));
case ENDLOOPCNTXT -> new BcInstr.EndLoopCntxt(bytecodes.get(i++) != 0);
case DOLOOPNEXT -> new BcInstr.DoLoopNext();
case DOLOOPBREAK -> new BcInstr.DoLoopBreak();
@@ -984,8 +983,8 @@ static Pair fromRaw(
new BcInstr.StartFor(
makePoolIdx.lang(bytecodes.get(i++)),
makePoolIdx.sym(bytecodes.get(i++)),
- new BcLabel(bytecodes.get(i++)));
- case STEPFOR -> new BcInstr.StepFor(new BcLabel(bytecodes.get(i++)));
+ Label.make(bytecodes.get(i++)));
+ case STEPFOR -> new BcInstr.StepFor(Label.make(bytecodes.get(i++)));
case ENDFOR -> new BcInstr.EndFor();
case SETLOOPVAL -> new BcInstr.SetLoopVal();
case INVISIBLE -> new BcInstr.Invisible();
@@ -1039,23 +1038,23 @@ static Pair fromRaw(
case ENDASSIGN -> new BcInstr.EndAssign(makePoolIdx.sym(bytecodes.get(i++)));
case STARTSUBSET ->
new BcInstr.StartSubset(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case DFLTSUBSET -> new BcInstr.DfltSubset();
case STARTSUBASSIGN ->
new BcInstr.StartSubassign(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case DFLTSUBASSIGN -> new BcInstr.DfltSubassign();
case STARTC ->
new BcInstr.StartC(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case DFLTC -> new BcInstr.DfltC();
case STARTSUBSET2 ->
new BcInstr.StartSubset2(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case DFLTSUBSET2 -> new BcInstr.DfltSubset2();
case STARTSUBASSIGN2 ->
new BcInstr.StartSubassign2(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case DFLTSUBASSIGN2 -> new BcInstr.DfltSubassign2();
case DOLLAR ->
new BcInstr.Dollar(
@@ -1080,11 +1079,11 @@ static Pair fromRaw(
new BcInstr.MatSubassign(makePoolIdx.langOrNegative(bytecodes.get(i++)));
case AND1ST ->
new BcInstr.And1st(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case AND2ND -> new BcInstr.And2nd(makePoolIdx.lang(bytecodes.get(i++)));
case OR1ST ->
new BcInstr.Or1st(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case OR2ND -> new BcInstr.Or2nd(makePoolIdx.lang(bytecodes.get(i++)));
case GETVAR_MISSOK -> new BcInstr.GetVarMissOk(makePoolIdx.sym(bytecodes.get(i++)));
case DDVAL_MISSOK -> new BcInstr.DdValMissOk(makePoolIdx.sym(bytecodes.get(i++)));
@@ -1107,10 +1106,10 @@ static Pair fromRaw(
case RETURNJMP -> new BcInstr.ReturnJmp();
case STARTSUBSET_N ->
new BcInstr.StartSubsetN(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case STARTSUBASSIGN_N ->
new BcInstr.StartSubassignN(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case VECSUBSET2 ->
new BcInstr.VecSubset2(makePoolIdx.langOrNegative(bytecodes.get(i++)));
case MATSUBSET2 ->
@@ -1121,10 +1120,10 @@ static Pair fromRaw(
new BcInstr.MatSubassign2(makePoolIdx.langOrNegative(bytecodes.get(i++)));
case STARTSUBSET2_N ->
new BcInstr.StartSubset2N(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case STARTSUBASSIGN2_N ->
new BcInstr.StartSubassign2N(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case SUBSET_N ->
new BcInstr.SubsetN(
makePoolIdx.langOrNegative(bytecodes.get(i++)), bytecodes.get(i++));
@@ -1148,7 +1147,7 @@ static Pair fromRaw(
case SEQLEN -> new BcInstr.SeqLen(makePoolIdx.lang(bytecodes.get(i++)));
case BASEGUARD ->
new BcInstr.BaseGuard(
- makePoolIdx.lang(bytecodes.get(i++)), new BcLabel(bytecodes.get(i++)));
+ makePoolIdx.lang(bytecodes.get(i++)), Label.make(bytecodes.get(i++)));
case INCLNK -> new BcInstr.IncLnk();
case DECLNK -> new BcInstr.DecLnk();
case DECLNK_N -> new BcInstr.DeclnkN(bytecodes.get(i++));
@@ -1156,6 +1155,167 @@ static Pair fromRaw(
case DECLNKSTK -> new BcInstr.DecLnkStk();
};
return new Pair<>(instr, i);
+ } catch (IllegalArgumentException e) {
+ throw new BcFromRawException("invalid opcode " + op + " (arguments)", e);
+ } catch (ArrayIndexOutOfBoundsException e) {
+ throw new BcFromRawException(
+ "invalid opcode " + op + " (arguments, unexpected end of bytecode stream)");
+ }
+ }
+
+ /**
+ * Get the GNU-R size of the instruction at the position without creating it.
+ *
+ * @param bytecodes The full list of GNU-R bytecodes including ones before and after this one.
+ * @param i The index in the list where this instruction starts.
+ * @return The size of the instruction (we don't return next position because it can be computed
+ * from this).
+ * @apiNote This has to be in a separate class because it's package-private but interface methods
+ * are public.
+ */
+ @SuppressWarnings({"DuplicateBranchesInSwitch", "DuplicatedCode"})
+ static int sizeFromRaw(ImmutableIntArray bytecodes, int i) {
+ BcOp op;
+ try {
+ op = BcOp.valueOf(bytecodes.get(i++));
+ } catch (IllegalArgumentException e) {
+ throw new BcFromRawException("invalid opcode (instruction) " + bytecodes.get(i - 1));
+ }
+
+ try {
+ return 1
+ + switch (op) {
+ case BCMISMATCH ->
+ throw new BcFromRawException("invalid opcode " + BcOp.BCMISMATCH.value());
+ case RETURN -> 0;
+ case GOTO -> 1;
+ case BRIFNOT -> 2;
+ case POP -> 0;
+ case DUP -> 0;
+ case PRINTVALUE -> 0;
+ case STARTLOOPCNTXT -> 2;
+ case ENDLOOPCNTXT -> 1;
+ case DOLOOPNEXT -> 0;
+ case DOLOOPBREAK -> 0;
+ case STARTFOR -> 3;
+ case STEPFOR -> 1;
+ case ENDFOR -> 0;
+ case SETLOOPVAL -> 0;
+ case INVISIBLE -> 0;
+ case LDCONST -> 1;
+ case LDNULL -> 0;
+ case LDTRUE -> 0;
+ case LDFALSE -> 0;
+ case GETVAR -> 1;
+ case DDVAL -> 1;
+ case SETVAR -> 1;
+ case GETFUN -> 1;
+ case GETGLOBFUN -> 1;
+ case GETSYMFUN -> 1;
+ case GETBUILTIN -> 1;
+ case GETINTLBUILTIN -> 1;
+ case CHECKFUN -> 0;
+ case MAKEPROM -> 1;
+ case DOMISSING -> 0;
+ case SETTAG -> 1;
+ case DODOTS -> 0;
+ case PUSHARG -> 0;
+ case PUSHCONSTARG -> 1;
+ case PUSHNULLARG -> 0;
+ case PUSHTRUEARG -> 0;
+ case PUSHFALSEARG -> 0;
+ case CALL -> 1;
+ case CALLBUILTIN -> 1;
+ case CALLSPECIAL -> 1;
+ case MAKECLOSURE -> 1;
+ case UMINUS -> 1;
+ case UPLUS -> 1;
+ case ADD -> 1;
+ case SUB -> 1;
+ case MUL -> 1;
+ case DIV -> 1;
+ case EXPT -> 1;
+ case SQRT -> 1;
+ case EXP -> 1;
+ case EQ -> 1;
+ case NE -> 1;
+ case LT -> 1;
+ case LE -> 1;
+ case GE -> 1;
+ case GT -> 1;
+ case AND -> 1;
+ case OR -> 1;
+ case NOT -> 1;
+ case DOTSERR -> 0;
+ case STARTASSIGN -> 1;
+ case ENDASSIGN -> 1;
+ case STARTSUBSET -> 2;
+ case DFLTSUBSET -> 0;
+ case STARTSUBASSIGN -> 2;
+ case DFLTSUBASSIGN -> 0;
+ case STARTC -> 2;
+ case DFLTC -> 0;
+ case STARTSUBSET2 -> 2;
+ case DFLTSUBSET2 -> 0;
+ case STARTSUBASSIGN2 -> 2;
+ case DFLTSUBASSIGN2 -> 0;
+ case DOLLAR -> 2;
+ case DOLLARGETS -> 2;
+ case ISNULL -> 0;
+ case ISLOGICAL -> 0;
+ case ISINTEGER -> 0;
+ case ISDOUBLE -> 0;
+ case ISCOMPLEX -> 0;
+ case ISCHARACTER -> 0;
+ case ISSYMBOL -> 0;
+ case ISOBJECT -> 0;
+ case ISNUMERIC -> 0;
+ case VECSUBSET -> 1;
+ case MATSUBSET -> 1;
+ case VECSUBASSIGN -> 1;
+ case MATSUBASSIGN -> 1;
+ case AND1ST -> 2;
+ case AND2ND -> 1;
+ case OR1ST -> 2;
+ case OR2ND -> 1;
+ case GETVAR_MISSOK -> 1;
+ case DDVAL_MISSOK -> 1;
+ case VISIBLE -> 0;
+ case SETVAR2 -> 1;
+ case STARTASSIGN2 -> 1;
+ case ENDASSIGN2 -> 1;
+ case SETTER_CALL -> 2;
+ case GETTER_CALL -> 1;
+ case SWAP -> 0;
+ case DUP2ND -> 0;
+ case SWITCH -> 4;
+ case RETURNJMP -> 0;
+ case STARTSUBSET_N -> 2;
+ case STARTSUBASSIGN_N -> 2;
+ case VECSUBSET2 -> 1;
+ case MATSUBSET2 -> 1;
+ case VECSUBASSIGN2 -> 1;
+ case MATSUBASSIGN2 -> 1;
+ case STARTSUBSET2_N -> 2;
+ case STARTSUBASSIGN2_N -> 2;
+ case SUBSET_N -> 2;
+ case SUBSET2_N -> 2;
+ case SUBASSIGN_N -> 2;
+ case SUBASSIGN2_N -> 2;
+ case LOG -> 1;
+ case LOGBASE -> 1;
+ case MATH1 -> 2;
+ case DOTCALL -> 2;
+ case COLON -> 1;
+ case SEQALONG -> 1;
+ case SEQLEN -> 1;
+ case BASEGUARD -> 2;
+ case INCLNK -> 0;
+ case DECLNK -> 0;
+ case DECLNK_N -> 1;
+ case INCLNKSTK -> 0;
+ case DECLNKSTK -> 0;
+ };
} catch (IllegalArgumentException e) {
throw new BcFromRawException("invalid opcode (arguments) " + op, e);
} catch (ArrayIndexOutOfBoundsException e) {
diff --git a/src/main/java/org/prlprg/bc/BcLabel.java b/src/main/java/org/prlprg/bc/BcLabel.java
index 5dbdb7b6a..a3aca42c5 100644
--- a/src/main/java/org/prlprg/bc/BcLabel.java
+++ b/src/main/java/org/prlprg/bc/BcLabel.java
@@ -1,3 +1,115 @@
package org.prlprg.bc;
-public record BcLabel(int id) {}
+import com.google.common.base.Objects;
+import com.google.common.primitives.ImmutableIntArray;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+/** A branch instruction destination. */
+public final class BcLabel {
+ /** Index of the instruction the branch instruction jumps to. */
+ public final int target;
+
+ private BcLabel(int target) {
+ this.target = target;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof BcLabel bcLabel)) return false;
+ return target == bcLabel.target;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(target);
+ }
+
+ @Override
+ public String toString() {
+ return "BcLabel(" + target + ')';
+ }
+
+ /**
+ * Create labels from GNU-R labels.
+ *
+ * @implNote This contains a map of positions in GNU-R bytecode to positions in our bytecode. We
+ * need this because every index in our bytecode maps to an instruction, while indexes in
+ * GNU-R's bytecode also map to the bytecode version and instruction metadata.
+ */
+ static class Factory {
+ private final ImmutableIntArray posMap;
+
+ private Factory(ImmutableIntArray posMap) {
+ this.posMap = posMap;
+ }
+
+ /** Create a label from a GNU-R label. */
+ BcLabel make(int gnurLabel) {
+ if (gnurLabel == 0) {
+ throw new IllegalArgumentException("GNU-R label 0 is reserved for the version number");
+ }
+
+ var target = posMap.get(gnurLabel);
+ if (target == -1) {
+ var gnurEarlier = gnurLabel - 1;
+ int earlier;
+ do {
+ earlier = posMap.get(gnurEarlier);
+ } while (earlier == -1);
+ var gnurLater = gnurLabel + 1;
+ int later;
+ do {
+ later = posMap.get(gnurLater);
+ } while (later == -1);
+ throw new IllegalArgumentException(
+ "GNU-R position maps to the middle of one of our instructions: "
+ + gnurLabel
+ + " between "
+ + earlier
+ + " and "
+ + later);
+ }
+ return new BcLabel(target);
+ }
+
+ /**
+ * Create an object which creates labels from GNU-R labels, by building the map of positions in
+ * GNU-R bytecode to positions in our bytecode (see {@link Factory} implNote).
+ */
+ static class Builder {
+ private final ImmutableIntArray.Builder map = ImmutableIntArray.builder();
+ private int targetPc = 0;
+
+ Builder() {
+ // Add initial mapping of 1 -> 0 (version # is 0)
+ map.add(-1);
+ map.add(0);
+ }
+
+ /** Step m times in the source bytecode and n times in the target bytecode */
+ @CanIgnoreReturnValue
+ Builder step(int sourceOffset, @SuppressWarnings("SameParameterValue") int targetOffset) {
+ if (sourceOffset < 0 || targetOffset < 0) {
+ throw new IllegalArgumentException("offsets must be nonnegative");
+ }
+
+ targetPc += targetOffset;
+ // Offsets before sourceOffset map to the middle of the previous instruction
+ for (int i = 0; i < sourceOffset - 1; i++) {
+ map.add(-1);
+ }
+ // Add target position
+ if (sourceOffset > 0) {
+ map.add(targetPc);
+ }
+
+ return this;
+ }
+
+ Factory build() {
+ return new Factory(map.build());
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/prlprg/bc/package-info.java b/src/main/java/org/prlprg/bc/package-info.java
index 805179475..d42a28101 100644
--- a/src/main/java/org/prlprg/bc/package-info.java
+++ b/src/main/java/org/prlprg/bc/package-info.java
@@ -1,3 +1,4 @@
+/** GNU-R bytecode representation. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/main/java/org/prlprg/compile/package-info.java b/src/main/java/org/prlprg/compile/package-info.java
index 1358783b4..a47d16b82 100644
--- a/src/main/java/org/prlprg/compile/package-info.java
+++ b/src/main/java/org/prlprg/compile/package-info.java
@@ -1,3 +1,4 @@
+/** Compile GNU-R ASTs (S-expressions) into bytecode. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/main/java/org/prlprg/primitive/Names.java b/src/main/java/org/prlprg/primitive/Names.java
index ed155b746..216bdaa56 100644
--- a/src/main/java/org/prlprg/primitive/Names.java
+++ b/src/main/java/org/prlprg/primitive/Names.java
@@ -2,6 +2,7 @@
import com.google.common.collect.ImmutableList;
+/** Special symbols */
public final class Names {
public static final ImmutableList BINOPS =
ImmutableList.of(
diff --git a/src/main/java/org/prlprg/primitive/package-info.java b/src/main/java/org/prlprg/primitive/package-info.java
index 8fd3c5c4b..1fcb4b9c9 100644
--- a/src/main/java/org/prlprg/primitive/package-info.java
+++ b/src/main/java/org/prlprg/primitive/package-info.java
@@ -1,3 +1,4 @@
+/** GNU-R simple struct datatypes and values which aren't s-exoressions or contexts. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/main/java/org/prlprg/rds/RDSReader.java b/src/main/java/org/prlprg/rds/RDSReader.java
index 9ba05386d..8a5375897 100644
--- a/src/main/java/org/prlprg/rds/RDSReader.java
+++ b/src/main/java/org/prlprg/rds/RDSReader.java
@@ -6,6 +6,8 @@
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -15,7 +17,7 @@
import org.prlprg.primitive.Constants;
import org.prlprg.primitive.Logical;
import org.prlprg.sexp.*;
-import org.prlprg.util.NotImplementedException;
+import org.prlprg.util.NotImplementedError;
public class RDSReader implements Closeable {
private final RDSInputStream in;
@@ -34,6 +36,10 @@ public static SEXP readStream(InputStream input) throws IOException {
}
}
+ public static SEXP readFile(Path path) throws IOException {
+ return readStream(Files.newInputStream(path));
+ }
+
private void readHeader() throws IOException {
var type = in.readByte();
if (type != 'X') {
@@ -110,7 +116,7 @@ private SEXP readItem() throws IOException {
PACKAGESXP,
PERSISTSXP,
CLASSREFSXP ->
- throw new NotImplementedException();
+ throw new NotImplementedError();
};
};
}
@@ -139,14 +145,9 @@ private BCodeSXP readByteCode1(SEXP[] reps) throws IOException {
default -> throw new RDSException("Expected IntSXP");
};
- if (code.get(0) != Bc.R_BC_VERSION) {
- throw new RDSException("Unsupported byte code version: " + code.get(0));
- }
-
- var bytecode = code.subArray(1, code.size());
var consts = readByteCodeConsts(reps);
try {
- return SEXPs.bcode(Bc.fromRaw(bytecode, consts));
+ return SEXPs.bcode(Bc.fromRaw(code.data(), consts));
} catch (BcFromRawException e) {
throw new RDSException("Error reading bytecode", e);
}
diff --git a/src/main/java/org/prlprg/rds/package-info.java b/src/main/java/org/prlprg/rds/package-info.java
index 71645c59d..66870f111 100644
--- a/src/main/java/org/prlprg/rds/package-info.java
+++ b/src/main/java/org/prlprg/rds/package-info.java
@@ -1,3 +1,4 @@
+/** Deserialize (and later serialize) S-expressions. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/main/java/org/prlprg/server/ClientHandleException.java b/src/main/java/org/prlprg/server/ClientHandleException.java
new file mode 100644
index 000000000..844fab6d3
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ClientHandleException.java
@@ -0,0 +1,14 @@
+package org.prlprg.server;
+
+/**
+ * An exception a background thread got when processing a client request. This exception is checked
+ * while the underlying exception may not be.
+ */
+public final class ClientHandleException extends Exception {
+ public final String address;
+
+ public ClientHandleException(String address, Throwable cause) {
+ super("Handler/thread for client " + address + " crashed", cause);
+ this.address = address;
+ }
+}
diff --git a/src/main/java/org/prlprg/server/ClientHandler.java b/src/main/java/org/prlprg/server/ClientHandler.java
new file mode 100644
index 000000000..ecbd4d9b6
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ClientHandler.java
@@ -0,0 +1,120 @@
+package org.prlprg.server;
+
+import javax.annotation.Nullable;
+import org.zeromq.ZMQ;
+import org.zeromq.ZMQ.Error;
+import org.zeromq.ZMQException;
+
+/**
+ * Manages communication between the server and a particular client. Contains the client's socket,
+ * thread, and {@link ClientState}.
+ *
+ * This class doesn't implement {@link AutoCloseable}; if you want to close this, close the
+ * socket. The reason is that the socket may close on its own, so we have to handle that case
+ * anyways, and don't want two separate ways to close.
+ */
+final class ClientHandler {
+ private final String address;
+ private final ZMQ.Socket socket;
+ private final Thread thread;
+ private @Nullable Throwable exception = null;
+ // When the socket closes, we aren't made aware until we handle its next message and get null or
+ // an exception.
+ private boolean isDefinitelyClosed = false;
+ private final ClientState state = new ClientState();
+
+ /**
+ * This will immediately start handling client requests on a background thread immediately.
+ *
+ *
As specified in the class description, to close this (and stop the thread), close the
+ * socket.
+ *
+ * @param address The address of the socket. It must resolve to the same address as {@code
+ * socket.getLastEndpoint()}, however the actual text may be different (e.g. localhost vs
+ * 127.0.0.1) which is why we need to pass it explicitly (because otherwise {@link
+ * Server#unbind} won't work).
+ */
+ ClientHandler(String address, ZMQ.Socket socket) {
+ this.address = address;
+ this.socket = socket;
+ this.thread =
+ new Thread(
+ () -> {
+ try {
+ while (true) {
+ var message = socket.recvStr();
+ if (message == null) {
+ // Socket closed
+ isDefinitelyClosed = true;
+ break;
+ }
+ var response = ReqRepHandlers.handle(state, message);
+ if (response != null) {
+ socket.send(response);
+ }
+ }
+ } catch (ZMQException e) {
+ close();
+ var error = Error.findByCode(e.getErrorCode());
+ switch (error) {
+ // Currently assume these are normal
+ case Error.ECONNABORTED:
+ case Error.ETERM:
+ System.out.println("ClientState-" + address() + " closed with " + error);
+ break;
+ default:
+ exception = e;
+ throw e;
+ }
+ } catch (Throwable e) {
+ close();
+ exception = e;
+ throw e;
+ }
+ });
+ thread.setName("ClientState-" + address());
+ thread.start();
+ }
+
+ /** The address the client is connected to. */
+ String address() {
+ return address;
+ }
+
+ /**
+ * Close the underlying socket, which makes the thread stop on next communication (if it's doing
+ * something it won't stop immediately).
+ *
+ *
If the socket is already closed, the behavior of this method is undefined.
+ */
+ void close() {
+ if (isDefinitelyClosed) {
+ throw new IllegalStateException("Socket already closed");
+ }
+ isDefinitelyClosed = true;
+ socket.close();
+ }
+
+ /**
+ * Throws a {@link ClientHandleException} if the client disconnected because it got an exception.
+ */
+ void checkForException() throws ClientHandleException {
+ if (exception != null) {
+ throw new ClientHandleException(address(), exception);
+ }
+ }
+
+ /**
+ * Waits for the client to disconnect and the socket to close on its own.
+ *
+ * @throws ClientHandleException if a client disconnected because it got an exception.
+ */
+ void waitForDisconnect() throws ClientHandleException {
+ try {
+ thread.join();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ checkForException();
+ }
+}
diff --git a/src/main/java/org/prlprg/server/ClientParseViolationException.java b/src/main/java/org/prlprg/server/ClientParseViolationException.java
new file mode 100644
index 000000000..0bbc14463
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ClientParseViolationException.java
@@ -0,0 +1,18 @@
+package org.prlprg.server;
+
+/** The client sent a malformed message. */
+public final class ClientParseViolationException extends IllegalStateException {
+ @SuppressWarnings("unused")
+ ClientParseViolationException(String message) {
+ super(message);
+ }
+
+ ClientParseViolationException(Throwable cause) {
+ super(cause);
+ }
+
+ @SuppressWarnings("unused")
+ ClientParseViolationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/org/prlprg/server/ClientState.java b/src/main/java/org/prlprg/server/ClientState.java
new file mode 100644
index 000000000..363fa0a60
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ClientState.java
@@ -0,0 +1,35 @@
+package org.prlprg.server;
+
+import javax.annotation.Nullable;
+import org.prlprg.RVersion;
+
+/**
+ * State for a client stored on the server visible to {@link ReqRepHandlers}.
+ *
+ *
Doesn't include the socket and client thread, that's the role of {@link ClientHandler}.
+ */
+final class ClientState {
+ private record Init(RVersion version) {}
+
+ private @Nullable Init init;
+
+ ClientState() {}
+
+ void init(RVersion version) {
+ if (init != null) {
+ throw new ClientStateViolationException("Client already initialized");
+ }
+ init = new Init(version);
+ }
+
+ RVersion version() {
+ return init().version;
+ }
+
+ private Init init() {
+ if (init == null) {
+ throw new ClientStateViolationException("Client not initialized");
+ }
+ return init;
+ }
+}
diff --git a/src/main/java/org/prlprg/server/ClientStateViolationException.java b/src/main/java/org/prlprg/server/ClientStateViolationException.java
new file mode 100644
index 000000000..6e0fdbbb9
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ClientStateViolationException.java
@@ -0,0 +1,21 @@
+package org.prlprg.server;
+
+/**
+ * The client sent a message or data the server wasn't expecting in its state. e.g. client
+ * initializes itself twice.
+ */
+public final class ClientStateViolationException extends IllegalStateException {
+ ClientStateViolationException(String message) {
+ super(message);
+ }
+
+ @SuppressWarnings("unused")
+ ClientStateViolationException(Throwable cause) {
+ super(cause);
+ }
+
+ @SuppressWarnings("unused")
+ ClientStateViolationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/org/prlprg/server/ReqRepHandlers.java b/src/main/java/org/prlprg/server/ReqRepHandlers.java
new file mode 100644
index 000000000..8a9861594
--- /dev/null
+++ b/src/main/java/org/prlprg/server/ReqRepHandlers.java
@@ -0,0 +1,57 @@
+package org.prlprg.server;
+
+import javax.annotation.Nullable;
+import org.prlprg.RVersion;
+import org.prlprg.util.NotImplementedError;
+
+/**
+ * Functions for each initial request the client can make. They take the initial request body as
+ * params (as well as client state), and return the final response (or void if there are none). If a
+ * request is more complicated than a simple function (e.g. server can respond with "more
+ * information needed" and then wait until the client sends more), the function will also take a
+ * {@link Mediator} to send intermediate responses and handle intermediate requests (possibly out-
+ * of-order, we're not sure what kinds of communicatio we'll need yet).
+ *
+ *
The specific handlers are actually all private, because this class's interface has the method
+ * which handles a generic initial request, parsing and dispatching to the specific handler.
+ */
+final class ReqRepHandlers {
+ private static final String SIMPLE_ACK = "";
+
+ /**
+ * Handle an initial request (not intermediate request in a request chain) from the client.
+ *
+ *
Apparetly ZMQ needs every request to have some response. If this returns null, it already
+ * send a response. If this only needs to do a simple ACK, it will return the empty string.
+ */
+ // TODO: Add GenericMediator which can create all other mediators which we'll do depending on the
+ // request type. GenericMediator will have a reference to the ClientHandler's socket and thread
+ // so it can send and receive subsequect requests,
+ static @Nullable String handle(ClientState state, String request) {
+ // TODO: Parse request type and the rest of the data, create specific mediator if necessary,
+ // dispatch to the specific handler, and encode the response.
+ if (request.equals("Hello, server!")) {
+ throw new ClientStateViolationException("bad message");
+ }
+ if (request.startsWith("Proper init ")) {
+ RVersion rVersion;
+ try {
+ rVersion = RVersion.parse(request.substring("Proper init ".length()));
+ } catch (IllegalArgumentException e) {
+ throw new ClientParseViolationException(e);
+ }
+ init(state, rVersion);
+ return SIMPLE_ACK;
+ }
+ if (request.startsWith("Something which returns null so IntelliJ doesn't complain")) {
+ return null;
+ }
+ throw new NotImplementedError();
+ }
+
+ // region specific handlers
+ private static void init(ClientState state, RVersion rVersion) {
+ state.init(rVersion);
+ }
+ // endregion
+}
diff --git a/src/main/java/org/prlprg/server/Server.java b/src/main/java/org/prlprg/server/Server.java
new file mode 100644
index 000000000..4d997fa8f
--- /dev/null
+++ b/src/main/java/org/prlprg/server/Server.java
@@ -0,0 +1,155 @@
+package org.prlprg.server;
+
+import com.google.common.collect.ImmutableList;
+import java.io.Closeable;
+import java.util.Set;
+import org.prlprg.util.WeakHashSet;
+import org.zeromq.SocketType;
+import org.zeromq.ZContext;
+
+/**
+ * An instance of the compile server which communicates with clients.
+ *
+ *
All methods to send and receive data are here so they're easy to find.
+ *
+ * @implNote The current plan is for the server's interface to be exclusive to the main thread,
+ * which includes adding sockets and closing. The server spawns separate threads for each client
+ * to wait and handle their requests, and the clients threads may spawn child threads for
+ * specific multi-part requests (although first the clients need to be multi- threaded).
+ */
+public final class Server implements Closeable {
+ // TODO: Make this an environment variable, but we should have some centralized configuration
+ // static class with all environment variables
+ private static final int IO_THREADS = 1;
+
+ private final Thread mainThread = Thread.currentThread();
+ private final ZContext context = new ZContext(IO_THREADS);
+ // Assume that ClientHandlers get garbage collected when their sockets and threads close.
+ // This doesn't mean all client handlers in the set are open, don't assume so.
+ private final Set clients = new WeakHashSet<>();
+
+ /** Creates a new server. */
+ public Server() {}
+
+ /**
+ * Binds the server to the given address so a client can connect. Currently, we only allow one
+ * client per address, but a server can bind to multiple addresses.
+ *
+ * This method must be called from the thread the server was created on.
+ *
+ * @param address The address to bind to, e.g. "tcp://*:5555".
+ */
+ public void bind(String address) {
+ requireMainThread("bind");
+
+ var socket = context.createSocket(SocketType.REP);
+ var isBound = socket.bind(address);
+ // Does ZMQ ever return false without throwing? If so, we need to handle
+ assert isBound;
+
+ clients.add(new ClientHandler(address, socket));
+ }
+
+ /**
+ * Unbinds the server from the given address so, if a client is connected, it will disconnect. If
+ * the client socket was closed, this method may or may not throw {@link
+ * IllegalArgumentException}.
+ *
+ *
If the client was once but already no longer connected, the behavior of this method is
+ * undefined.
+ *
+ *
This method must be called from the thread the server was created on.
+ *
+ * @param address The address to unbind from, e.g. "tcp://*:5555".
+ * @throws IllegalArgumentException If no client is bound to the given address.
+ */
+ public void unbind(String address) {
+ requireMainThread("unbind");
+
+ for (var client : clients) {
+ if (client.address().equals(address)) {
+ client.close();
+ return;
+ }
+ }
+ throw new IllegalArgumentException("No client bound to " + address);
+ }
+
+ /**
+ * Throws a {@link SomeClientHandleException} if any client disconnected because it got an
+ * exception.
+ *
+ *
This method must be called from the thread the server was created on.
+ */
+ void checkForAnyException() throws SomeClientHandleException {
+ requireMainThread("checkForAnyException");
+
+ var exceptionsBuilder =
+ ImmutableList.builderWithExpectedSize(clients.size());
+
+ for (var client : clients) {
+ try {
+ client.checkForException();
+ } catch (ClientHandleException e) {
+ exceptionsBuilder.add(e);
+ }
+ }
+
+ var exceptions = exceptionsBuilder.build();
+ if (!exceptions.isEmpty()) {
+ throw new SomeClientHandleException(exceptions);
+ }
+ }
+
+ /**
+ * Waits for all clients to disconnect.
+ *
+ * This method must be called from the thread the server was created on.
+ *
+ * @throws SomeClientHandleException If any client disconnected because it got an exception.
+ */
+ public void waitForAllDisconnect() throws SomeClientHandleException {
+ requireMainThread("waitForAllDisconnect");
+
+ var exceptionsBuilder =
+ ImmutableList.builderWithExpectedSize(clients.size());
+
+ for (var client : clients) {
+ try {
+ client.waitForDisconnect();
+ } catch (ClientHandleException e) {
+ exceptionsBuilder.add(e);
+ }
+ }
+
+ var exceptions = exceptionsBuilder.build();
+ if (!exceptions.isEmpty()) {
+ throw new SomeClientHandleException(exceptions);
+ }
+ }
+
+ /**
+ * Closes the server, disconnecting all clients.
+ *
+ * This method must be called from the thread the server was created on.
+ */
+ @Override
+ public void close() {
+ requireMainThread("close");
+
+ // Probably handled by context but anyways
+ if (context.isClosed()) {
+ throw new IllegalStateException("Server already closed");
+ }
+
+ context.close();
+ // Sockets will disconnect, and client handlers will stop their threads, automatically
+ }
+
+ private void requireMainThread(String method) {
+ if (Thread.currentThread() != mainThread) {
+ throw new IllegalStateException(
+ "Method " + method + " must be called from the thread the server was created on");
+ }
+ }
+}
diff --git a/src/main/java/org/prlprg/server/SomeClientHandleException.java b/src/main/java/org/prlprg/server/SomeClientHandleException.java
new file mode 100644
index 000000000..d0f66af7d
--- /dev/null
+++ b/src/main/java/org/prlprg/server/SomeClientHandleException.java
@@ -0,0 +1,13 @@
+package org.prlprg.server;
+
+import com.google.common.collect.ImmutableList;
+
+/** If any client threads closed because of an exception, the server will throw this. */
+public final class SomeClientHandleException extends Exception {
+ public final ImmutableList clientExceptions;
+
+ public SomeClientHandleException(ImmutableList clientExceptions) {
+ super("Some client handlers/threads crashed, see the client exceptions for details.");
+ this.clientExceptions = clientExceptions;
+ }
+}
diff --git a/src/main/java/org/prlprg/server/package-info.java b/src/main/java/org/prlprg/server/package-info.java
new file mode 100644
index 000000000..2e043e1d6
--- /dev/null
+++ b/src/main/java/org/prlprg/server/package-info.java
@@ -0,0 +1,9 @@
+/** Socket setup and communication with clients, and protocols. */
+@ParametersAreNonnullByDefault
+@FieldsAreNonNullByDefault
+@ReturnTypesAreNonNullByDefault
+package org.prlprg.server;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+import org.prlprg.util.FieldsAreNonNullByDefault;
+import org.prlprg.util.ReturnTypesAreNonNullByDefault;
diff --git a/src/main/java/org/prlprg/sexp/IntSXP.java b/src/main/java/org/prlprg/sexp/IntSXP.java
index 7986c6c78..e3c88d753 100644
--- a/src/main/java/org/prlprg/sexp/IntSXP.java
+++ b/src/main/java/org/prlprg/sexp/IntSXP.java
@@ -6,7 +6,7 @@
@Immutable
public sealed interface IntSXP extends VectorSXP {
- ImmutableIntArray subArray(int startIndex, int endIndex);
+ ImmutableIntArray data();
@Override
default SEXPType type() {
@@ -20,12 +20,8 @@ default SEXPType type() {
IntSXP withAttributes(Attributes attributes);
}
-record IntSXPImpl(ImmutableIntArray data, @Override Attributes attributes) implements IntSXP {
- @Override
- public ImmutableIntArray subArray(int startIndex, int endIndex) {
- return data.subArray(startIndex, endIndex);
- }
-
+record IntSXPImpl(@Override ImmutableIntArray data, @Override Attributes attributes)
+ implements IntSXP {
@Override
public PrimitiveIterator.OfInt iterator() {
return data.stream().iterator();
@@ -58,15 +54,8 @@ final class SimpleIntSXPImpl extends SimpleScalarSXPImpl implements Int
}
@Override
- public ImmutableIntArray subArray(int startIndex, int endIndex) {
- if (startIndex == endIndex && (startIndex == 0 || startIndex == 1)) {
- return ImmutableIntArray.of();
- } else if (startIndex == 0 && endIndex == 1) {
- return ImmutableIntArray.of(data);
- } else {
- throw new IndexOutOfBoundsException(
- "subArray of simple scalar; startIndex=" + startIndex + ", endIndex=" + endIndex);
- }
+ public ImmutableIntArray data() {
+ return ImmutableIntArray.of(data);
}
@Override
@@ -83,13 +72,8 @@ private EmptyIntSXPImpl() {
}
@Override
- public ImmutableIntArray subArray(int startIndex, int endIndex) {
- if (startIndex == 0 && endIndex == 0) {
- return ImmutableIntArray.of();
- } else {
- throw new IndexOutOfBoundsException(
- "subArray of empty vector; startIndex=" + startIndex + ", endIndex=" + endIndex);
- }
+ public ImmutableIntArray data() {
+ return ImmutableIntArray.of();
}
@Override
diff --git a/src/main/java/org/prlprg/sexp/SEXP.java b/src/main/java/org/prlprg/sexp/SEXP.java
index cb92adb2f..372555822 100644
--- a/src/main/java/org/prlprg/sexp/SEXP.java
+++ b/src/main/java/org/prlprg/sexp/SEXP.java
@@ -15,6 +15,11 @@ public sealed interface SEXP
}
/**
+ * Returns an SEXP which would be equal except it has the given attributes instead of its old
+ * ones. If the SEXP is a {@link RegEnvSXP}, it will mutate in-place and return itself. If the
+ * SEXP is a list or vector containing environments, this performs a shallow copy, so mutating the
+ * environments in one version will affect the other.
+ *
* @throws UnsupportedOperationException if the SEXP doesn't support attributes.
*/
default SEXP withAttributes(Attributes attributes) {
diff --git a/src/main/java/org/prlprg/sexp/SEXPs.java b/src/main/java/org/prlprg/sexp/SEXPs.java
index 041404745..9421f066d 100644
--- a/src/main/java/org/prlprg/sexp/SEXPs.java
+++ b/src/main/java/org/prlprg/sexp/SEXPs.java
@@ -12,6 +12,7 @@
import org.prlprg.primitive.Constants;
import org.prlprg.primitive.Logical;
+/** All global SEXPs and methods to create SEXPs are here so they're easy to find. */
public final class SEXPs {
// region constants
public static final NilSXP NULL = NilSXP.INSTANCE;
diff --git a/src/main/java/org/prlprg/sexp/package-info.java b/src/main/java/org/prlprg/sexp/package-info.java
index 81d89a94c..f9b817754 100644
--- a/src/main/java/org/prlprg/sexp/package-info.java
+++ b/src/main/java/org/prlprg/sexp/package-info.java
@@ -1,3 +1,4 @@
+/** S-expressions (GNU-R object type): the different types and associated data. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/main/java/org/prlprg/util/NotImplementedError.java b/src/main/java/org/prlprg/util/NotImplementedError.java
new file mode 100644
index 000000000..f4a743f0b
--- /dev/null
+++ b/src/main/java/org/prlprg/util/NotImplementedError.java
@@ -0,0 +1,11 @@
+package org.prlprg.util;
+
+public class NotImplementedError extends Error {
+ public NotImplementedError(Object switchCase) {
+ super("Sorry, this case is not yet implemented: " + switchCase);
+ }
+
+ public NotImplementedError() {
+ super("Sorry, this code is not yet implemented");
+ }
+}
diff --git a/src/main/java/org/prlprg/util/NotImplementedException.java b/src/main/java/org/prlprg/util/NotImplementedException.java
deleted file mode 100644
index c82879ff5..000000000
--- a/src/main/java/org/prlprg/util/NotImplementedException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.prlprg.util;
-
-public class NotImplementedException extends UnsupportedOperationException {
- public NotImplementedException(Object switchCase) {
- super("Sorry, this case is not yet implemented: " + switchCase);
- }
-
- public NotImplementedException() {
- super("Sorry, this code is not yet implemented");
- }
-}
diff --git a/src/main/java/org/prlprg/util/PackagePrivate.java b/src/main/java/org/prlprg/util/PackagePrivate.java
deleted file mode 100644
index 747c21222..000000000
--- a/src/main/java/org/prlprg/util/PackagePrivate.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.prlprg.util;
-
-/**
- * Explicitly denotes that a member is package-private. Otherwise, pmd will warn in case you forgot
- * to add a visibility.
- */
-public @interface PackagePrivate {}
diff --git a/src/main/java/org/prlprg/util/WeakHashSet.java b/src/main/java/org/prlprg/util/WeakHashSet.java
new file mode 100644
index 000000000..4d319c7d3
--- /dev/null
+++ b/src/main/java/org/prlprg/util/WeakHashSet.java
@@ -0,0 +1,84 @@
+package org.prlprg.util;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.WeakHashMap;
+
+/**
+ * Set version of {@link java.util.WeakHashMap}: all the keys are weakly refereced and when they get
+ * garbage collected, the corresponding value is removed from the set.
+ */
+public class WeakHashSet implements Set {
+ private final WeakHashMap map = new WeakHashMap<>();
+
+ @Override
+ public int size() {
+ return map.size();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return map.isEmpty();
+ }
+
+ @SuppressWarnings("SuspiciousMethodCalls")
+ @Override
+ public boolean contains(Object o) {
+ return map.containsKey(o);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return map.keySet().iterator();
+ }
+
+ @Override
+ public Object[] toArray() {
+ return map.keySet().toArray();
+ }
+
+ @Override
+ public T1[] toArray(T1[] a) {
+ return map.keySet().toArray(a);
+ }
+
+ @Override
+ public boolean add(T t) {
+ return map.put(t, null) == null;
+ }
+
+ @Override
+ public boolean remove(Object o) {
+ return map.remove(o) != null;
+ }
+
+ @Override
+ public boolean containsAll(Collection> c) {
+ return map.keySet().containsAll(c);
+ }
+
+ @Override
+ public boolean addAll(Collection extends T> c) {
+ boolean changed = false;
+ for (var e : c) {
+ changed |= add(e);
+ }
+ return changed;
+ }
+
+ @Override
+ public boolean retainAll(Collection> c) {
+ return map.keySet().retainAll(c);
+ }
+
+ @Override
+ public boolean removeAll(Collection> c) {
+ return map.keySet().removeAll(c);
+ }
+
+ @Override
+ public void clear() {
+ map.clear();
+ }
+}
diff --git a/src/main/java/org/prlprg/util/package-info.java b/src/main/java/org/prlprg/util/package-info.java
index c222aa8cf..f062e0173 100644
--- a/src/main/java/org/prlprg/util/package-info.java
+++ b/src/main/java/org/prlprg/util/package-info.java
@@ -1,3 +1,4 @@
+/** Misc stuff which could go in any package. */
@ParametersAreNonnullByDefault
@FieldsAreNonNullByDefault
@ReturnTypesAreNonNullByDefault
diff --git a/src/test/R/org/prlprg/bc/serialize-closures.R b/src/test/R/org/prlprg/bc/serialize-closures.R
new file mode 100644
index 000000000..474361b9f
--- /dev/null
+++ b/src/test/R/org/prlprg/bc/serialize-closures.R
@@ -0,0 +1,13 @@
+.args <- commandArgs(trailingOnly = TRUE)
+.source <- .args[1]
+.target <- .args[2]
+
+source(.source, chdir = TRUE)
+dir.create(.target)
+for (.name in ls()) {
+ if (is.function(get(.name))) {
+ .func <- get(.name)
+ saveRDS(.func, compress = FALSE, file = file.path(.target, paste0(.name, ".ast.rds")))
+ saveRDS(compiler::cmpfun(.func), compress = FALSE, file = file.path(.target, paste0(.name, ".bc.rds")))
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/prlprg/bc/CompilerTest.java b/src/test/java/org/prlprg/bc/CompilerTest.java
index 0a85881ef..a47a0cdf5 100644
--- a/src/test/java/org/prlprg/bc/CompilerTest.java
+++ b/src/test/java/org/prlprg/bc/CompilerTest.java
@@ -1,16 +1,69 @@
package org.prlprg.bc;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.prlprg.util.Assertions.assertSnapshot;
+import static org.prlprg.util.StructuralUtils.printStructurally;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
import org.prlprg.compile.Compiler;
import org.prlprg.rds.RDSReader;
+import org.prlprg.sexp.BCodeSXP;
import org.prlprg.sexp.CloSXP;
+import org.prlprg.util.DirectorySource;
+import org.prlprg.util.Files;
+import org.prlprg.util.SubTest;
import org.prlprg.util.Tests;
public class CompilerTest implements Tests {
@Test
- public void testBasic() throws Exception {
+ public void testSomeOutput() throws IOException {
var source = (CloSXP) RDSReader.readStream(getResourceAsStream("f1.rds"));
var bc = Compiler.compileFun(source);
System.out.println(bc);
}
+
+ @Disabled
+ @ParameterizedTest(name = "Commutative read/compile {0}")
+ @DirectorySource(glob = "*.R", relativize = true, exclude = "serialize-closures.R")
+ void testCommutativeReadAndCompile(Path sourceName) {
+ var sourcePath = getResourcePath(sourceName);
+ var compiledRoot = getSnapshotPath(sourceName);
+
+ // Generate `compiledRoot` from `sourcePath` using GNU-R if necessary
+ if (Files.exists(compiledRoot) && Files.isOlder(compiledRoot, sourcePath)) {
+ Files.deleteRecursively(compiledRoot);
+ }
+ if (!Files.exists(compiledRoot)) {
+ // Generate `compiledRoot` from `sourcePath` using GNU-R
+ cmd(
+ "R",
+ "-s",
+ "-f",
+ getResourcePath("serialize-closures.R"),
+ "--args",
+ sourcePath,
+ compiledRoot);
+ }
+
+ // Test each closure in the file
+ for (var astPath : Files.listDir(compiledRoot, "*.ast.rds", 1, false, false)) {
+ var name = astPath.getFileName().toString().split("\\.")[0];
+ var bcPath = compiledRoot.resolve(name + ".bc.rds");
+ var bcOutPath = compiledRoot.resolve(name + ".bc.out");
+ SubTest.run(
+ name,
+ () -> {
+ var astClos = (CloSXP) RDSReader.readFile(astPath);
+ var bcClos = (CloSXP) RDSReader.readFile(bcPath);
+ var ourBc = printStructurally(Compiler.compileFun(astClos));
+ var rBc = printStructurally(((BCodeSXP) bcClos.body()).bc());
+ assertEquals(ourBc, rBc, "`compile(read(ast)) == read(R.compile(ast))`");
+ assertSnapshot(bcOutPath, bcClos::toString, "`print(bc)`");
+ });
+ }
+ }
}
diff --git a/src/test/java/org/prlprg/server/ClientStateTests.java b/src/test/java/org/prlprg/server/ClientStateTests.java
new file mode 100644
index 000000000..825522aab
--- /dev/null
+++ b/src/test/java/org/prlprg/server/ClientStateTests.java
@@ -0,0 +1,17 @@
+package org.prlprg.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import org.prlprg.RVersion;
+
+public class ClientStateTests {
+ @Test
+ public void testClientState() {
+ var clientState = new ClientState();
+ assertThrows(ClientStateViolationException.class, clientState::version);
+ clientState.init(RVersion.LATEST_AWARE);
+ assertEquals(RVersion.LATEST_AWARE, clientState.version());
+ }
+}
diff --git a/src/test/java/org/prlprg/server/ServerTests.java b/src/test/java/org/prlprg/server/ServerTests.java
new file mode 100644
index 000000000..0dc4fc208
--- /dev/null
+++ b/src/test/java/org/prlprg/server/ServerTests.java
@@ -0,0 +1,102 @@
+package org.prlprg.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import org.junit.jupiter.api.Test;
+import org.zeromq.SocketType;
+import org.zeromq.ZSocket;
+
+public class ServerTests {
+ @Test
+ public void testEmptyServer() throws SomeClientHandleException {
+ var server = new Server();
+ server.waitForAllDisconnect();
+ server.close();
+ }
+
+ @Test
+ public void testServerImmediatelyUnbind() throws SomeClientHandleException {
+ int port = getUnusedPort();
+
+ var server = new Server();
+ server.bind("tcp://localhost:" + port);
+
+ var aClient = new ZSocket(SocketType.REQ);
+ aClient.connect("tcp://localhost:" + port);
+
+ server.unbind("tcp://localhost:" + port);
+
+ // Client won't send
+ aClient.sendStringUtf8("Hello, server!");
+
+ // These will do nothing
+ server.checkForAnyException();
+ server.waitForAllDisconnect();
+ // Close should still work
+ server.close();
+ }
+
+ @Test
+ public void testServerWithABadClient() {
+ int port = getUnusedPort();
+
+ var server = new Server();
+ server.bind("tcp://localhost:" + port);
+
+ var aClient = new ZSocket(SocketType.REQ);
+ aClient.connect("tcp://localhost:" + port);
+
+ // This isn't a valid message, so it will trigger an exception in the background thread
+ aClient.sendStringUtf8("Hello, server!");
+
+ // Which will propogate when we join
+ assertThrows(SomeClientHandleException.class, server::waitForAllDisconnect);
+ // And check exceptions (this must be called after join or the client handler may have not yet
+ // thrown anything)
+ assertThrows(SomeClientHandleException.class, server::checkForAnyException);
+
+ // Close should still work
+ server.close();
+ }
+
+ @Test
+ public void testServerWithAGoodClientOnlyInit() throws SomeClientHandleException {
+ int port = getUnusedPort();
+
+ var server = new Server();
+ server.bind("tcp://localhost:" + port);
+
+ var aClient = new ZSocket(SocketType.REQ);
+ aClient.connect("tcp://localhost:" + port);
+
+ // This is a valid message and will initialize the client
+ aClient.sendStringUtf8("Proper init 1.23.45");
+ assertEquals("", aClient.receiveStringUtf8());
+
+ // So this will do nothing
+ server.checkForAnyException();
+
+ // This is now invalid because the client has already been initialized
+ aClient.sendStringUtf8("Proper init 1.23.45");
+ // If we try receiveStringUtf8 here it will hang forever. Even though we closed the socket on
+ // the server end (and even we unbind), that's what ZMQ seems to do.
+
+ // So this will throw an exception
+ assertThrows(SomeClientHandleException.class, server::waitForAllDisconnect);
+
+ // This should work normally, as always
+ server.close();
+ }
+
+ private int getUnusedPort() {
+ // From https://stackoverflow.com/questions/2675362/how-to-find-an-available-port
+ try (var socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to find an unused port", e);
+ }
+ }
+}
diff --git a/src/test/java/org/prlprg/util/Assertions.java b/src/test/java/org/prlprg/util/Assertions.java
new file mode 100644
index 000000000..ab15a03b1
--- /dev/null
+++ b/src/test/java/org/prlprg/util/Assertions.java
@@ -0,0 +1,54 @@
+package org.prlprg.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.file.Path;
+import java.util.function.Supplier;
+import org.opentest4j.AssertionFailedError;
+
+public class Assertions {
+ /**
+ * First it asserts that producing the string twice results in the same string.
+ *
+ * Then, if {@code snapshotPath} exists, it asserts that the produced string matches its
+ * content. If {@code snapshotPath} doesn't exist, it instead writes the produced string to it and
+ * logs that it was created.
+ *
+ *
Additionally, test failures are written to a file next to {@code snapshotPath} which gets
+ * automatically deleted after the test passes; this file can be diffed with the snapshot to see
+ * the regression, and if the snapshot was supposed to change it can be moved to {@code
+ * snapshotPath} to update it.
+ *
+ * @param snapshotPath The absolute path to the snapshot file.
+ * @param snapshotName A human-readable name for the snapshot, used in the assertion messages.
+ */
+ public static void assertSnapshot(
+ Path snapshotPath, Supplier actual, String snapshotName) {
+ if (!snapshotPath.isAbsolute()) {
+ throw new IllegalArgumentException(
+ "Snapshot path must be absolute: "
+ + snapshotPath
+ + "\nUse Tests.getSnapshot to get an absolute path from a relative one.");
+ }
+ var firstActual = actual.get();
+ var secondActual = actual.get();
+ assertEquals(firstActual, secondActual, "Non-determinism in " + snapshotName);
+
+ var failingSnapshotPath =
+ Paths.withExtension(snapshotPath, ".fail" + Paths.getExtension(snapshotPath));
+ Files.deleteIfExists(failingSnapshotPath);
+
+ if (!Files.exists(snapshotPath)) {
+ Files.writeString(snapshotPath, secondActual);
+ System.err.println("Created snapshot: " + snapshotPath);
+ } else {
+ var expected = Files.readString(snapshotPath);
+ if (!expected.equals(secondActual)) {
+ Files.writeString(failingSnapshotPath, secondActual);
+ throw new AssertionFailedError("Regression in " + snapshotName, expected, secondActual);
+ }
+ }
+ }
+
+ private Assertions() {}
+}
diff --git a/src/test/java/org/prlprg/util/DirectorySource.java b/src/test/java/org/prlprg/util/DirectorySource.java
new file mode 100644
index 000000000..dbd41ec2d
--- /dev/null
+++ b/src/test/java/org/prlprg/util/DirectorySource.java
@@ -0,0 +1,72 @@
+package org.prlprg.util;
+
+import com.google.common.collect.ImmutableSet;
+import java.lang.annotation.*;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.support.AnnotationConsumer;
+
+/** List all files in a directory and provide each one's path as an argument. */
+@Documented
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@ArgumentsSource(DirectoryArgumentsProvider.class)
+public @interface DirectorySource {
+ /** Filter files by glob applied to the filename. Default is to not filter. */
+ @Nullable String glob();
+
+ /** Whether to include directories. Default is to not. */
+ boolean includeDirs() default false;
+
+ /** Whether to relativize the paths to {@link #root}. Default is to not. */
+ boolean relativize() default false;
+
+ /**
+ * Specify a number > 1 to include files in subdirectories. Specify INT_MAX to recurse infinitely.
+ * Defaults to 1.
+ */
+ int depth() default 1;
+
+ /**
+ * Directory to list files from, defaults to corresponding resources directory of the test
+ * directory. Paths will be relative to this default unless they start with {@code /}
+ */
+ String root() default "";
+
+ /** Paths to exclude. */
+ String[] exclude() default {};
+}
+
+class DirectoryArgumentsProvider implements ArgumentsProvider, AnnotationConsumer {
+ private boolean accepted = false;
+ private @Nullable String glob;
+ private boolean includeDirs;
+ private boolean relativize;
+ private int depth;
+ private String root = "";
+ private ImmutableSet exclude = ImmutableSet.of();
+
+ @Override
+ public void accept(DirectorySource directorySource) {
+ accepted = true;
+ glob = directorySource.glob();
+ includeDirs = directorySource.includeDirs();
+ relativize = directorySource.relativize();
+ root = directorySource.root();
+ depth = directorySource.depth();
+ exclude = ImmutableSet.copyOf(directorySource.exclude());
+ }
+
+ @Override
+ public Stream extends Arguments> provideArguments(ExtensionContext context) {
+ assert accepted;
+ var path = Tests.getResourcePath(context.getRequiredTestClass(), root);
+ return Files.listDir(path, glob, depth, includeDirs, relativize).stream()
+ .filter(p -> !exclude.contains(p.toString()))
+ .map(Arguments::of);
+ }
+}
diff --git a/src/test/java/org/prlprg/util/Files.java b/src/test/java/org/prlprg/util/Files.java
new file mode 100644
index 000000000..3a79994ef
--- /dev/null
+++ b/src/test/java/org/prlprg/util/Files.java
@@ -0,0 +1,150 @@
+package org.prlprg.util;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.Collection;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+public class Files {
+ /**
+ * @param root Directory to list files from
+ * @param glob Filter files by glob applied to the filename. Pass {@code null} to not filter.
+ * @param depth Specify a number > 1 to include files in subdirectories. Specify INT_MAX to
+ * recurse infinitely.
+ * @param includeDirs Whether to include directories.
+ * @param relativize Whether to relativize the paths to {@code root}.
+ * @return The paths of each of the children of {@code root} filtered by the other arguments. It
+ * doesn't return {@code root} itself.
+ */
+ public static Collection listDir(
+ Path root, @Nullable String glob, int depth, boolean includeDirs, boolean relativize) {
+ if (!isDirectory(root)) {
+ throw new IllegalArgumentException("Not a directory: " + root);
+ }
+
+ var globMatcher = glob == null ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);
+
+ try (var filesHandle = java.nio.file.Files.walk(root, depth)) {
+ var files = filesHandle.filter(file -> !file.equals(root));
+ if (!includeDirs) {
+ files = files.filter(Files::isRegularFile);
+ }
+ if (glob != null) {
+ files = files.filter(p -> globMatcher.matches(p.getFileName()));
+ }
+ if (relativize) {
+ files = files.map(root::relativize);
+ }
+ return files.collect(Collectors.toList());
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to list files in " + root, e);
+ }
+ }
+
+ public static void deleteRecursively(Path path) {
+ try (var subpaths = java.nio.file.Files.walk(path)) {
+ subpaths
+ .sorted(java.util.Comparator.reverseOrder())
+ .forEach(
+ subpath -> {
+ try {
+ java.nio.file.Files.delete(subpath);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Failed to recursively delete "
+ + path
+ + ", specifically on "
+ + path.relativize(subpath),
+ e);
+ }
+ });
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to delete recursively " + path, e);
+ }
+ }
+
+ public static boolean isOlder(Path lhs, Path rhs) {
+ return getLastModifiedTime(lhs).compareTo(getLastModifiedTime(rhs)) < 0;
+ }
+
+ public static Path pathFromFileUrl(URL url) {
+ try {
+ return Paths.get(url.toURI());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Not a file URL: " + url, e);
+ }
+ }
+
+ // region boilerplate wrappers which don't throw IOException
+ public static Path createTempDirectory(String prefix) {
+ try {
+ return java.nio.file.Files.createTempDirectory(prefix);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to create temp directory", e);
+ }
+ }
+
+ public static void createDirectories(Path path) {
+ try {
+ java.nio.file.Files.createDirectories(path);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to create directories", e);
+ }
+ }
+
+ public static FileTime getLastModifiedTime(Path path) {
+ try {
+ return java.nio.file.Files.getLastModifiedTime(path);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to get last modified time", e);
+ }
+ }
+
+ public static void writeString(Path path, CharSequence out) {
+ try {
+ java.nio.file.Files.writeString(path, out);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to write string to " + path, e);
+ }
+ }
+
+ public static String readString(Path path) {
+ try {
+ return java.nio.file.Files.readString(path);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read string from " + path, e);
+ }
+ }
+
+ @CanIgnoreReturnValue
+ public static boolean deleteIfExists(Path path) {
+ try {
+ return java.nio.file.Files.deleteIfExists(path);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to delete " + path, e);
+ }
+ }
+
+ public static boolean exists(Path path) {
+ return java.nio.file.Files.exists(path);
+ }
+
+ public static boolean isDirectory(Path path) {
+ return java.nio.file.Files.isDirectory(path);
+ }
+
+ public static boolean isRegularFile(Path path) {
+ return java.nio.file.Files.isRegularFile(path);
+ }
+
+ // endregion
+
+ private Files() {}
+}
diff --git a/src/test/java/org/prlprg/util/Paths.java b/src/test/java/org/prlprg/util/Paths.java
new file mode 100644
index 000000000..3af409a0b
--- /dev/null
+++ b/src/test/java/org/prlprg/util/Paths.java
@@ -0,0 +1,23 @@
+package org.prlprg.util;
+
+import java.nio.file.Path;
+
+public class Paths {
+ public static String getFileStem(Path path) {
+ var fileName = path.getFileName().toString();
+ var lastDot = fileName.lastIndexOf('.');
+ return lastDot == -1 ? fileName : fileName.substring(0, lastDot);
+ }
+
+ public static String getExtension(Path path) {
+ var fileName = path.getFileName().toString();
+ var lastDot = fileName.lastIndexOf('.');
+ return lastDot == -1 ? "" : fileName.substring(lastDot);
+ }
+
+ public static Path withExtension(Path path, String extension) {
+ return path.resolveSibling(getFileStem(path) + extension);
+ }
+
+ private Paths() {}
+}
diff --git a/src/test/java/org/prlprg/util/StructuralUtils.java b/src/test/java/org/prlprg/util/StructuralUtils.java
new file mode 100644
index 000000000..20559c3ff
--- /dev/null
+++ b/src/test/java/org/prlprg/util/StructuralUtils.java
@@ -0,0 +1,38 @@
+package org.prlprg.util;
+
+import java.util.regex.Pattern;
+
+public class StructuralUtils {
+ private static final Pattern HASH_PATTERN = Pattern.compile("@[0-9a-f]{1,16}");
+
+ /**
+ * Calls {@link Object#toString}, then replaces obvious references and hash-codes with
+ * deterministic values. This means that you can test that two objects are structurally equivalent
+ * by comparing their {@code printStructurally}.
+ *
+ * It uses heuristics to find and references and hash-codes, so it can't be relied on for
+ * any data-structures we can't or aren't willing to change the {@link Object#toString}
+ * representation of to make them pass the tests.
+ */
+ public static String printStructurally(Object object) {
+ var string = object.toString();
+ var hashMatcher = HASH_PATTERN.matcher(string);
+
+ // Get hashes in order of occurrence
+ var hashes = new java.util.LinkedHashSet();
+ while (hashMatcher.find()) {
+ hashes.add(hashMatcher.group());
+ }
+
+ // Replace each hash with its index
+ var index = 0;
+ for (var hash : hashes) {
+ string = string.replace(hash, "@HASH" + index);
+ index++;
+ }
+
+ return string;
+ }
+
+ private StructuralUtils() {}
+}
diff --git a/src/test/java/org/prlprg/util/SubTest.java b/src/test/java/org/prlprg/util/SubTest.java
new file mode 100644
index 000000000..18d8f7418
--- /dev/null
+++ b/src/test/java/org/prlprg/util/SubTest.java
@@ -0,0 +1,21 @@
+package org.prlprg.util;
+
+import org.opentest4j.AssertionFailedError;
+
+public class SubTest {
+ /** Runs the test and attaches {@code name} output and errors. */
+ public static void run(String name, ThrowingRunnable test) {
+ System.out.println("- " + name);
+ try {
+ test.run();
+ } catch (AssertionFailedError e) {
+ // Keep IntelliJ's ``
+ throw new AssertionFailedError(
+ "In " + name + ", " + e.getMessage(), e.getExpected(), e.getActual(), e.getCause());
+ } catch (Throwable e) {
+ throw new RuntimeException("Failed " + name, e);
+ }
+ }
+
+ private SubTest() {}
+}
diff --git a/src/test/java/org/prlprg/util/Tests.java b/src/test/java/org/prlprg/util/Tests.java
index b383b8875..c751fd44a 100644
--- a/src/test/java/org/prlprg/util/Tests.java
+++ b/src/test/java/org/prlprg/util/Tests.java
@@ -1,12 +1,124 @@
package org.prlprg.util;
+import static org.prlprg.util.TestsPrivate.SNAPSHOT_RESOURCES_ROOT;
+
+import java.io.IOException;
import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Arrays;
import java.util.Objects;
public interface Tests {
- default InputStream getResourceAsStream(String path) {
+ /** Reads the resource at {@code path}. {@code path} is relative to {@code anchor}'s package. */
+ static InputStream getResourceAsStream(Class> anchor, String path) {
+ return Objects.requireNonNull(
+ anchor.getResourceAsStream(path),
+ "Resource not found in " + anchor.getPackageName() + ": " + path);
+ }
+
+ /**
+ * The absolute path of the snapshot resource at {@code path}. {@code path} is relative to {@code
+ * anchor}'s package. Be aware that it may not exist, although parent directories will be
+ * created if necessary.
+ */
+ static Path getSnapshotPath(Class> anchor, String path) {
+ if (path.startsWith("/")) {
+ throw new IllegalArgumentException("getSnapshotPath doesn't support absolute paths: " + path);
+ }
+ var snapshotPath =
+ SNAPSHOT_RESOURCES_ROOT.resolve(anchor.getPackageName().replace('.', '/')).resolve(path);
+ Files.createDirectories(snapshotPath.getParent());
+ return snapshotPath;
+ }
+
+ /**
+ * The absolute path of the snapshot resource at {@code path}. {@code path} is relative to {@code
+ * anchor}'s package. Be aware that it may not exist, although parent directories will be
+ * created if necessary.
+ */
+ static Path getSnapshotPath(Class> anchor, Path path) {
+ return getSnapshotPath(anchor, path.toString());
+ }
+
+ /**
+ * The absolute path of the resource at {@code path}. {@code path} is relative to {@code anchor}'s
+ * package.
+ */
+ static Path getResourcePath(Class> anchor, String path) {
+ return Files.pathFromFileUrl(getResource(anchor, path));
+ }
+
+ /**
+ * The URL of the resource at {@code path}. {@code path} is relative to {@code anchor}'s package.
+ */
+ static URL getResource(Class> anchor, String path) {
return Objects.requireNonNull(
- getClass().getResourceAsStream(path),
- "Resource not found in " + getClass().getPackageName() + ": " + path);
+ anchor.getResource(path), "Resource not found in " + anchor.getPackageName() + ": " + path);
+ }
+
+ /** Reads the resource at {@code path}. {@code path} is relative to this class's package. */
+ default InputStream getResourceAsStream(String path) {
+ return getResourceAsStream(getClass(), path);
}
+
+ /**
+ * The absolute path of the resource at {@code path}. {@code path} is relative to this class's
+ * package.
+ */
+ default Path getResourcePath(String path) {
+ return getResourcePath(getClass(), path);
+ }
+
+ /**
+ * The absolute path of the resource at {@code path}. {@code path} is relative to this class's
+ * package.
+ */
+ default Path getResourcePath(Path path) {
+ return getResourcePath(path.toString());
+ }
+
+ /**
+ * The absolute path of the snapshot resource at {@code path}. {@code path} is relative to {@code
+ * anchor}'s package. Be aware that it may not exist, although parent directories will be
+ * created if necessary.
+ */
+ default Path getSnapshotPath(Path path) {
+ return getSnapshotPath(getClass(), path);
+ }
+
+ /** Run a command. */
+ default void cmd(Object... command) {
+ var commandStrs = Arrays.stream(command).map(Object::toString).toList();
+ var commandStr = String.join(" ", commandStrs);
+ try {
+ var process = new ProcessBuilder(commandStrs).inheritIO().start();
+ var exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new RuntimeException("Command failed with exit code " + exitCode + ": " + commandStr);
+ }
+ } catch (InterruptedException e) {
+ throw new RuntimeException("Command was interrupted: " + commandStr, e);
+ } catch (IOException e) {
+ throw new RuntimeException("Command failed with IO exception: " + commandStr, e);
+ }
+ }
+}
+
+class TestsPrivate {
+ static final Path SNAPSHOT_RESOURCES_ROOT = getSnapshotPathsRoot();
+
+ private static Path getSnapshotPathsRoot() {
+ var basePath = Files.pathFromFileUrl(ClassLoader.getSystemResource("."));
+ while (basePath != null && !basePath.endsWith("target")) {
+ basePath = basePath.getParent();
+ }
+ if (basePath == null) {
+ System.err.println("Can't infer snapshot resources root, so we'll use a temporary directory");
+ return Files.createTempDirectory(".snapshots");
+ }
+ return basePath.getParent().resolve(".snapshots");
+ }
+
+ private TestsPrivate() {}
}
diff --git a/src/test/java/org/prlprg/util/ThrowingRunnable.java b/src/test/java/org/prlprg/util/ThrowingRunnable.java
new file mode 100644
index 000000000..dba34e018
--- /dev/null
+++ b/src/test/java/org/prlprg/util/ThrowingRunnable.java
@@ -0,0 +1,7 @@
+package org.prlprg.util;
+
+/** {@link Runnable} that may throw an error or exception. */
+@FunctionalInterface
+public interface ThrowingRunnable {
+ void run() throws Throwable;
+}
diff --git a/src/test/resources/org/prlprg/bc/basics.R b/src/test/resources/org/prlprg/bc/basics.R
new file mode 100644
index 000000000..1e04a8570
--- /dev/null
+++ b/src/test/resources/org/prlprg/bc/basics.R
@@ -0,0 +1,3 @@
+arithmetic <- function(n) n + 1
+calls <- function(a, b, c) f(a+b, length(b), c)
+conditions <- function(n) if (n > 0) n else if (n < 0) -n else 0
\ No newline at end of file