From c678fe6f49ecd6c163e948810099658e7ef16f4e Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 31 Mar 2026 08:05:05 +0100 Subject: [PATCH] implements PLN-0016 --- discussion/index.ndjson | 2 +- ...ild-prometeu-vfs-filesystem-backed-core.md | 4 +- prometeu-studio/build.gradle.kts | 1 + prometeu-vfs/build.gradle.kts | 8 + .../vfs/FilesystemProjectDocumentVfs.java | 209 ++++++++++++++++++ .../FilesystemProjectDocumentVfsFactory.java | 10 + .../java/p/studio/vfs/ProjectDocumentVfs.java | 19 ++ .../studio/vfs/ProjectDocumentVfsFactory.java | 5 + .../p/studio/vfs/VfsDocumentOpenResult.java | 7 + .../java/p/studio/vfs/VfsProjectContext.java | 16 ++ .../java/p/studio/vfs/VfsProjectNode.java | 19 ++ .../java/p/studio/vfs/VfsProjectSnapshot.java | 14 ++ .../java/p/studio/vfs/VfsRefreshRequest.java | 10 + .../java/p/studio/vfs/VfsTextDocument.java | 18 ++ .../p/studio/vfs/VfsUnsupportedDocument.java | 14 ++ .../p/studio/vfs/VfsUnsupportedReason.java | 10 + .../vfs/FilesystemProjectDocumentVfsTest.java | 97 ++++++++ settings.gradle.kts | 1 + 18 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 prometeu-vfs/build.gradle.kts create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfsFactory.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfs.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfsFactory.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsDocumentOpenResult.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectContext.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectNode.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectSnapshot.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsRefreshRequest.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsTextDocument.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedDocument.java create mode 100644 prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedReason.java create mode 100644 prometeu-vfs/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 0fdb8037..28aa88dd 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -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":[]} diff --git a/discussion/workflow/plans/PLN-0016-build-prometeu-vfs-filesystem-backed-core.md b/discussion/workflow/plans/PLN-0016-build-prometeu-vfs-filesystem-backed-core.md index 1ae32aa5..865e4d7a 100644 --- a/discussion/workflow/plans/PLN-0016-build-prometeu-vfs-filesystem-backed-core.md +++ b/discussion/workflow/plans/PLN-0016-build-prometeu-vfs-filesystem-backed-core.md @@ -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 diff --git a/prometeu-studio/build.gradle.kts b/prometeu-studio/build.gradle.kts index 16fc90df..3b3206b7 100644 --- a/prometeu-studio/build.gradle.kts +++ b/prometeu-studio/build.gradle.kts @@ -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")) diff --git a/prometeu-vfs/build.gradle.kts b/prometeu-vfs/build.gradle.kts new file mode 100644 index 00000000..223ac4c6 --- /dev/null +++ b/prometeu-vfs/build.gradle.kts @@ -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")) +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java b/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java new file mode 100644 index 00000000..91e23216 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java @@ -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 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 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 taggedRoots) { + if (!Files.isDirectory(path)) { + return new VfsProjectNode(path, displayName, false, taggedRoots.contains(path), List.of()); + } + + final List children; + try (Stream 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 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 nodeComparator() { + return Comparator + .comparing((Path path) -> !Files.isDirectory(path)) + .thenComparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER); + } + + private Comparator 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"; + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfsFactory.java b/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfsFactory.java new file mode 100644 index 00000000..a2089836 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfsFactory.java @@ -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")); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfs.java b/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfs.java new file mode 100644 index 00000000..c11fd5ad --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfs.java @@ -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() { + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfsFactory.java b/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfsFactory.java new file mode 100644 index 00000000..4bc858e2 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/ProjectDocumentVfsFactory.java @@ -0,0 +1,5 @@ +package p.studio.vfs; + +public interface ProjectDocumentVfsFactory { + ProjectDocumentVfs open(VfsProjectContext projectContext); +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsDocumentOpenResult.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsDocumentOpenResult.java new file mode 100644 index 00000000..4b968ec1 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsDocumentOpenResult.java @@ -0,0 +1,7 @@ +package p.studio.vfs; + +import java.nio.file.Path; + +public sealed interface VfsDocumentOpenResult permits VfsTextDocument, VfsUnsupportedDocument { + Path path(); +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectContext.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectContext.java new file mode 100644 index 00000000..b4c6367c --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectContext.java @@ -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(); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectNode.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectNode.java new file mode 100644 index 00000000..e6ec9a6e --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectNode.java @@ -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 children) { + + public VfsProjectNode { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + Objects.requireNonNull(displayName, "displayName"); + children = List.copyOf(Objects.requireNonNull(children, "children")); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectSnapshot.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectSnapshot.java new file mode 100644 index 00000000..a8af0159 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsProjectSnapshot.java @@ -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"); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsRefreshRequest.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsRefreshRequest.java new file mode 100644 index 00000000..234dd314 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsRefreshRequest.java @@ -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(); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsTextDocument.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsTextDocument.java new file mode 100644 index 00000000..7ac3a66c --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsTextDocument.java @@ -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"); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedDocument.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedDocument.java new file mode 100644 index 00000000..58bc8f8f --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedDocument.java @@ -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"); + } +} diff --git a/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedReason.java b/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedReason.java new file mode 100644 index 00000000..c050bc06 --- /dev/null +++ b/prometeu-vfs/src/main/java/p/studio/vfs/VfsUnsupportedReason.java @@ -0,0 +1,10 @@ +package p.studio.vfs; + +public enum VfsUnsupportedReason { + OUTSIDE_PROJECT, + NOT_FOUND, + NOT_A_FILE, + NO_HANDLER, + BINARY_CONTENT, + INVALID_UTF8 +} diff --git a/prometeu-vfs/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java b/prometeu-vfs/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java new file mode 100644 index 00000000..6a783615 --- /dev/null +++ b/prometeu-vfs/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java @@ -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); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e2c55c2..8b1fe1a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")