From 8eaafa3c694d6c6bb0f23e5cd566964c2b1354be Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 20 Aug 2026 11:49:39 -0400 Subject: [PATCH] Object fixes / PS --- .../src/main/java/art/arcane/iris/Iris.java | 3 +- .../iris/core/IrisWorldGeneratorResolver.java | 12 +- .../iris/core/commands/CommandObject.java | 40 +++- .../iris/core/commands/ObjectPasteBounds.java | 57 ++++++ .../core/IrisWorldGeneratorResolverTest.java | 45 ++++- .../CommandObjectPasteRaycastTest.java | 28 +++ .../core/commands/ObjectPasteBoundsTest.java | 53 +++++ .../iris/modded/ModdedWorkspaceGenerator.java | 37 ++-- .../modded/command/ModdedStudioCommands.java | 13 +- .../iris/core/loader/ResourceLoader.java | 17 ++ .../iris/core/project/IrisCodeWorkspace.java | 187 ++++++++---------- .../core/project/IrisCodeWorkspaceTest.java | 41 ++++ 12 files changed, 401 insertions(+), 132 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/ObjectPasteBounds.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectPasteRaycastTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/ObjectPasteBoundsTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 58f521cde..e0f417f2d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -1222,8 +1222,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return generatorResolver.resolveDefaultBiomeProvider(worldName, id, () -> super.getDefaultBiomeProvider(worldName, id)); } + @Nullable @Override - public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { + public ChunkGenerator getDefaultWorldGenerator(@NotNull String worldName, @Nullable String id) { return generatorResolver.resolveDefaultWorldGenerator(worldName, id); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index cbb8dd2db..534c099a1 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -62,6 +62,7 @@ public final class IrisWorldGeneratorResolver { private static final int VALIDATION_STABILITY_ATTEMPTS = 2; private static final Object SNAPSHOT_VALIDATION_LOCK = new Object(); private static final String IRIS_DIMENSION_NAMESPACE = "iris"; + private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator"; private final VolmitPlugin plugin; @@ -340,7 +341,12 @@ public final class IrisWorldGeneratorResolver { return fallback.get(); } - public ChunkGenerator resolveDefaultWorldGenerator(String worldName, String id) { + @Nullable + public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) { + if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) { + Iris.debug("Ignoring PlotSquared generator discovery probe"); + return null; + } if (isGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Generator discovery probe for loaded world " + worldName); return new IrisProbeChunkGenerator(worldName); @@ -370,6 +376,10 @@ public final class IrisWorldGeneratorResolver { } } + private static boolean isPlotSquaredGeneratorDiscoveryProbe(String worldName, String id) { + return PLOT_SQUARED_DISCOVERY_WORLD.equals(worldName) && id != null && id.isEmpty(); + } + /** * Multiverse-Core probes every enabled plugin by asking for a generator with an empty dimension * id and the name of a world that is already loaded. Bukkit never creates a world that is diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index f564659d7..e12d3e682 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -42,7 +42,6 @@ import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.data.Cuboid; import art.arcane.iris.util.common.data.IrisCustomData; -import art.arcane.iris.util.common.data.registry.Materials; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -89,6 +88,23 @@ import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; import art.arcane.iris.core.localization.RuntimeUiMessages; @Director(name = "object", aliases = "o", origin = DirectorOrigin.PLAYER, description = "Iris object manipulation", descriptionKey = "iris.director.commandobject.director.iris_object_manipulation") public class CommandObject implements DirectorExecutor { + static final Set PASTE_TRANSPARENT_BLOCKS = Set.of( + Material.AIR, + Material.CAVE_AIR, + Material.VOID_AIR, + Material.SHORT_GRASS, + Material.SNOW, + Material.VINE, + Material.TORCH, + Material.DEAD_BUSH, + Material.POPPY, + Material.DANDELION + ); + + static boolean isPasteTarget(Material material) { + return !PASTE_TRANSPARENT_BLOCKS.contains(material); + } + @Director(description = "Open an object studio world (grid of every object; dimension optional, defaults to all packs)", descriptionKey = "iris.director.commandobject.director.open_object_studio_world_grid_every_object_dimension_optional_defaults_all_packs", sync = true) public void studio( @Param(defaultValue = "null", description = "Optional dimension whose object pack to lay out; omit to aggregate objects from every pack", descriptionKey = "iris.director.commandobject.param.optional_dimension_whose_object_pack_lay_out_omit_aggregate_objects_from_every", aliases = "dim", customHandler = NullableDimensionHandler.class) @@ -179,9 +195,6 @@ public class CommandObject implements DirectorExecutor { } } - private static final Set skipBlocks = Set.of(Materials.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH, - Material.POPPY, Material.DANDELION); - public static IObjectPlacer createPlacer(World world, Map futureBlockChanges, Engine targetEngine) { return new IObjectPlacer() { @Override @@ -545,15 +558,20 @@ public class CommandObject implements DirectorExecutor { scale = maxScale; } - sender().playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f); - IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setRotation(IrisObjectRotation.of(0, rotate, 0)); VolmitSender commandSender = sender(); Player player = player(); ItemStack wand = player.getInventory().getItemInMainHand(); - Location block = player.getTargetBlock(skipBlocks, 256).getLocation().clone().add(0, 1, 0); + Block targetBlock = player.getTargetBlock(PASTE_TRANSPARENT_BLOCKS, 256); + if (!isPasteTarget(targetBlock.getType())) { + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_PLEASE_LOOK_AT_ANY_BLOCK_NOT_AT_SKY)); + return; + } + Location block = targetBlock.getLocation().clone().add(0, 1, 0); + + commandSender.playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f); Map futureChanges = new HashMap<>(); @@ -573,9 +591,11 @@ public class CommandObject implements DirectorExecutor { } onPlayerThread(player, () -> { - Vector center = new Vector(placed.getCenter().getX(), placed.getCenter().getY(), placed.getCenter().getZ()); - ItemStack newWand = WandSVC.createWand(block.clone().subtract(center).add(placed.getW() - 1, - placed.getH() + center.getY() - 1, placed.getD() - 1), block.clone().subtract(center.clone().setY(0))); + ObjectPasteBounds bounds = ObjectPasteBounds.resolve(placed, placement.getRotation(), block.getBlockX(), + block.getBlockY() + placed.getCenter().getBlockY(), block.getBlockZ()); + Location minimum = new Location(block.getWorld(), bounds.minX(), bounds.minY(), bounds.minZ()); + Location maximum = new Location(block.getWorld(), bounds.maxX(), bounds.maxY(), bounds.maxZ()); + ItemStack newWand = WandSVC.createWand(maximum, minimum); if (WandSVC.isWand(wand)) { player.getInventory().setItemInMainHand(newWand); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_OBJECT_UPDATED_WAND_OBJECTS_IOB, MessageArgument.untrusted("value", placed.getLoadKey()))); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/ObjectPasteBounds.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/ObjectPasteBounds.java new file mode 100644 index 000000000..b03e75cdf --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/ObjectPasteBounds.java @@ -0,0 +1,57 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.util.common.math.IrisBlockVector; + +import java.util.Objects; + +record ObjectPasteBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + static ObjectPasteBounds resolve( + IrisObject object, + IrisObjectRotation rotation, + int anchorX, + int anchorY, + int anchorZ + ) { + IrisObject placedObject = Objects.requireNonNull(object, "object"); + IrisObjectRotation placementRotation = Objects.requireNonNull(rotation, "rotation"); + int[] xOffsets = { + -placedObject.getCenter().getBlockX(), + placedObject.getW() - 1 - placedObject.getCenter().getBlockX() + }; + int[] yOffsets = { + -placedObject.getCenter().getBlockY(), + placedObject.getH() - 1 - placedObject.getCenter().getBlockY() + }; + int[] zOffsets = { + -placedObject.getCenter().getBlockZ(), + placedObject.getD() - 1 - placedObject.getCenter().getBlockZ() + }; + int minimumX = Integer.MAX_VALUE; + int minimumY = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumY = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + + for (int xOffset : xOffsets) { + for (int yOffset : yOffsets) { + for (int zOffset : zOffsets) { + IrisBlockVector transformed = placementRotation.rotate(new IrisBlockVector(xOffset, yOffset, zOffset)); + int worldX = anchorX + (int) Math.round(transformed.getX()); + int worldY = anchorY + (int) Math.round(transformed.getY()); + int worldZ = anchorZ + (int) Math.round(transformed.getZ()); + minimumX = Math.min(minimumX, worldX); + minimumY = Math.min(minimumY, worldY); + minimumZ = Math.min(minimumZ, worldZ); + maximumX = Math.max(maximumX, worldX); + maximumY = Math.max(maximumY, worldY); + maximumZ = Math.max(maximumZ, worldZ); + } + } + } + + return new ObjectPasteBounds(minimumX, minimumY, minimumZ, maximumX, maximumY, maximumZ); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index a98eb68b1..17d16ff52 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -26,6 +26,7 @@ import java.util.Random; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -231,7 +232,8 @@ public class IrisWorldGeneratorResolverTest { int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart); String resolver = source.substring(resolverStart, resolverEnd); - int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)"); + int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)"); + int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe); int readiness = resolver.indexOf("IrisStartupValidation.requireWorldCreationReady()"); int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)"); int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)"); @@ -241,7 +243,8 @@ public class IrisWorldGeneratorResolverTest { int shutdown = resolver.indexOf("Bukkit.shutdown()", report); int rethrow = resolver.indexOf("throw failure", shutdown); - assertTrue(probe >= 0); + assertTrue(plotSquaredProbe >= 0); + assertTrue(probe > plotSquaredProbe); assertTrue(readiness > probe); assertTrue(duplicateGuard > readiness); assertTrue(ownership > duplicateGuard); @@ -256,6 +259,44 @@ public class IrisWorldGeneratorResolverTest { assertEquals(shutdownScope, resolverStart + shutdown, source.lastIndexOf("Bukkit.shutdown()")); } + @Test + public void plotSquaredDiscoveryProbeIsIgnoredQuietly() { + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + bukkit.when(() -> Bukkit.getWorld("CheckingPlotSquaredGenerator")).thenReturn(null); + + ChunkGenerator probe = new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", ""); + + assertNull("Iris cannot be used as a PlotSquared base generator", probe); + bukkit.verify(Bukkit::shutdown, never()); + } + } + + @Test + public void plotSquaredSentinelWithDimensionIdUsesNormalOwnershipChecks() throws Exception { + File worldContainer = temporaryFolder.newFolder("plotsquared-non-probe"); + File levelRoot = new File(worldContainer, "world"); + assertTrue(levelRoot.mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + Server server = mock(Server.class); + when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld")); + + assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator")); + bukkit.verify(Bukkit::shutdown, never()); + } + } + @Test public void discoveryProbeOfLoadedWorldReturnsInertGeneratorQuietly() { try (MockedStatic bukkit = mockStatic(Bukkit.class); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectPasteRaycastTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectPasteRaycastTest.java new file mode 100644 index 000000000..87253a21b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectPasteRaycastTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.core.commands; + +import org.bukkit.Material; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CommandObjectPasteRaycastTest { + @Test + public void rejectsEveryAirVariantAndConfiguredFoliageAsPasteTargets() { + assertFalse(CommandObject.isPasteTarget(Material.AIR)); + assertFalse(CommandObject.isPasteTarget(Material.CAVE_AIR)); + assertFalse(CommandObject.isPasteTarget(Material.VOID_AIR)); + assertFalse(CommandObject.isPasteTarget(Material.SHORT_GRASS)); + assertFalse(CommandObject.isPasteTarget(Material.SNOW)); + assertFalse(CommandObject.isPasteTarget(Material.VINE)); + assertFalse(CommandObject.isPasteTarget(Material.TORCH)); + assertFalse(CommandObject.isPasteTarget(Material.DEAD_BUSH)); + assertFalse(CommandObject.isPasteTarget(Material.POPPY)); + assertFalse(CommandObject.isPasteTarget(Material.DANDELION)); + } + + @Test + public void acceptsSolidBlocksAsPasteTargets() { + assertTrue(CommandObject.isPasteTarget(Material.STONE)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/ObjectPasteBoundsTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/ObjectPasteBoundsTest.java new file mode 100644 index 000000000..a141763d9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/ObjectPasteBoundsTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class ObjectPasteBoundsTest { + private static final int ANCHOR_X = 100; + private static final int ANCHOR_Y = 65; + private static final int ANCHOR_Z = -30; + + @Test + public void preservesPasteBoundsWithoutRotation() { + ObjectPasteBounds bounds = resolve(0); + + assertEquals(new ObjectPasteBounds(98, 64, -31, 101, 66, -30), bounds); + } + + @Test + public void followsQuarterTurnPasteFootprint() { + ObjectPasteBounds bounds = resolve(90); + + assertEquals(new ObjectPasteBounds(99, 64, -31, 100, 66, -28), bounds); + } + + @Test + public void followsHalfTurnPasteOffset() { + ObjectPasteBounds bounds = resolve(180); + + assertEquals(new ObjectPasteBounds(99, 64, -30, 102, 66, -29), bounds); + } + + @Test + public void followsThreeQuarterTurnPasteOffset() { + ObjectPasteBounds bounds = resolve(270); + + assertEquals(new ObjectPasteBounds(100, 64, -32, 101, 66, -29), bounds); + } + + @Test + public void enclosesRoundedArbitraryAnglePasteFootprint() { + ObjectPasteBounds bounds = resolve(45); + + assertEquals(new ObjectPasteBounds(98, 64, -31, 101, 66, -29), bounds); + } + + private ObjectPasteBounds resolve(int rotation) { + IrisObject object = new IrisObject(4, 3, 2); + return ObjectPasteBounds.resolve(object, IrisObjectRotation.of(0, rotation, 0), ANCHOR_X, ANCHOR_Y, ANCHOR_Z); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorkspaceGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorkspaceGenerator.java index f0b5ee441..9c96885b7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorkspaceGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorkspaceGenerator.java @@ -38,13 +38,21 @@ public final class ModdedWorkspaceGenerator { } public static File writeWorkspace(IrisData data, File folder) throws IOException { - JSONObject workspaceConfig = buildWorkspace(data); + return writeWorkspace(data, folder, false); + } + + public static File writeWorkspace(IrisData data, File folder, boolean immediateSchemas) throws IOException { + JSONObject workspaceConfig = buildWorkspace(data, immediateSchemas); File workspace = new File(folder, folder.getName() + ".code-workspace"); IO.writeAll(workspace, workspaceConfig.toString(4)); return workspace; } public static JSONObject buildWorkspace(IrisData data) { + return buildWorkspace(data, false); + } + + private static JSONObject buildWorkspace(IrisData data, boolean immediateSchemas) { JSONObject ws = new JSONObject(); JSONArray folders = new JSONArray(); JSONObject folder = new JSONObject(); @@ -74,17 +82,17 @@ public final class ModdedWorkspaceGenerator { json.put("editor.suggest.insertMode", "replace"); settings.put("[json]", json); settings.put("json.maxItemsComputed", 30000); - settings.put("json.schemas", buildSchemas(data)); + settings.put("json.schemas", buildSchemas(data, immediateSchemas)); ws.put("settings", settings); return ws; } - private static JSONArray buildSchemas(IrisData data) { + private static JSONArray buildSchemas(IrisData data, boolean immediateSchemas) { JSONArray schemas = new JSONArray(); for (ResourceLoader loader : data.getLoaders().v()) { if (loader.supportsSchemas()) { - schemas.put(loader.buildSchema()); + schemas.put(immediateSchemas ? loader.buildSchemaImmediately() : loader.buildSchema()); } } @@ -102,14 +110,21 @@ public final class ModdedWorkspaceGenerator { entry.put("url", "./.iris/schema/snippet/" + snipType + "-schema.json"); schemas.put(entry); File schemaFile = new File(data.getDataFolder(), ".iris/schema/snippet/" + snipType + "-schema.json"); - J.attemptAsync(() -> { - try { - IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - }); + if (immediateSchemas) { + IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4)); + } else { + J.attemptAsync(() -> { + try { + IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4)); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + }); + } } catch (Throwable e) { + if (immediateSchemas) { + throw new IllegalStateException("Could not write snippet schema for " + snippetClass.getName(), e); + } IrisLogging.reportError(e); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index f6f381478..9657abbb2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -285,8 +285,8 @@ public final class ModdedStudioCommands { } File workspace; try { - workspace = ModdedWorkspaceGenerator.writeWorkspace(IrisData.get(folder), folder); - } catch (IOException e) { + workspace = ModdedWorkspaceGenerator.writeWorkspace(IrisData.get(folder), folder, open); + } catch (Throwable e) { LOGGER.error("Iris workspace write failed for {}", folder, e); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE, MessageArgument.untrusted("value", folder.getAbsolutePath()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage())))); return 0; @@ -393,6 +393,15 @@ public final class ModdedStudioCommands { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_HAS_NO_DIMENSIONS_JSON, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); return; } + try { + ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true); + } catch (Throwable workspaceError) { + LOGGER.error("Iris workspace write failed for {}", packFolder, workspaceError); + server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE, + MessageArgument.untrusted("value", packFolder.getAbsolutePath()), + MessageArgument.untrusted("value2", String.valueOf(workspaceError.getMessage()))))); + } server.execute(() -> { if (owner.equals(CONSOLE_OWNER)) { injectConsole(source, server, dimensionId, pack, seed); diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index bc8838c99..dabd48102 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -158,6 +158,14 @@ public class ResourceLoader implements MeteredCache { } public JSONObject buildSchema() { + return buildSchema(false); + } + + public JSONObject buildSchemaImmediately() { + return buildSchema(true); + } + + private JSONObject buildSchema(boolean immediate) { IrisLogging.debug("Building Schema " + objectClass.getSimpleName() + " " + root.getPath()); JSONObject o = new JSONObject(); KList fm = new KList<>(); @@ -169,6 +177,15 @@ public class ResourceLoader implements MeteredCache { o.put("fileMatch", new JSONArray(fm.toArray())); o.put("url", "./.iris/schema/" + getFolderName() + "-schema.json"); File a = new File(getManager().getDataFolder(), ".iris/schema/" + getFolderName() + "-schema.json"); + if (immediate) { + try { + IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4)); + } catch (IOException e) { + throw new IllegalStateException("Could not write schema " + a.getAbsolutePath(), e); + } + return o; + } + String schemaPath = a.getAbsolutePath(); if (schemaBuildQueue.add(schemaPath)) { try { diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java index ae1d8a426..f2527319a 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java @@ -21,9 +21,6 @@ package art.arcane.iris.core.project; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; -import art.arcane.iris.core.localization.BukkitRuntimeMessages; -import art.arcane.iris.core.localization.IrisLanguage; -import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -34,7 +31,6 @@ import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; -import art.arcane.volmlib.util.localization.MessageArgument; import org.dom4j.Document; import org.dom4j.Element; @@ -45,7 +41,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -58,69 +53,35 @@ public class IrisCodeWorkspace { } public void openVSCode(VolmitSender sender) { - - IrisDimension d = IrisData.loadAnyDimension(project.getName(), null); - J.attemptAsync(() -> - { - try { - if (d == null) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_COULD_NOT_LOAD_DIMENSION, MessageArgument.untrusted("value", String.valueOf(project.getName())))); - return; - } - - if (d.getLoader() == null) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_COULD_NOT_GET_DIMENSION_LOADER)); - return; - } - File f = d.getLoader().getDataFolder(); - - if (!doOpenVSCode(f)) { - File ff = new File(d.getLoader().getDataFolder(), d.getLoadKey() + ".code-workspace"); - IrisLogging.warn("Project missing code-workspace: " + ff.getAbsolutePath() + " Re-creating code workspace."); - - try { - IO.writeAll(ff, createCodeWorkspaceConfig(false)); - } catch (IOException e1) { - IrisLogging.reportError(e1); - e1.printStackTrace(); - } - if (!doOpenVSCode(f)) { - IrisLogging.warn("Tried creating code workspace but failed a second time. Your project is likely corrupt."); - } - } - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - }); + J.attemptAsync(this::prepareAndOpenVSCode); } - private boolean doOpenVSCode(File f) throws IOException { - boolean foundWork = false; - for (File i : Objects.requireNonNull(f.listFiles())) { - if (i.getName().endsWith(".code-workspace")) { - foundWork = true; - - if (IrisSettings.get().getStudio().isOpenVSCode()) { - if (!GraphicsEnvironment.isHeadless()) { - IrisLogging.msg("Opening VSCode. You may see the output from VSCode."); - IrisLogging.msg("VSCode output always starts with: '(node:#####) electron'"); - Thread launcherThread = new Thread(() -> { - try { - Desktop.getDesktop().open(i); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - }, "Iris-VSCode-Launcher"); - launcherThread.setDaemon(true); - launcherThread.start(); - } - } - - break; - } + void prepareAndOpenVSCode() { + boolean updated = updateWorkspace(true); + File workspace = getCodeWorkspaceFile(); + if (!workspace.isFile()) { + IrisLogging.warn("Could not create the code workspace for project " + project.getName() + " at " + workspace.getAbsolutePath() + "."); + return; } - return foundWork; + if (!updated) { + IrisLogging.warn("Could not refresh every schema for " + workspace.getAbsolutePath() + "; the editor will not be opened with stale autocomplete data."); + return; + } + if (!IrisSettings.get().getStudio().isOpenVSCode() || GraphicsEnvironment.isHeadless()) { + return; + } + + IrisLogging.msg("Opening VSCode. You may see the output from VSCode."); + IrisLogging.msg("VSCode output always starts with: '(node:#####) electron'"); + Thread launcherThread = new Thread(() -> { + try { + Desktop.getDesktop().open(workspace); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + }, "Iris-VSCode-Launcher"); + launcherThread.setDaemon(true); + launcherThread.start(); } public File getCodeWorkspaceFile() { @@ -128,6 +89,10 @@ public class IrisCodeWorkspace { } public boolean updateWorkspace() { + return updateWorkspace(false); + } + + private boolean updateWorkspace(boolean immediateSchemas) { project.getPath().mkdirs(); File ws = getCodeWorkspaceFile(); @@ -136,7 +101,7 @@ public class IrisCodeWorkspace { // destroyed the author's workspace on every boot. String rendered; try { - rendered = createCodeWorkspaceConfig().toString(4); + rendered = createCodeWorkspaceConfig(immediateSchemas).toString(4); } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.warn("Could not generate the code workspace config for " + ws.getAbsolutePath() + "; leaving the existing workspace file untouched."); @@ -162,7 +127,7 @@ public class IrisCodeWorkspace { } public JSONObject createCodeWorkspaceConfig() { - return createCodeWorkspaceConfig(true); + return createCodeWorkspaceConfig(false); } private static void writeIfChanged(File target, String rendered) throws IOException { @@ -172,7 +137,7 @@ public class IrisCodeWorkspace { IO.writeAll(target, rendered); } - private JSONObject createCodeWorkspaceConfig(boolean includeSchemas) { + private JSONObject createCodeWorkspaceConfig(boolean immediateSchemas) { JSONObject ws = new JSONObject(); JSONArray folders = new JSONArray(); JSONObject folder = new JSONObject(); @@ -204,55 +169,68 @@ public class IrisCodeWorkspace { settings.put("json.maxItemsComputed", 30000); JSONArray schemas = new JSONArray(); List schemaEntries = new ArrayList<>(); - IrisData dm = null; - if (includeSchemas) { - dm = IrisData.get(project.getPath()); - for (ResourceLoader r : dm.getLoaders().v()) { - if (r.supportsSchemas()) { - schemaEntries.add(r.buildSchema()); - } + IrisData dm = IrisData.get(project.getPath()); + for (ResourceLoader resourceLoader : dm.getLoaders().v()) { + if (resourceLoader.supportsSchemas()) { + schemaEntries.add(immediateSchemas + ? resourceLoader.buildSchemaImmediately() + : resourceLoader.buildSchema()); } + } - for (Class i : sortedSnippets(dm.resolveSnippets())) { - try { - String snipType = i.getDeclaredAnnotation(Snippet.class).value(); - JSONObject o = new JSONObject(); - KList fm = new KList<>(); + for (Class snippetClass : sortedSnippets(dm.resolveSnippets())) { + try { + String snipType = snippetClass.getDeclaredAnnotation(Snippet.class).value(); + JSONObject schemaEntry = new JSONObject(); + KList fileMatches = new KList<>(); - for (int g = 1; g < 8; g++) { - fm.add("/snippet/" + snipType + Form.repeat("/*", g) + ".json"); - } + for (int depth = 1; depth < 8; depth++) { + fileMatches.add("/snippet/" + snipType + Form.repeat("/*", depth) + ".json"); + } - o.put("fileMatch", new JSONArray(fm.toArray())); - o.put("url", "./.iris/schema/snippet/" + snipType + "-schema.json"); - schemaEntries.add(o); + schemaEntry.put("fileMatch", new JSONArray(fileMatches.toArray())); + schemaEntry.put("url", "./.iris/schema/snippet/" + snipType + "-schema.json"); + schemaEntries.add(schemaEntry); + File schemaFile = new File(dm.getDataFolder(), ".iris/schema/snippet/" + snipType + "-schema.json"); + if (immediateSchemas) { + IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, dm).construct().toString(4)); + } else { IrisData snippetData = dm; - File a = new File(snippetData.getDataFolder(), ".iris/schema/snippet/" + snipType + "-schema.json"); J.attemptAsync(() -> { try { - IO.writeAll(a, new SchemaBuilder(i, snippetData).construct().toString(4)); + IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, snippetData).construct().toString(4)); } catch (Throwable e) { - e.printStackTrace(); + IrisLogging.reportError(e); } }); - } catch (Throwable e) { - e.printStackTrace(); } + } catch (Throwable e) { + if (immediateSchemas) { + throw new IllegalStateException("Could not write snippet schema for " + snippetClass.getName(), e); + } + IrisLogging.reportError(e); } + } - schemaEntries.sort(Comparator.comparing(entry -> entry.getString("url"))); - for (JSONObject entry : schemaEntries) { - schemas.put(entry); - } + schemaEntries.sort(Comparator.comparing(entry -> entry.getString("url"))); + for (JSONObject entry : schemaEntries) { + schemas.put(entry); } settings.put("json.schemas", schemas); ws.put("settings", settings); - if (!includeSchemas) { - return ws; + try { + updateIntelliJSchemaMappings(schemas); + } catch (Throwable e) { + IrisLogging.reportError(e); + IrisLogging.warn("Could not update IntelliJ schema mappings for " + project.getPath().getAbsolutePath() + "; VSCode workspace generation will continue."); } + return ws; + } + + private void updateIntelliJSchemaMappings(JSONArray schemas) { File schemasFile = new File(project.getPath(), ".idea" + File.separator + "jsonSchemas.xml"); Document doc = IO.read(schemasFile); Element mappings = (Element) doc.selectSingleNode("//component[@name='JsonSchemaMappingsProjectConfiguration']"); @@ -267,7 +245,7 @@ public class IrisCodeWorkspace { Element map = (Element) state.selectSingleNode("map"); if (map == null) map = state.addElement("map"); - var schemaMap = new KMap(); + KMap schemaMap = new KMap<>(); schemas.forEach(element -> { if (!(element instanceof JSONObject obj)) return; @@ -282,17 +260,17 @@ public class IrisCodeWorkspace { .map(node -> node.valueOf("@value")) .forEach(schemaMap::remove); - var ideaSchemas = map; + Element ideaSchemas = map; schemaMap.forEach((url, dir) -> { - var genName = UUID.randomUUID().toString(); + String generatedName = UUID.randomUUID().toString(); - var info = ideaSchemas.addElement("entry") - .addAttribute("key", genName) + Element info = ideaSchemas.addElement("entry") + .addAttribute("key", generatedName) .addElement("value") .addElement("SchemaInfo"); info.addElement("option") .addAttribute("name", "generatedName") - .addAttribute("value", genName); + .addAttribute("value", generatedName); info.addElement("option") .addAttribute("name", "name") .addAttribute("value", dir); @@ -301,7 +279,7 @@ public class IrisCodeWorkspace { .addAttribute("value", url); - var item = info.addElement("option") + Element item = info.addElement("option") .addAttribute("name", "patterns") .addElement("list") .addElement("Item"); @@ -318,7 +296,6 @@ public class IrisCodeWorkspace { if (!schemaMap.isEmpty()) { IO.write(schemasFile, doc); } - return ws; } private static List> sortedSnippets(Set> snippets) { diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java index d7da5dc82..8a1063d1d 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisCodeWorkspaceTest.java @@ -7,6 +7,7 @@ import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import org.junit.After; @@ -17,6 +18,7 @@ import org.junit.rules.TemporaryFolder; import org.mockito.Answers; import java.io.File; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.attribute.FileTime; import java.util.ArrayList; @@ -45,7 +47,9 @@ public class IrisCodeWorkspaceTest { previousSettings = IrisSettings.settings; IrisPlatforms.unbind(); IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + PlatformRegistries registries = mock(PlatformRegistries.class); when(platform.dataFolder()).thenReturn(temporaryFolder.getRoot()); + when(platform.registries()).thenReturn(registries); IrisPlatforms.bind(platform); IrisSettings.settings = new IrisSettings(); IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); @@ -103,6 +107,43 @@ public class IrisCodeWorkspaceTest { assertEquals("Schema entries must be emitted in a boot-stable order", sorted, urls); } + @Test + public void studioOpenCreatesAndRefreshesCanonicalWorkspaceAndMaterializesSchemas() throws Exception { + File pack = temporaryFolder.newFolder("studio-pack"); + File canonicalWorkspace = new File(pack, "studio-pack.code-workspace"); + File unrelatedWorkspace = new File(pack, "unrelated.code-workspace"); + File intellijMappings = new File(pack, ".idea/jsonSchemas.xml"); + Files.writeString(unrelatedWorkspace.toPath(), "unrelated", StandardCharsets.UTF_8); + Files.createDirectories(intellijMappings.toPath().getParent()); + Files.writeString(intellijMappings.toPath(), "not xml", StandardCharsets.UTF_8); + IrisSettings.get().getStudio().setOpenVSCode(false); + + IrisCodeWorkspace workspace = new IrisCodeWorkspace(new IrisProject(pack)); + workspace.prepareAndOpenVSCode(); + data = IrisData.get(pack); + + JSONArray schemas = assertWorkspaceSchemasMaterialized(pack, canonicalWorkspace); + String firstSchemaPath = schemas.getJSONObject(0).getString("url").substring(2); + Files.writeString(canonicalWorkspace.toPath(), "{\"settings\":{\"json.schemas\":[]}}", StandardCharsets.UTF_8); + Files.delete(new File(pack, firstSchemaPath).toPath()); + + workspace.prepareAndOpenVSCode(); + assertWorkspaceSchemasMaterialized(pack, canonicalWorkspace); + assertEquals("Studio open must target only the canonical workspace", "unrelated", + Files.readString(unrelatedWorkspace.toPath(), StandardCharsets.UTF_8)); + } + + private static JSONArray assertWorkspaceSchemasMaterialized(File pack, File canonicalWorkspace) throws Exception { + JSONObject configuration = new JSONObject(Files.readString(canonicalWorkspace.toPath(), StandardCharsets.UTF_8)); + JSONArray schemas = configuration.getJSONObject("settings").getJSONArray("json.schemas"); + assertTrue("Studio open must declare schemas", schemas.length() > 0); + for (int index = 0; index < schemas.length(); index++) { + String relativePath = schemas.getJSONObject(index).getString("url").substring(2); + assertTrue("Studio open must write schema " + relativePath, new File(pack, relativePath).isFile()); + } + return schemas; + } + private static final class NoOpPreservationRegistry implements PreservationRegistry { @Override public void register(Thread thread) {