implements PLN-0016

This commit is contained in:
bQUARKz 2026-03-31 08:05:05 +01:00
parent 928f844658
commit c678fe6f49
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
18 changed files with 461 additions and 3 deletions

View File

@ -10,4 +10,4 @@
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["studio","debugger","workspace","integration","shell"],"agendas":[{"id":"AGD-0009","file":"AGD-0009-studio-debugger-workspace-integration.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"done","ticket":"studio-code-editor-workspace-foundations","title":"Establish Code Editor workspace foundations in Studio without LSP","created_at":"2026-03-30","updated_at":"2026-03-31","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0010-studio-code-editor-workspace-foundations/LSN-0026-read-only-editor-foundations-and-semantic-deferral.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]}
{"type":"discussion","id":"DSC-0011","status":"done","ticket":"compiler-analyze-compile-build-pipeline-split","title":"Split compiler pipeline into analyze, compile, and build entrypoints","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["compiler","pipeline","artifacts","build","analysis"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0011-compiler-analyze-compile-build-pipeline-split/LSN-0025-compiler-pipeline-entrypoints-and-result-boundaries.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]}
{"type":"discussion","id":"DSC-0012","status":"open","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[{"id":"AGD-0012","file":"AGD-0012-studio-editor-document-vfs-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0009","file":"DEC-0009-studio-prometeu-vfs-project-document-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0012"}],"plans":[{"id":"PLN-0015","file":"PLN-0015-propagate-dec-0009-into-studio-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0016","file":"PLN-0016-build-prometeu-vfs-filesystem-backed-core.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0017","file":"PLN-0017-add-studio-project-session-ownership-for-prometeu-vfs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0018","file":"PLN-0018-migrate-code-editor-to-prometeu-vfs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0012","status":"open","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[{"id":"AGD-0012","file":"AGD-0012-studio-editor-document-vfs-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0009","file":"DEC-0009-studio-prometeu-vfs-project-document-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0012"}],"plans":[{"id":"PLN-0015","file":"PLN-0015-propagate-dec-0009-into-studio-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0016","file":"PLN-0016-build-prometeu-vfs-filesystem-backed-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0017","file":"PLN-0017-add-studio-project-session-ownership-for-prometeu-vfs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0018","file":"PLN-0018-migrate-code-editor-to-prometeu-vfs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]}],"lessons":[]}

View File

@ -2,9 +2,9 @@
id: PLN-0016
ticket: studio-editor-document-vfs-boundary
title: Build the filesystem-backed `prometeu-vfs` core for project tree and documents
status: review
status: done
created: 2026-03-31
completed:
completed: 2026-03-31
tags:
- studio
- vfs

View File

@ -5,6 +5,7 @@ plugins {
dependencies {
implementation(project(":prometeu-infra"))
implementation(project(":prometeu-vfs"))
implementation(project(":prometeu-packer:prometeu-packer-api"))
implementation(project(":prometeu-compiler:prometeu-compiler-core"))
implementation(project(":prometeu-compiler:prometeu-build-pipeline"))

View File

@ -0,0 +1,8 @@
plugins {
id("gradle.java-library-conventions")
}
dependencies {
implementation(project(":prometeu-compiler:prometeu-compiler-core"))
implementation(project(":prometeu-compiler:prometeu-frontend-registry"))
}

View File

@ -0,0 +1,209 @@
package p.studio.vfs;
import p.studio.compiler.FrontendRegistryService;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
private final VfsProjectContext projectContext;
private VfsProjectSnapshot snapshot;
FilesystemProjectDocumentVfs(final VfsProjectContext projectContext) {
this.projectContext = Objects.requireNonNull(projectContext, "projectContext");
this.snapshot = buildSnapshot();
}
@Override
public VfsProjectContext projectContext() {
return projectContext;
}
@Override
public VfsProjectSnapshot snapshot() {
return snapshot;
}
@Override
public VfsProjectSnapshot refresh() {
snapshot = buildSnapshot();
return snapshot;
}
@Override
public VfsProjectSnapshot refresh(final VfsRefreshRequest request) {
final Path refreshTarget = refreshRootFor(Objects.requireNonNull(request, "request").targetPath());
if (refreshTarget.equals(projectContext.rootPath())) {
return refresh();
}
final Set<Path> taggedRoots = taggedSourceRoots();
final String displayName = refreshTarget.getFileName() == null
? projectContext.projectName()
: refreshTarget.getFileName().toString();
final VfsProjectNode refreshedNode = buildNode(refreshTarget, displayName, taggedRoots);
snapshot = new VfsProjectSnapshot(
projectContext.rootPath(),
replaceNode(snapshot.rootNode(), refreshedNode));
return snapshot;
}
@Override
public VfsDocumentOpenResult openDocument(final Path path) {
final Path normalizedPath = normalize(path);
if (!normalizedPath.startsWith(projectContext.rootPath())) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT);
}
if (!Files.exists(normalizedPath)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_FOUND);
}
if (!Files.isRegularFile(normalizedPath)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_A_FILE);
}
final byte[] bytes;
try {
bytes = Files.readAllBytes(normalizedPath);
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
}
if (containsNulByte(bytes)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.BINARY_CONTENT);
}
final String content;
try {
content = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException codingException) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.INVALID_UTF8);
}
return new VfsTextDocument(
normalizedPath,
normalizedPath.getFileName().toString(),
content,
lineSeparator(content));
}
private VfsProjectSnapshot buildSnapshot() {
return new VfsProjectSnapshot(
projectContext.rootPath(),
buildNode(projectContext.rootPath(), projectContext.projectName(), taggedSourceRoots()));
}
private Set<Path> taggedSourceRoots() {
return FrontendRegistryService.getFrontendSpec(projectContext.languageId())
.stream()
.flatMap(frontendSpec -> frontendSpec.getSourceRoots().stream())
.map(projectContext.rootPath()::resolve)
.map(Path::toAbsolutePath)
.map(Path::normalize)
.collect(Collectors.toSet());
}
private VfsProjectNode buildNode(
final Path path,
final String displayName,
final Set<Path> taggedRoots) {
if (!Files.isDirectory(path)) {
return new VfsProjectNode(path, displayName, false, taggedRoots.contains(path), List.of());
}
final List<VfsProjectNode> children;
try (Stream<Path> stream = Files.list(path)) {
children = stream
.sorted(nodeComparator())
.map(child -> buildNode(child, child.getFileName().toString(), taggedRoots))
.toList();
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
}
return new VfsProjectNode(path, displayName, true, taggedRoots.contains(path), children);
}
private VfsProjectNode replaceNode(final VfsProjectNode currentNode, final VfsProjectNode replacementNode) {
if (currentNode.path().equals(replacementNode.path())) {
return replacementNode;
}
if (!replacementNode.path().startsWith(currentNode.path())) {
return currentNode;
}
if (!currentNode.directory()) {
return currentNode;
}
final List<VfsProjectNode> replacedChildren = currentNode.children()
.stream()
.map(child -> replaceNode(child, replacementNode))
.sorted(projectNodeComparator())
.toList();
return new VfsProjectNode(
currentNode.path(),
currentNode.displayName(),
true,
currentNode.taggedSourceRoot(),
replacedChildren);
}
private Path refreshRootFor(final Path targetPath) {
final Path normalizedTarget = normalize(targetPath);
if (!normalizedTarget.startsWith(projectContext.rootPath())) {
throw new IllegalArgumentException("Refresh target must stay within the project root.");
}
if (Files.exists(normalizedTarget)) {
return normalizedTarget;
}
final Path parent = normalizedTarget.getParent();
if (parent == null || !parent.startsWith(projectContext.rootPath())) {
return projectContext.rootPath();
}
return parent;
}
private Comparator<Path> nodeComparator() {
return Comparator
.comparing((Path path) -> !Files.isDirectory(path))
.thenComparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER);
}
private Comparator<VfsProjectNode> projectNodeComparator() {
return Comparator
.comparing((VfsProjectNode node) -> !node.directory())
.thenComparing(VfsProjectNode::displayName, String.CASE_INSENSITIVE_ORDER);
}
private Path normalize(final Path path) {
return Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
}
private boolean containsNulByte(final byte[] bytes) {
for (final byte value : bytes) {
if (value == 0) {
return true;
}
}
return false;
}
private String lineSeparator(final String content) {
return content.contains("\r\n") ? "CRLF" : "LF";
}
}

View File

@ -0,0 +1,10 @@
package p.studio.vfs;
import java.util.Objects;
public final class FilesystemProjectDocumentVfsFactory implements ProjectDocumentVfsFactory {
@Override
public ProjectDocumentVfs open(final VfsProjectContext projectContext) {
return new FilesystemProjectDocumentVfs(Objects.requireNonNull(projectContext, "projectContext"));
}
}

View File

@ -0,0 +1,19 @@
package p.studio.vfs;
import java.nio.file.Path;
public interface ProjectDocumentVfs extends AutoCloseable {
VfsProjectContext projectContext();
VfsProjectSnapshot snapshot();
VfsProjectSnapshot refresh();
VfsProjectSnapshot refresh(VfsRefreshRequest request);
VfsDocumentOpenResult openDocument(Path path);
@Override
default void close() {
}
}

View File

@ -0,0 +1,5 @@
package p.studio.vfs;
public interface ProjectDocumentVfsFactory {
ProjectDocumentVfs open(VfsProjectContext projectContext);
}

View File

@ -0,0 +1,7 @@
package p.studio.vfs;
import java.nio.file.Path;
public sealed interface VfsDocumentOpenResult permits VfsTextDocument, VfsUnsupportedDocument {
Path path();
}

View File

@ -0,0 +1,16 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.Objects;
public record VfsProjectContext(
String projectName,
String languageId,
Path rootPath) {
public VfsProjectContext {
Objects.requireNonNull(projectName, "projectName");
Objects.requireNonNull(languageId, "languageId");
rootPath = Objects.requireNonNull(rootPath, "rootPath").toAbsolutePath().normalize();
}
}

View File

@ -0,0 +1,19 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public record VfsProjectNode(
Path path,
String displayName,
boolean directory,
boolean taggedSourceRoot,
List<VfsProjectNode> children) {
public VfsProjectNode {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
Objects.requireNonNull(displayName, "displayName");
children = List.copyOf(Objects.requireNonNull(children, "children"));
}
}

View File

@ -0,0 +1,14 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.Objects;
public record VfsProjectSnapshot(
Path projectRoot,
VfsProjectNode rootNode) {
public VfsProjectSnapshot {
projectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize();
Objects.requireNonNull(rootNode, "rootNode");
}
}

View File

@ -0,0 +1,10 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.Objects;
public record VfsRefreshRequest(Path targetPath) {
public VfsRefreshRequest {
targetPath = Objects.requireNonNull(targetPath, "targetPath").toAbsolutePath().normalize();
}
}

View File

@ -0,0 +1,18 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.Objects;
public record VfsTextDocument(
Path path,
String documentName,
String content,
String lineSeparator) implements VfsDocumentOpenResult {
public VfsTextDocument {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
Objects.requireNonNull(documentName, "documentName");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(lineSeparator, "lineSeparator");
}
}

View File

@ -0,0 +1,14 @@
package p.studio.vfs;
import java.nio.file.Path;
import java.util.Objects;
public record VfsUnsupportedDocument(
Path path,
VfsUnsupportedReason reason) implements VfsDocumentOpenResult {
public VfsUnsupportedDocument {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
Objects.requireNonNull(reason, "reason");
}
}

View File

@ -0,0 +1,10 @@
package p.studio.vfs;
public enum VfsUnsupportedReason {
OUTSIDE_PROJECT,
NOT_FOUND,
NOT_A_FILE,
NO_HANDLER,
BINARY_CONTENT,
INVALID_UTF8
}

View File

@ -0,0 +1,97 @@
package p.studio.vfs;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class FilesystemProjectDocumentVfsTest {
@TempDir
Path tempDir;
@Test
void snapshotIncludesHiddenFilesOrdersFoldersFirstAndTagsSourceRoots() throws Exception {
Files.createDirectories(tempDir.resolve("src"));
Files.createDirectories(tempDir.resolve("assets"));
Files.writeString(tempDir.resolve(".env"), "TOKEN=1\n");
Files.writeString(tempDir.resolve("README.md"), "# project\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsProjectSnapshot snapshot = vfs.snapshot();
assertEquals("Example", snapshot.rootNode().displayName());
assertEquals("assets", snapshot.rootNode().children().get(0).displayName());
assertEquals("src", snapshot.rootNode().children().get(1).displayName());
assertEquals(".env", snapshot.rootNode().children().get(2).displayName());
assertTrue(snapshot.rootNode().children().get(1).taggedSourceRoot());
}
@Test
void openDocumentReturnsTextDocumentForUtf8TextFile() throws Exception {
final Path file = tempDir.resolve("main.pbs");
Files.writeString(file, "fn main(): void\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals("main.pbs", document.documentName());
assertEquals("LF", document.lineSeparator());
assertTrue(document.content().contains("fn main()"));
}
@Test
void openDocumentRejectsBinaryLikeFiles() throws Exception {
final Path file = tempDir.resolve("sprite.bin");
Files.write(file, new byte[]{0x01, 0x00, 0x02});
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsUnsupportedDocument unsupported = assertInstanceOf(VfsUnsupportedDocument.class, result);
assertEquals(VfsUnsupportedReason.BINARY_CONTENT, unsupported.reason());
}
@Test
void openDocumentRejectsPathsOutsideProjectScope() throws Exception {
final Path outsideFile = tempDir.getParent().resolve("outside.pbs");
Files.writeString(outsideFile, "fn stray(): void\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(outsideFile);
final VfsUnsupportedDocument unsupported = assertInstanceOf(VfsUnsupportedDocument.class, result);
assertEquals(VfsUnsupportedReason.OUTSIDE_PROJECT, unsupported.reason());
}
@Test
void targetedRefreshUpdatesOnlyTheRequestedSubtreeInTheSnapshot() throws Exception {
final Path src = Files.createDirectories(tempDir.resolve("src"));
Files.createDirectories(tempDir.resolve("assets"));
Files.writeString(src.resolve("main.pbs"), "fn main(): void\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
Files.writeString(src.resolve("later.pbs"), "fn later(): void\n");
final VfsProjectSnapshot refreshedSnapshot = vfs.refresh(new VfsRefreshRequest(src));
final VfsProjectNode srcNode = refreshedSnapshot.rootNode().children().get(1);
assertEquals("src", srcNode.displayName());
assertEquals(2, srcNode.children().size());
assertEquals("later.pbs", srcNode.children().get(0).displayName());
assertEquals("main.pbs", srcNode.children().get(1).displayName());
}
private VfsProjectContext projectContext() {
return new VfsProjectContext("Example", "pbs", tempDir);
}
}

View File

@ -5,6 +5,7 @@ plugins {
rootProject.name = "prometeu-studio"
include("prometeu-infra")
include("prometeu-vfs")
include("prometeu-lsp:prometeu-lsp-api")
include("prometeu-lsp:prometeu-lsp-v1")
include("prometeu-packer:prometeu-packer-api")