diff --git a/prometeu-studio/build.gradle.kts b/prometeu-studio/build.gradle.kts index 070a27c1..5dadc75d 100644 --- a/prometeu-studio/build.gradle.kts +++ b/prometeu-studio/build.gradle.kts @@ -10,10 +10,12 @@ dependencies { implementation(project(":prometeu-packer:prometeu-packer-api")) implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-build-pipeline")) + implementation(project(":prometeu-compiler:prometeu-frontend-api")) implementation(project(":prometeu-compiler:prometeu-frontend-registry")) implementation(libs.javafx.controls) implementation(libs.javafx.fxml) implementation(libs.richtextfx) + testImplementation(project(":prometeu-packer:prometeu-packer-v1")) } javafx { diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogEntry.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogEntry.java new file mode 100644 index 00000000..7aeee2dd --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogEntry.java @@ -0,0 +1,33 @@ +package p.studio.shipper; + +import java.time.Instant; +import java.util.Objects; + +public record StudioShipperLogEntry( + StudioShipperLogSource source, + StudioShipperLogLevel level, + String message, + Instant timestamp) { + + public StudioShipperLogEntry { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(level, "level"); + message = Objects.requireNonNull(message, "message").trim(); + timestamp = timestamp == null ? Instant.now() : timestamp; + if (message.isBlank()) { + throw new IllegalArgumentException("message must not be blank"); + } + } + + public static StudioShipperLogEntry info( + final StudioShipperLogSource source, + final String message) { + return new StudioShipperLogEntry(source, StudioShipperLogLevel.INFO, message, Instant.now()); + } + + public static StudioShipperLogEntry error( + final StudioShipperLogSource source, + final String message) { + return new StudioShipperLogEntry(source, StudioShipperLogLevel.ERROR, message, Instant.now()); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogLevel.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogLevel.java new file mode 100644 index 00000000..4c1f2224 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogLevel.java @@ -0,0 +1,6 @@ +package p.studio.shipper; + +public enum StudioShipperLogLevel { + INFO, + ERROR +} diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogSource.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogSource.java new file mode 100644 index 00000000..4f86b129 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperLogSource.java @@ -0,0 +1,8 @@ +package p.studio.shipper; + +public enum StudioShipperLogSource { + BUILD, + PACK_VALIDATION, + PACK, + MANIFEST +} diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPreparationStatus.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPreparationStatus.java new file mode 100644 index 00000000..b26f65e8 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPreparationStatus.java @@ -0,0 +1,6 @@ +package p.studio.shipper; + +public enum StudioShipperPreparationStatus { + SUCCESS, + FAILED +} diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPrepareResult.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPrepareResult.java new file mode 100644 index 00000000..5b3c974d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperPrepareResult.java @@ -0,0 +1,28 @@ +package p.studio.shipper; + +import p.packer.messages.PackWorkspaceResult; +import p.packer.messages.ValidatePackWorkspaceResult; +import p.studio.compiler.models.BuildResult; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record StudioShipperPrepareResult( + StudioShipperPreparationStatus status, + List logs, + BuildResult buildResult, + ValidatePackWorkspaceResult validationResult, + PackWorkspaceResult packResult, + Path manifestPath) { + + public StudioShipperPrepareResult { + Objects.requireNonNull(status, "status"); + logs = List.copyOf(Objects.requireNonNull(logs, "logs")); + manifestPath = manifestPath == null ? null : manifestPath.toAbsolutePath().normalize(); + } + + public boolean success() { + return status == StudioShipperPreparationStatus.SUCCESS; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperService.java b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperService.java new file mode 100644 index 00000000..cff8d285 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/shipper/StudioShipperService.java @@ -0,0 +1,275 @@ +package p.studio.shipper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import p.packer.PackerWorkspaceService; +import p.packer.dtos.PackerDiagnosticDTO; +import p.packer.dtos.PackerPackValidationAssetDTO; +import p.packer.messages.PackWorkspaceRequest; +import p.packer.messages.PackWorkspaceResult; +import p.packer.messages.PackerOperationStatus; +import p.packer.messages.ValidatePackWorkspaceRequest; +import p.packer.messages.ValidatePackWorkspaceResult; +import p.studio.Container; +import p.studio.compiler.messages.BuilderPipelineConfig; +import p.studio.compiler.models.BuildResult; +import p.studio.compiler.models.BuilderPipelineContext; +import p.studio.compiler.workspaces.BuilderPipelineService; +import p.studio.projects.ProjectReference; +import p.studio.utilities.logs.LogAggregator; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +public final class StudioShipperService { + private static final String BUILD_DIR = "build"; + private static final String MANIFEST_FILE = "manifest.json"; + private static final String PROJECT_MANIFEST_FILE = "prometeu.json"; + private static final String ASSET_TABLE_FILE = "asset_table.json"; + private static final String PRELOAD_FILE = "preload.json"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final PackerWorkspaceService packerWorkspaceService; + + public StudioShipperService() { + this(Container.packer().workspaceService()); + } + + public StudioShipperService(final PackerWorkspaceService packerWorkspaceService) { + this.packerWorkspaceService = Objects.requireNonNull(packerWorkspaceService, "packerWorkspaceService"); + } + + public StudioShipperPrepareResult prepare(final ProjectReference projectReference) { + return prepare(projectReference, ignored -> { }); + } + + public StudioShipperPrepareResult prepare( + final ProjectReference projectReference, + final Consumer logSink) { + final ProjectReference safeProjectReference = Objects.requireNonNull(projectReference, "projectReference"); + final Consumer safeLogSink = Objects.requireNonNull(logSink, "logSink"); + final ArrayList logs = new ArrayList<>(); + + try { + final BuildResult buildResult = build(safeProjectReference, logs, safeLogSink); + final ValidatePackWorkspaceResult validationResult = validatePack(safeProjectReference, logs, safeLogSink); + if (!validationResult.canPack()) { + emit(logs, safeLogSink, StudioShipperLogEntry.error( + StudioShipperLogSource.PACK_VALIDATION, + validationResult.summary())); + logBlockedAssets(validationResult.assets(), logs, safeLogSink); + return new StudioShipperPrepareResult( + StudioShipperPreparationStatus.FAILED, + logs, + buildResult, + validationResult, + null, + null); + } + + final PackWorkspaceResult packResult = pack(safeProjectReference, logs, safeLogSink); + if (packResult.status() != PackerOperationStatus.SUCCESS) { + emit(logs, safeLogSink, logFor(packResult.status(), StudioShipperLogSource.PACK, packResult.summary())); + return new StudioShipperPrepareResult( + StudioShipperPreparationStatus.FAILED, + logs, + buildResult, + validationResult, + packResult, + null); + } + + final Path manifestPath = writeManifest(safeProjectReference, buildResult, logs, safeLogSink); + return new StudioShipperPrepareResult( + StudioShipperPreparationStatus.SUCCESS, + logs, + buildResult, + validationResult, + packResult, + manifestPath); + } catch (RuntimeException runtimeException) { + emit(logs, safeLogSink, StudioShipperLogEntry.error( + StudioShipperLogSource.BUILD, + runtimeException.getMessage() == null ? runtimeException.getClass().getSimpleName() : runtimeException.getMessage())); + return new StudioShipperPrepareResult( + StudioShipperPreparationStatus.FAILED, + logs, + null, + null, + null, + null); + } + } + + private BuildResult build( + final ProjectReference projectReference, + final List logs, + final Consumer logSink) { + final var config = new BuilderPipelineConfig(false, projectReference.rootPath().toString()); + final var ctx = BuilderPipelineContext.fromConfig(config); + final var logAggregator = LogAggregator.with(message -> emit(logs, logSink, StudioShipperLogEntry.info( + StudioShipperLogSource.BUILD, + normalizeMessage(message)))); + final BuildResult result = BuilderPipelineService.INSTANCE.build(ctx, logAggregator); + if (result.bytecodeArtifactPath() != null) { + emit(logs, logSink, StudioShipperLogEntry.info( + StudioShipperLogSource.BUILD, + "Prepared program bytecode at " + result.bytecodeArtifactPath().toAbsolutePath().normalize())); + } + return result; + } + + private ValidatePackWorkspaceResult validatePack( + final ProjectReference projectReference, + final List logs, + final Consumer logSink) { + final ValidatePackWorkspaceResult result = packerWorkspaceService + .validatePackWorkspace(new ValidatePackWorkspaceRequest(projectReference.toPackerProjectContext())); + // keep the validation source explicit even when the downstream packer reports partial status + emit(logs, logSink, logFor(result.status(), StudioShipperLogSource.PACK_VALIDATION, result.summary())); + if (!result.canPack()) { + logBlockedAssets(result.assets(), logs, logSink); + } + return result; + } + + private PackWorkspaceResult pack( + final ProjectReference projectReference, + final List logs, + final Consumer logSink) { + final PackWorkspaceResult result = packerWorkspaceService + .packWorkspace(new PackWorkspaceRequest(projectReference.toPackerProjectContext())); + emit(logs, logSink, logFor(result.status(), StudioShipperLogSource.PACK, result.summary())); + result.result().emittedArtifacts().forEach(artifact -> emit(logs, logSink, StudioShipperLogEntry.info( + StudioShipperLogSource.PACK, + "Emitted " + artifact.label() + " at " + artifact.path()))); + return result; + } + + private Path writeManifest( + final ProjectReference projectReference, + final BuildResult buildResult, + final List logs, + final Consumer logSink) { + final Path buildRoot = projectReference.rootPath().resolve(BUILD_DIR).toAbsolutePath().normalize(); + final Path manifestPath = buildRoot.resolve(MANIFEST_FILE); + try { + Files.createDirectories(buildRoot); + final ObjectNode manifest = createManifest(projectReference, buildResult, buildRoot); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + emit(logs, logSink, StudioShipperLogEntry.info( + StudioShipperLogSource.MANIFEST, + "Generated manifest at " + manifestPath)); + return manifestPath; + } catch (IOException ioException) { + emit(logs, logSink, StudioShipperLogEntry.error( + StudioShipperLogSource.MANIFEST, + "Failed to write manifest: " + manifestPath)); + throw new RuntimeException("failed to write build manifest: " + manifestPath, ioException); + } + } + + private ObjectNode createManifest( + final ProjectReference projectReference, + final BuildResult buildResult, + final Path buildRoot) throws IOException { + final ObjectNode manifest = MAPPER.createObjectNode(); + final JsonNode projectManifest = MAPPER.readTree(projectReference.rootPath().resolve(PROJECT_MANIFEST_FILE).toFile()); + manifest.put("magic", "PMTU"); + manifest.put("cartridge_version", 1); + manifest.put("app_id", stableAppId(projectReference)); + manifest.put("title", projectReference.name()); + manifest.put("app_version", projectReference.version()); + manifest.put("app_mode", "game"); + manifest.set("capabilities", capabilitiesNode(buildResult)); + manifest.set("asset_table", readArrayOrEmpty(buildRoot.resolve(ASSET_TABLE_FILE))); + manifest.set("preload", readArrayOrEmpty(buildRoot.resolve(PRELOAD_FILE))); + if (projectManifest != null) { + putIfTextual(manifest, "project_name", projectManifest.get("name")); + putIfTextual(manifest, "language", projectManifest.get("language")); + } + return manifest; + } + + private ArrayNode capabilitiesNode(final BuildResult buildResult) { + final ArrayNode capabilities = MAPPER.createArrayNode(); + final var irBackend = buildResult.compileResult().analysisSnapshot().irBackend(); + final LinkedHashSet distinct = new LinkedHashSet<>(irBackend.getReservedMetadata().requiredCapabilities().asList()); + distinct.forEach(capabilities::add); + return capabilities; + } + + private JsonNode readArrayOrEmpty(final Path path) throws IOException { + if (!Files.isRegularFile(path)) { + return MAPPER.createArrayNode(); + } + final JsonNode loaded = MAPPER.readTree(path.toFile()); + return loaded != null && loaded.isArray() ? loaded : MAPPER.createArrayNode(); + } + + private int stableAppId(final ProjectReference projectReference) { + final int hash = Objects.hash( + projectReference.name(), + projectReference.version(), + projectReference.languageId(), + projectReference.stdlibVersion()); + return hash == Integer.MIN_VALUE ? 1 : Math.max(1, Math.abs(hash)); + } + + private void putIfTextual( + final ObjectNode node, + final String fieldName, + final JsonNode value) { + if (value != null && value.isTextual() && !value.asText().isBlank()) { + node.put(fieldName, value.asText()); + } + } + + private void logBlockedAssets( + final List assets, + final List logs, + final Consumer logSink) { + for (final PackerPackValidationAssetDTO asset : assets) { + emit(logs, logSink, StudioShipperLogEntry.error( + StudioShipperLogSource.PACK_VALIDATION, + "Blocked asset #" + asset.assetId() + " (" + asset.assetName() + ")")); + for (final PackerDiagnosticDTO diagnostic : asset.diagnostics()) { + emit(logs, logSink, StudioShipperLogEntry.error( + StudioShipperLogSource.PACK_VALIDATION, + diagnostic.message())); + } + } + } + + private StudioShipperLogEntry logFor( + final PackerOperationStatus status, + final StudioShipperLogSource source, + final String message) { + return status == PackerOperationStatus.FAILED || status == PackerOperationStatus.PARTIAL + ? StudioShipperLogEntry.error(source, message) + : StudioShipperLogEntry.info(source, message); + } + + private void emit( + final List logs, + final Consumer logSink, + final StudioShipperLogEntry entry) { + logs.add(entry); + logSink.accept(entry); + } + + private String normalizeMessage(final String message) { + if (message == null) { + return "(empty build log entry)"; + } + final String trimmed = message.strip(); + return trimmed.isEmpty() ? "(empty build log entry)" : trimmed; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java index c383adcb..0c88a924 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java @@ -6,12 +6,9 @@ import javafx.scene.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import p.studio.Container; -import p.studio.compiler.messages.BuilderPipelineConfig; -import p.studio.compiler.models.BuilderPipelineContext; -import p.studio.compiler.workspaces.BuilderPipelineService; import p.studio.projects.ProjectReference; +import p.studio.shipper.StudioShipperService; import p.studio.utilities.i18n.I18n; -import p.studio.utilities.logs.LogAggregator; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; @@ -20,6 +17,7 @@ public class ShipperWorkspace extends Workspace { private final TextArea logs = new TextArea(); private final Button buildButton = new Button(); private final Button clearButton = new Button(); + private final StudioShipperService shipperService = new StudioShipperService(); public ShipperWorkspace(ProjectReference projectReference) { super(projectReference); @@ -66,10 +64,8 @@ public class ShipperWorkspace extends Workspace { buildButton.getStyleClass().addAll("studio-button", "studio-button-primary"); buildButton.setOnAction(e -> { logs.clear(); - final var logAggregator = LogAggregator.with(logs::appendText); - final var config = new BuilderPipelineConfig(false, projectReference.rootPath().toString()); - final var ctx = BuilderPipelineContext.fromConfig(config); - BuilderPipelineService.INSTANCE.build(ctx, logAggregator); + shipperService.prepare(projectReference, entry -> logs.appendText( + "[%s] %s%s".formatted(entry.source().name(), entry.message(), System.lineSeparator()))); }); clearButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_CLEAR)); diff --git a/prometeu-studio/src/test/java/p/studio/shipper/StudioShipperServiceTest.java b/prometeu-studio/src/test/java/p/studio/shipper/StudioShipperServiceTest.java new file mode 100644 index 00000000..fef7c1fa --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/shipper/StudioShipperServiceTest.java @@ -0,0 +1,99 @@ +package p.studio.shipper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.Packer; +import p.studio.projects.ProjectCatalogService; +import p.studio.projects.ProjectReference; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +final class StudioShipperServiceTest { + @TempDir + Path tempDir; + + @Test + void prepareBuildsPackAndManifestForMainFixture() throws Exception { + final Path projectRoot = copyFixture(fixturePath("test-projects/main"), tempDir.resolve("main-project")); + final ProjectReference projectReference = new ProjectCatalogService(tempDir).openProject(projectRoot); + final ArrayList streamedLogs = new ArrayList<>(); + + final StudioShipperPrepareResult result; + try (final Packer packer = Packer.bootstrap(new ObjectMapper(), ignored -> { })) { + final StudioShipperService service = new StudioShipperService(packer.workspaceService()); + result = service.prepare(projectReference, streamedLogs::add); + } + + assertTrue(result.success()); + assertEquals(StudioShipperPreparationStatus.SUCCESS, result.status()); + assertNotNull(result.buildResult()); + assertNotNull(result.validationResult()); + assertNotNull(result.packResult()); + assertNotNull(result.manifestPath()); + assertEquals(result.logs(), streamedLogs); + assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("program.pbx"))); + assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("assets.pa"))); + assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("manifest.json"))); + assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.BUILD)); + assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.PACK_VALIDATION)); + assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.PACK)); + assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.MANIFEST)); + final String manifestJson = Files.readString(result.manifestPath()); + assertTrue(manifestJson.contains("\"magic\"")); + assertTrue(manifestJson.contains("\"capabilities\"")); + assertTrue(manifestJson.contains("\"asset_table\"")); + assertTrue(manifestJson.contains("\"preload\"")); + } + + @Test + void prepareManifestPreservesCompilerCapabilities() throws Exception { + final Path projectRoot = copyFixture(fixturePath("test-projects/main"), tempDir.resolve("main-capabilities")); + final ProjectReference projectReference = new ProjectCatalogService(tempDir).openProject(projectRoot); + final StudioShipperPrepareResult result; + try (final Packer packer = Packer.bootstrap(new ObjectMapper(), ignored -> { })) { + final StudioShipperService service = new StudioShipperService(packer.workspaceService()); + result = service.prepare(projectReference); + } + + assertTrue(result.success()); + final String manifestJson = Files.readString(result.manifestPath()); + assertTrue(manifestJson.contains("\"log\"")); + assertTrue(manifestJson.contains("\"gfx\"")); + assertTrue(manifestJson.contains("\"asset\"")); + } + + private Path copyFixture(final Path sourceRoot, final Path targetRoot) throws IOException { + Files.walk(sourceRoot).forEach(source -> { + try { + final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString()); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(source, target); + } + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + }); + return targetRoot; + } + + private Path fixturePath(final String relativePath) { + final Path direct = Path.of(relativePath).toAbsolutePath().normalize(); + if (Files.exists(direct)) { + return direct; + } + final Path parent = Path.of("..").resolve(relativePath).toAbsolutePath().normalize(); + if (Files.exists(parent)) { + return parent; + } + throw new IllegalArgumentException("fixture not found: " + relativePath); + } +}