diff --git a/.changeset/nrw-abitur-list.md b/.changeset/nrw-abitur-list.md new file mode 100644 index 00000000..09489b29 --- /dev/null +++ b/.changeset/nrw-abitur-list.md @@ -0,0 +1,5 @@ +--- +type: minor +--- + +Add an NRW version of the library, `scratch--nrw-all.jar`, for courses that use the classes of the NRW Zentralabitur. In it, `Stage.find`, `Stage.getAll` and `Sprite.getTouchingSprites` return the Abitur's `List` instead of `java.util.List`, so students can write `List gegner = find(Gegner.class);`. `List.java` from the Abitur classes has to be in the project's default package. The normal JAR is unchanged. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4128edc0..66e2630e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,15 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - - name: Build fat jar + - name: Build NRW jar + # The NRW build returns the List of the NRW Zentralabitur from find, + # getAll and getTouchingSprites. It is only offered as a download, and + # the next step cleans target/, so the jars are put aside first. + run: | + mvn -B clean package -Pall,nrw + mkdir -p dist + cp target/*-nrw-all.jar target/*-nrw-sources.jar dist/ + # Tests already ran as part of the deploy step above; skip them here # to avoid paying for the full suite twice in the same job. run: mvn -B clean package -Pall -DskipTests @@ -66,6 +74,7 @@ jobs: body: ${{ github.event.pull_request.body || format('Automated release of version {0}', env.VERSION) }} files: | target/*.jar + dist/*.jar - name: Delete changeset branch run: | diff --git a/CLAUDE.md b/CLAUDE.md index 34a54af7..21aef296 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ keeping the same mental model (Window / Stage / Sprite, costumes, backdrops, bro - `mvn test` — run the JUnit 5 test suite (152 tests) - `mvn clean package` — build the JAR - `mvn clean package -Pall` — build a standalone JAR with all dependencies shaded in (maven-shade-plugin) +- `mvn clean package -Pall,nrw` — build the NRW Abitur version (`target/scratch--nrw-all.jar`), in which `find`, `getAll` and `getTouchingSprites` return the `List` of the NRW Zentralabitur. The sources are rewritten from the `// nrw-standard-begin` / `// nrw:` markers in `Stage.java` and `Sprite.java` by `src/tools/java/variants/NrwVariant.java`; tests, examples and docs are skipped in that build - `mvn deploy -Pcentral` — release to Maven Central (GPG signing + central-publishing-maven-plugin); not something to run casually - `./build.sh` — full doc site build: copies CHANGELOG into the docs book, regenerates reference GIFs from `src/examples/java/reference`, substitutes the version into docs, then runs `npx hyperbook build` inside `docs/` - `cd docs && npx hyperbook dev` — run the documentation site locally diff --git a/docs/book/abitur-nrw.md b/docs/book/abitur-nrw.md new file mode 100644 index 00000000..1d0f4b72 --- /dev/null +++ b/docs/book/abitur-nrw.md @@ -0,0 +1,168 @@ +--- +name: Abiturklassen NRW +index: 5 +--- + +# Abiturklassen NRW + +In North Rhine-Westphalia the Zentralabitur in computer science comes with its +own classes: `List`, `Queue`, `Stack`, `BinaryTree`, `BinarySearchTree`, +`ComparableContent`, `Graph`, `Vertex`, `Edge`, `DatabaseConnector` and +`QueryResult`. Students work with them throughout the Oberstufe, and they are the +classes the Abitur tasks are written against. + +You can use them together with Scratch for Java. The Abiturklassen then hold the +sprites of your game, for example all the enemies on the stage in a `List`. + +## Two versions of Scratch for Java + +Scratch for Java comes in two versions. They differ in three methods, which hand +you a list of sprites: + +| JAR | `find`, `getAll` and `getTouchingSprites` return | +| ------------------------------- | ------------------------------------------------ | +| `scratch--all.jar` | Java's own `java.util.List` | +| `scratch--nrw-all.jar` | the `List` of the Abiturklassen | + +Everything else is the same in both. If your course uses the Abiturklassen, take +the NRW version from the [Download](/download) page. + +## Setting up a project + +1. Download `scratch--nrw-all.jar` from the [Download](/download) page + and add it to your project, the same way as described in [Setup](/setup). In + BlueJ, for example, put it into the `+libs` folder of your project. +2. Get the Abiturklassen. QUA-LiS NRW publishes them on the Lehrplannavigator as + [Implementationen von Klassen für das Zentralabitur ab 2018](https://lehrplannavigator.nrw.de/system/files/media/document/file/2020-03-11_implementationen_von_klassen_fuer_das_zentralabitur_ab_2018.zip) + (ZIP). Your teacher may also hand them out. +3. Copy the `.java` files you need into your project, next to your own classes. + `List.java` is the one Scratch for Java needs. Take the others as your course + uses them. `Graph.java` needs `List.java`, and `DatabaseConnector.java` needs + `Queue.java` and `QueryResult.java`. + +A BlueJ project then looks like this: + +```text +MeinSpiel/ + +libs/ + scratch--nrw-all.jar + List.java <- from the Abiturklassen + Queue.java <- from the Abiturklassen, if you use it + MyStage.java <- your classes + Gegner.java + Muenze.java +``` + +Do not put the Abiturklassen into a package, and do not put them into `+libs`. +They have to sit next to your own classes. Do not change the files either. Use +them exactly as they come. + +## Using the List + +With the NRW version, write the Abitur's `List` on the left, and the sprites come +back in it. + +In a stage, `find` gives you all sprites of one class: + +```java +List gegner = find(Gegner.class); +gegner.toFirst(); +while (gegner.hasAccess()) { + gegner.getContent().changeY(-5); + gegner.next(); +} +``` + +`getAll` gives you every sprite on the stage: + +```java +List alle = getAll(); +alle.toFirst(); +while (alle.hasAccess()) { + alle.getContent().hide(); + alle.next(); +} +``` + +In a sprite, `getTouchingSprites` gives you the sprites it is touching right now: + +```java +List muenzen = getTouchingSprites(Muenze.class); +muenzen.toFirst(); +while (muenzen.hasAccess()) { + getStage().remove(muenzen.getContent()); + muenzen.next(); +} +``` + +Each call returns a new list. Adding to it or removing from it does not add +sprites to the stage or remove them. Use `add` and `remove` on the stage for that, +as in the last example. + +## Using the other Abiturklassen + +The other Abiturklassen need nothing special. Create them yourself and put sprites +into them like any other object. A `Queue` lets enemies take turns, for example: + +```java +Queue warteschlange = new Queue<>(); + +List gegner = find(Gegner.class); +gegner.toFirst(); +while (gegner.hasAccess()) { + warteschlange.enqueue(gegner.getContent()); + gegner.next(); +} + +// later, whenever it is the next enemy's turn +Gegner amZug = warteschlange.front(); +warteschlange.dequeue(); +amZug.move(20); +warteschlange.enqueue(amZug); +``` + +## How it works + +The Abiturklassen are **not** part of Scratch for Java. The NRW version only +expects a class called `List` in your project. Whenever `find`, `getAll` or +`getTouchingSprites` is called, it looks that class up, creates an empty list +with `new List()`, adds the sprites with `append`, and hands the list to you. + +That is why `List.java` has to be in your project, and why it works with the files +exactly as QUA-LiS publishes them. It is always your copy of the Abiturklassen +that is used, never a different one. + +## Troubleshooting + +**`incompatible types: java.util.List<…> cannot be converted to List<…>`** + +You are using the normal JAR. Replace it with `scratch--nrw-all.jar`. + +**`There is no class List in your project.`** + +`List.java` is missing, is in a package, or is in `+libs`. Copy it next to your +own classes. + +**`var` does not work** + +`var gegner = find(Gegner.class);` does not tell Java which list you want. Write +the type out: `List gegner = find(Gegner.class);`. + +**The examples in the reference do not compile** + +The examples in the reference are written for the normal version, for example +`find(Coin.class).size()`. With the NRW version, walk through the list with +`toFirst`, `hasAccess` and `next` instead. + +## Using the Abiturklassen with the normal version + +You can also keep the normal JAR and still use the Abiturklassen for everything +else. Then `find`, `getAll` and `getTouchingSprites` return Java's list. The +name `List` means the Abitur's list, so write Java's list with its full name: + +```java +java.util.List gegner = find(Gegner.class); +for (Gegner g : gegner) { + g.hide(); +} +``` diff --git a/docs/book/download.md b/docs/book/download.md index f1eb253e..d78cf26e 100644 --- a/docs/book/download.md +++ b/docs/book/download.md @@ -28,6 +28,16 @@ If you also need the JavaDoc, i.e., the documentation of the classes, it can be It is best to add all three JAR files so that autocomplete and documentation lookup work correctly in your IDE. +### NRW Abitur version + +If your course uses the classes of the NRW Zentralabitur, there is a version in +which `find`, `getAll` and `getTouchingSprites` return their `List`. See +[Abiturklassen NRW](/abitur-nrw). + +::download[All Operating Systems (NRW)]{src="https://github.com/openpatch/scratch-for-java/releases/latest/download/scratch-{{VERSION}}-nrw-all.jar"} + +::download[Sources (NRW)]{src="https://github.com/openpatch/scratch-for-java/releases/latest/download/scratch-{{VERSION}}-nrw-sources.jar"} + ## Maven Central ```xml diff --git a/docs/book/setup.md b/docs/book/setup.md index 18c9dd45..7212cf4d 100644 --- a/docs/book/setup.md +++ b/docs/book/setup.md @@ -5,6 +5,12 @@ index: 2 # Setup +:::alert{info} +Does your course use the Abiturklassen of the NRW Zentralabitur (`List`, `Queue`, +`Graph`, …)? Then read [Abiturklassen NRW](/abitur-nrw) first. There is a version +of Scratch for Java made for them. +::: + ## VS Code (Recommended) First make sure you have the Java Extension Pack installed. You can find it in the Extensions view by searching for `redhat.java`. diff --git a/pom.xml b/pom.xml index dac72ae0..830389a7 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,9 @@ 3.2.1 5.11.4 4.4.0 + + src/main/java + all @@ -221,7 +224,7 @@ false true - all + ${scratch.allClassifier} + + nrw + + ${project.build.directory}/nrw-sources + nrw-all + true + true + + + ${project.artifactId}-${project.version}-nrw + + + org.codehaus.mojo + exec-maven-plugin + + + generate-nrw-sources + generate-sources + + exec + + + ${java.home}/bin/java + ${project.basedir} + + src/tools/java/variants/NrwVariant.java + src/main/java + ${project.build.directory}/nrw-sources + + + + + generate-asset-pages + none + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-examples-source + none + + + + + org.apache.maven.plugins + maven-clean-plugin + + + clean-reference-docs + none + + + + + + + ${scratch.sourceDirectory} @@ -265,6 +339,7 @@ demos/** reference/** tools/** + variants/** @@ -434,7 +509,7 @@ - demos.*:demos:reference.*:reference,doclets.*:doclets,docs.*:docs + demos.*:demos:reference.*:reference,doclets.*:doclets,docs.*:docs,variants.*:variants all,-missing diff --git a/src/main/java/org/openpatch/scratch/Sprite.java b/src/main/java/org/openpatch/scratch/Sprite.java index 05db4f0e..648cc0e0 100644 --- a/src/main/java/org/openpatch/scratch/Sprite.java +++ b/src/main/java/org/openpatch/scratch/Sprite.java @@ -7,6 +7,7 @@ import java.util.stream.Collectors; import org.openpatch.scratch.internal.Utils; import org.openpatch.scratch.extensions.shader.Shaders; +import org.openpatch.scratch.internal.NrwList; import org.openpatch.scratch.internal.Image; import org.openpatch.scratch.internal.Sound; import org.openpatch.scratch.internal.Stamp; @@ -1716,6 +1717,7 @@ public T getTouchingSprite(Class c) { .orElse(null); } + // nrw-standard-begin /** * Returns a list of sprites of the specified type that are currently touching * this sprite. @@ -1729,6 +1731,30 @@ public T getTouchingSprite(Class c) { * @example.files SpriteGetTouchingSprites.java */ public List getTouchingSprites(Class c) { + return this.touchingSprites(c); + } + // nrw-standard-end + // nrw: /** + // nrw: * Returns a list of sprites of the specified type that are currently touching + // nrw: * this sprite. + // nrw: * + // nrw: *

+ // nrw: * The list is the {@code List} of the NRW Zentralabitur, which has to be in + // nrw: * your project next to your own classes. + // nrw: * + // nrw: * @param the type of sprites to return + // nrw: * @param your {@code List} type + // nrw: * @param c the class of the type of sprites to return + // nrw: * @return a new list of the touching sprites, or null if the stage is not set + // nrw: */ + // nrw: public L getTouchingSprites(Class c) { + // nrw: List touching = this.touchingSprites(c); + // nrw: if (touching == null) + // nrw: return null; + // nrw: return NrwList.of(touching, c, this.getClass()); + // nrw: } + + private List touchingSprites(Class c) { if (stage == null) return null; return this.stage.sprites.stream() diff --git a/src/main/java/org/openpatch/scratch/Stage.java b/src/main/java/org/openpatch/scratch/Stage.java index ffcc29e0..4b483889 100644 --- a/src/main/java/org/openpatch/scratch/Stage.java +++ b/src/main/java/org/openpatch/scratch/Stage.java @@ -15,6 +15,7 @@ import org.openpatch.scratch.internal.Applet; import org.openpatch.scratch.internal.Font; import org.openpatch.scratch.internal.Image; +import org.openpatch.scratch.internal.NrwList; import org.openpatch.scratch.internal.Sound; import org.openpatch.scratch.internal.Stamp; import org.openpatch.scratch.internal.StageAccess; @@ -396,6 +397,7 @@ public void add(Pen pen) { + // nrw-standard-begin /** * Retrieves a list of all sprites in the current stage. * @@ -406,6 +408,20 @@ public void add(Pen pen) { public List getAll() { return new CopyOnWriteArrayList<>(this.sprites); } + // nrw-standard-end + // nrw: /** + // nrw: * Retrieves a list of all sprites in the current stage. + // nrw: * + // nrw: *

+ // nrw: * The list is the {@code List} of the NRW Zentralabitur, which has to be in + // nrw: * your project next to your own classes. + // nrw: * + // nrw: * @param your {@code List} type + // nrw: * @return a new list containing all sprites + // nrw: */ + // nrw: public L getAll() { + // nrw: return NrwList.of(new CopyOnWriteArrayList<>(this.sprites), this.getClass()); + // nrw: } /** * Removes the specified sprite from the stage. @@ -554,6 +570,7 @@ public void remove(Class c) { this.sprites.removeIf(c::isInstance); } + // nrw-standard-begin /** * Find sprites of a given class. * @@ -564,6 +581,25 @@ public void remove(Class c) { public List find(Class c) { return this.sprites.stream().filter(c::isInstance).map(c::cast).collect(Collectors.toList()); } + // nrw-standard-end + // nrw: /** + // nrw: * Find sprites of a given class. + // nrw: * + // nrw: *

+ // nrw: * The list is the {@code List} of the NRW Zentralabitur, which has to be in + // nrw: * your project next to your own classes. + // nrw: * + // nrw: * @param c Class + // nrw: * @param the type of sprites to find + // nrw: * @param your {@code List} type + // nrw: * @return a new list containing the sprites of that class + // nrw: */ + // nrw: public L find(Class c) { + // nrw: return NrwList.of( + // nrw: this.sprites.stream().filter(c::isInstance).map(c::cast).collect(Collectors.toList()), + // nrw: c, + // nrw: this.getClass()); + // nrw: } /** * Returns the number of sprites of the specified class. diff --git a/src/main/java/org/openpatch/scratch/internal/NrwList.java b/src/main/java/org/openpatch/scratch/internal/NrwList.java new file mode 100644 index 00000000..c3f71ba5 --- /dev/null +++ b/src/main/java/org/openpatch/scratch/internal/NrwList.java @@ -0,0 +1,106 @@ +package org.openpatch.scratch.internal; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.openpatch.scratch.ScratchException; + +/** + * Builds a {@code List} of the kind the NRW Zentralabitur hands out. + * + *

+ * Those classes live in the default package of the student's project, where a + * library cannot name them. So the class is looked up by name when it is + * needed, through the class loader of the student's own classes, and filled + * through its {@code append} method. The caller hands the result back with a + * type the compiler infers from the assignment, which is how + * {@code List gegner = find(Gegner.class)} compiles in the NRW build. + */ +public final class NrwList { + + private static final String CLASS_NAME = "List"; + + private NrwList() {} + + /** + * Returns a new NRW {@code List} holding the given items, in order. + * + * @param items the items to append + * @param hints classes of the student's project, whose class loader also + * loaded their {@code List}; the context class loader is tried + * after them + * @param the student's {@code List} type, inferred at the call site + * @return the filled list + */ + public static L of(Iterable items, Class... hints) { + return of(items, CLASS_NAME, hints); + } + + @SuppressWarnings("unchecked") + static L of(Iterable items, String className, Class... hints) { + Class listClass = find(className, hints); + try { + Constructor constructor = listClass.getDeclaredConstructor(); + Method append = listClass.getMethod("append", Object.class); + constructor.setAccessible(true); + append.setAccessible(true); + Object list = constructor.newInstance(); + for (Object item : items) { + append.invoke(list, item); + } + return (L) list; + } catch (NoSuchMethodException e) { + return fail( + "Your class " + className + " is not the List from the NRW Abitur.", + "It needs a constructor without parameters and a method append(ContentType)."); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new ScratchException("Could not fill the List: " + cause); + } catch (ReflectiveOperationException e) { + throw new ScratchException("Could not create the List: " + e); + } + } + + private static Class find(String className, Class... hints) { + Set loaders = new LinkedHashSet<>(); + for (Class hint : hints) { + if (hint != null && hint.getClassLoader() != null) { + loaders.add(hint.getClassLoader()); + } + } + ClassLoader context = Thread.currentThread().getContextClassLoader(); + if (context != null) { + loaders.add(context); + } + loaders.add(NrwList.class.getClassLoader()); + + for (ClassLoader loader : loaders) { + try { + return Class.forName(className, false, loader); + } catch (ClassNotFoundException ignored) { + // try the next loader + } + } + return fail( + "There is no class " + className + " in your project.", + "Copy List.java from the NRW Abitur classes into your project, next to your" + + " own classes (not into a package). If you do not use the Abitur" + + " classes, use the normal Scratch for Java JAR instead of the NRW one."); + } + + private static T fail(String problem, String tip) { + System.err.println("\n=============================================="); + System.err.println("ERROR: Could not create an NRW List!"); + System.err.println("=============================================="); + System.err.println(problem); + System.err.println("\nTip: " + tip); + System.err.println("==============================================\n"); + throw new ScratchException(problem); + } +} diff --git a/src/test/java/org/openpatch/scratch/internal/NrwListTest.java b/src/test/java/org/openpatch/scratch/internal/NrwListTest.java new file mode 100644 index 00000000..240e3860 --- /dev/null +++ b/src/test/java/org/openpatch/scratch/internal/NrwListTest.java @@ -0,0 +1,65 @@ +package org.openpatch.scratch.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.openpatch.scratch.ScratchException; + +class NrwListTest { + + /** Stands in for the Abitur's List, which lives in the default package. */ + static class AbiturList { + final java.util.List appended = new ArrayList<>(); + + public void append(ContentType pContent) { + appended.add(pContent); + } + } + + static class NotAnAbiturList {} + + private static final String ABITUR_LIST = AbiturList.class.getName(); + + @Test + void appendsEveryItemInOrder() { + AbiturList list = NrwList.of(Arrays.asList("a", "b", "c"), ABITUR_LIST, NrwListTest.class); + + assertEquals(Arrays.asList("a", "b", "c"), list.appended); + } + + @Test + void returnsAnEmptyListForNoItems() { + AbiturList list = NrwList.of(new ArrayList(), ABITUR_LIST, NrwListTest.class); + + assertEquals(0, list.appended.size()); + } + + @Test + void findsTheClassThroughTheContextClassLoaderWithoutHints() { + Object list = NrwList.of(Arrays.asList(1), ABITUR_LIST); + + assertInstanceOf(AbiturList.class, list); + } + + @Test + void failsWhenThereIsNoListInTheProject() { + ScratchException e = assertThrows(ScratchException.class, + () -> NrwList.of(Arrays.asList(1), "NoSuchList", NrwListTest.class)); + + assertEquals("There is no class NoSuchList in your project.", e.getMessage()); + } + + @Test + void failsWhenTheListHasNoAppend() { + String name = NotAnAbiturList.class.getName(); + ScratchException e = assertThrows(ScratchException.class, + () -> NrwList.of(Arrays.asList(1), name, NrwListTest.class)); + + assertEquals("Your class " + name + " is not the List from the NRW Abitur.", e.getMessage()); + } +} diff --git a/src/test/java/variants/NrwVariantTest.java b/src/test/java/variants/NrwVariantTest.java new file mode 100644 index 00000000..deb4139b --- /dev/null +++ b/src/test/java/variants/NrwVariantTest.java @@ -0,0 +1,139 @@ +package variants; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NrwVariantTest { + + @Test + void swapsTheStandardVersionForTheNrwOne() { + String source = String.join("\n", + "class A {", + " // nrw-standard-begin", + " public List getAll() {", + " return sprites;", + " }", + " // nrw-standard-end", + " // nrw: public L getAll() {", + " // nrw: return NrwList.of(sprites);", + " // nrw:", + " // nrw: }", + "}"); + + assertEquals(String.join("\n", + "class A {", + " public L getAll() {", + " return NrwList.of(sprites);", + "", + " }", + "}"), NrwVariant.rewrite(source, "A.java")); + } + + @Test + void leavesFilesWithoutMarkersAlone() { + String source = "class A {\n // a comment\n void a() {}\n}\n"; + + assertEquals(source, NrwVariant.rewrite(source, "A.java")); + } + + @Test + void refusesAnUnclosedStandardBlock() { + assertThrows(IllegalStateException.class, + () -> NrwVariant.rewrite("// nrw-standard-begin\nclass A {}\n", "A.java")); + } + + @Test + void refusesAnEndWithoutABeginning() { + assertThrows(IllegalStateException.class, + () -> NrwVariant.rewrite("class A {}\n// nrw-standard-end\n", "A.java")); + } + + /** + * Builds the NRW library from the real sources and compiles a student's + * program against it, with the Abitur's List in the default package. This is + * what a teacher gets from the nrw jar, so it is what has to keep working. + */ + @Test + void studentsGetTheAbiturListFromTheNrwBuild(@TempDir Path dir) throws IOException { + Path sources = dir.resolve("nrw-sources"); + NrwVariant.write(Path.of("src", "main", "java"), sources); + Path library = dir.resolve("library"); + List libraryFiles; + try (Stream walk = Files.walk(sources)) { + libraryFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + assertEquals("", compile(libraryFiles, library, System.getProperty("java.class.path"))); + + Path student = dir.resolve("student"); + Files.createDirectories(student); + Files.writeString(student.resolve("List.java"), String.join("\n", + "public class List {", + " public void append(ContentType pContent) {}", + "}"), StandardCharsets.UTF_8); + Files.writeString(student.resolve("Gegner.java"), String.join("\n", + "import org.openpatch.scratch.*;", + "public class Gegner extends Sprite {", + " public void run() {", + " List touching = getTouchingSprites(Gegner.class);", + " }", + "}"), StandardCharsets.UTF_8); + Files.writeString(student.resolve("MyStage.java"), String.join("\n", + "import org.openpatch.scratch.*;", + "public class MyStage extends Stage {", + " public void run() {", + " List gegner = find(Gegner.class);", + " List alle = getAll();", + " }", + "}"), StandardCharsets.UTF_8); + List studentFiles = List.of( + student.resolve("List.java"), student.resolve("Gegner.java"), student.resolve("MyStage.java")); + + assertEquals("", compile(studentFiles, dir.resolve("student-classes"), + library + File.pathSeparator + System.getProperty("java.class.path"))); + } + + private static String compile(List files, Path out, String classpath) throws IOException { + Files.createDirectories(out); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertTrue(compiler != null, "needs a JDK"); + var diagnostics = new DiagnosticCollector(); + try (StandardJavaFileManager manager = + compiler.getStandardFileManager(diagnostics, Locale.ENGLISH, StandardCharsets.UTF_8)) { + var options = List.of("-d", out.toString(), "-nowarn", "-proc:none", "-classpath", classpath); + boolean ok = compiler + .getTask(null, manager, diagnostics, options, null, + manager.getJavaFileObjectsFromPaths(files)) + .call(); + if (ok) { + return ""; + } + } + return diagnostics.getDiagnostics().stream() + .filter(d -> d.getKind() == Diagnostic.Kind.ERROR) + .map(d -> d.getSource().getName().replaceAll(".*/", "") + + ":" + d.getLineNumber() + " " + d.getMessage(Locale.ENGLISH)) + .limit(6) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/tools/java/variants/NrwVariant.java b/src/tools/java/variants/NrwVariant.java new file mode 100644 index 00000000..9305157c --- /dev/null +++ b/src/tools/java/variants/NrwVariant.java @@ -0,0 +1,127 @@ +package variants; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Writes the sources of the NRW build of the library. + * + *

+ * The NRW build is the same library, except that the methods handing out a list + * of sprites hand out the {@code List} of the NRW Zentralabitur instead of + * {@code java.util.List}. Java cannot overload on the return type, so both + * versions of such a method are written into the one source file: + * + *

+ *   // nrw-standard-begin
+ *   public List<Sprite> getAll() { ... }
+ *   // nrw-standard-end
+ *   // nrw: public <L> L getAll() { ... }
+ * 
+ * + * The normal build compiles the file as it is, where the NRW version is a + * comment. This copies the sources and swaps the two: the standard version is + * dropped and the {@code // nrw:} prefix removed. + * + *

+ * It runs in the {@code nrw} profile of the pom, before compiling, through the + * java launcher, since nothing has been compiled at that point: + * {@code java src/tools/java/variants/NrwVariant.java src/main/java target/nrw-sources}. + */ +public final class NrwVariant { + + private static final String BEGIN = "// nrw-standard-begin"; + private static final String END = "// nrw-standard-end"; + private static final Pattern NRW_LINE = Pattern.compile("^(\\s*)// nrw:(?: (.*))?$"); + + private NrwVariant() {} + + public static void main(String[] args) throws IOException { + if (args.length != 2) { + System.err.println("Usage: java NrwVariant.java "); + System.exit(2); + } + write(Path.of(args[0]), Path.of(args[1])); + } + + /** + * Copies every file below {@code from} to {@code to}, rewriting the Java + * sources into their NRW version. Whatever was in {@code to} is removed first. + */ + public static void write(Path from, Path to) throws IOException { + if (Files.exists(to)) { + try (Stream walk = Files.walk(to)) { + for (Path p : (Iterable) walk.sorted((a, b) -> b.compareTo(a))::iterator) { + Files.delete(p); + } + } + } + try (Stream walk = Files.walk(from)) { + walk.filter(Files::isRegularFile).forEach(file -> { + Path target = to.resolve(from.relativize(file).toString()); + try { + Files.createDirectories(target.getParent()); + if (file.toString().endsWith(".java")) { + String source = Files.readString(file, StandardCharsets.UTF_8); + Files.writeString(target, rewrite(source, file.toString()), StandardCharsets.UTF_8); + } else { + Files.copy(file, target); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + /** + * Returns the NRW version of one source file. + * + * @param source the file's content + * @param name the file's name, for the error messages + */ + public static String rewrite(String source, String name) { + List out = new ArrayList<>(); + boolean inStandard = false; + int lineNumber = 0; + for (String line : source.split("\n", -1)) { + lineNumber++; + String trimmed = line.trim(); + if (trimmed.equals(BEGIN)) { + if (inStandard) { + throw new IllegalStateException(name + ":" + lineNumber + ": nested " + BEGIN); + } + inStandard = true; + continue; + } + if (trimmed.equals(END)) { + if (!inStandard) { + throw new IllegalStateException(name + ":" + lineNumber + ": " + END + " without " + BEGIN); + } + inStandard = false; + continue; + } + if (inStandard) { + continue; + } + Matcher nrw = NRW_LINE.matcher(line); + if (nrw.matches()) { + out.add(nrw.group(2) == null ? "" : nrw.group(1) + nrw.group(2)); + } else { + out.add(line); + } + } + if (inStandard) { + throw new IllegalStateException(name + ": " + BEGIN + " without " + END); + } + return String.join("\n", out); + } +}