package p.studio.vfs; import p.studio.compiler.FrontendRegistryService; import p.studio.compiler.models.FrontendSpec; import p.studio.vfs.messages.*; 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.*; import java.util.stream.Collectors; import java.util.stream.Stream; final class FilesystemVfsProjectDocument implements VfsProjectDocument { private final VfsProjectContext projectContext; private final Map editableSnapshots = new LinkedHashMap<>(); private final Map> accessContextAttributes = new HashMap<>(); private VfsProjectSnapshot snapshot; FilesystemVfsProjectDocument(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) { try { final var supportedDocument = requireSupportedDocument(path); return openSupportedDocument(supportedDocument); } catch (UnsupportedDocumentException unsupportedDocumentException) { return new VfsDocumentOpenResult.VfsUnsupportedDocument( unsupportedDocumentException.path(), unsupportedDocumentException.reason()); } } @Override public VfsDocumentAccessContext accessContext(final Path path) { return accessContextFor(requireSupportedDocument(path)); } @Override public VfsDocumentAccessContext updateAccessContext(final Path path, final Map attributes) { final var supportedDocument = requireSupportedDocument(path); accessContextAttributes.put(supportedDocument.path(), Map.copyOf(Objects.requireNonNull(attributes, "attributes"))); return accessContextFor(supportedDocument); } @Override public VfsDocumentOpenResult.VfsTextDocument updateDocument(final Path path, final String content) { final var supportedDocument = requireSupportedDocument(path); if (supportedDocument.accessMode() != VfsDocumentAccessMode.EDITABLE) { throw new IllegalStateException("Document is hard read-only: " + supportedDocument.path()); } final var normalizedPath = supportedDocument.path(); final var snapshot = editableSnapshots.computeIfAbsent( normalizedPath, ignored -> new EditableDocumentSnapshot( supportedDocument.content(), supportedDocument.content(), supportedDocument.lineSeparator())); snapshot.content = Objects.requireNonNull(content, "content"); snapshot.lineSeparator = lineSeparator(snapshot.content); return toVfsTextDocument(supportedDocument.withContent(snapshot.content, snapshot.lineSeparator), snapshot.isDirty(), accessContextFor(supportedDocument)); } @Override public VfsDocumentSaveResult saveDocument(final Path path) { final var supportedDocument = requireSupportedDocument(path); final var accessContext = accessContextFor(supportedDocument); if (accessContext.accessMode() != VfsDocumentAccessMode.EDITABLE) { return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.READ_ONLY); } final var snapshot = editableSnapshots.computeIfAbsent( supportedDocument.path(), ignored -> new EditableDocumentSnapshot( supportedDocument.content(), supportedDocument.content(), supportedDocument.lineSeparator())); if (!snapshot.isDirty()) { return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.NO_CHANGES); } writeUtf8(supportedDocument.path(), snapshot.content); snapshot.savedContent = snapshot.content; snapshot.lineSeparator = lineSeparator(snapshot.content); return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.SAVED); } @Override public List saveAllDocuments() { return editableSnapshots.keySet().stream() .map(this::saveDocument) .toList(); } private SupportedDocument requireSupportedDocument(final Path path) { final Path normalizedPath = normalize(path); if (!normalizedPath.startsWith(projectContext.rootPath())) { throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT); } if (!Files.exists(normalizedPath)) { throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_FOUND); } if (!Files.isRegularFile(normalizedPath)) { throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_A_FILE); } final byte[] bytes = readAllBytes(normalizedPath); if (containsNulByte(bytes)) { throw new UnsupportedDocumentException(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) { throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.INVALID_UTF8, codingException); } final DocumentKind documentKind = documentKind(normalizedPath, content); return new SupportedDocument( normalizedPath, normalizedPath.getFileName().toString(), documentKind.typeId(), documentKind.frontendDocument(), documentKind.accessMode(), content, lineSeparator(content)); } private VfsDocumentOpenResult openSupportedDocument(final SupportedDocument supportedDocument) { final var accessContext = accessContextFor(supportedDocument); if (supportedDocument.accessMode() == VfsDocumentAccessMode.EDITABLE) { final var snapshot = editableSnapshots.computeIfAbsent( supportedDocument.path(), ignored -> new EditableDocumentSnapshot( supportedDocument.content(), supportedDocument.content(), supportedDocument.lineSeparator())); return toVfsTextDocument( supportedDocument.withContent(snapshot.content, snapshot.lineSeparator), snapshot.isDirty(), accessContext); } return toVfsTextDocument(supportedDocument, false, accessContext); } private VfsDocumentOpenResult.VfsTextDocument toVfsTextDocument( final SupportedDocument supportedDocument, final boolean dirty, final VfsDocumentAccessContext accessContext) { return new VfsDocumentOpenResult.VfsTextDocument( supportedDocument.path(), supportedDocument.documentName(), supportedDocument.typeId(), supportedDocument.content(), supportedDocument.lineSeparator(), dirty, accessContext); } 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 DocumentKind documentKind(final Path path, final String content) { final String extension = extensionOf(path); if ("json".equals(extension)) { return new DocumentKind(VfsDocumentTypeIds.JSON, false, VfsDocumentAccessMode.EDITABLE); } if ("ndjson".equals(extension)) { return new DocumentKind(VfsDocumentTypeIds.NDJSON, false, VfsDocumentAccessMode.EDITABLE); } if (isBashDocument(path, extension, content)) { return new DocumentKind(VfsDocumentTypeIds.BASH, false, VfsDocumentAccessMode.EDITABLE); } if (isFrontendSourceDocument(extension)) { return new DocumentKind(projectContext.languageId(), true, VfsDocumentAccessMode.READ_ONLY); } return new DocumentKind(VfsDocumentTypeIds.TEXT, false, VfsDocumentAccessMode.EDITABLE); } 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 isFrontendSourceDocument(final String extension) { if (extension.isBlank()) { return false; } return FrontendRegistryService.getFrontendSpec(projectContext.languageId()) .map(FrontendSpec::getAllowedExtensions) .stream() .flatMap(allowedExtensions -> allowedExtensions.stream()) .anyMatch(allowedExtension -> allowedExtension.equalsIgnoreCase(extension)); } private boolean isBashDocument(final Path path, final String extension, final String content) { if (Set.of("sh", "bash", "bashrc", "bash_profile").contains(extension)) { return true; } final String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); if (".bashrc".equals(fileName) || ".bash_profile".equals(fileName)) { return true; } final int firstLineBreak = content.indexOf('\n'); final String firstLine = firstLineBreak >= 0 ? content.substring(0, firstLineBreak) : content; return firstLine.startsWith("#!") && (firstLine.contains("bash") || firstLine.contains("/sh")); } private String extensionOf(final Path path) { final String fileName = path.getFileName().toString(); final int dot = fileName.lastIndexOf('.'); if (dot < 0 || dot == fileName.length() - 1) { return ""; } return fileName.substring(dot + 1).toLowerCase(Locale.ROOT); } 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"; } private byte[] readAllBytes(final Path path) { try { return Files.readAllBytes(path); } catch (IOException ioException) { throw new UncheckedIOException(ioException); } } private void writeUtf8(final Path path, final String content) { try { Files.writeString(path, content, StandardCharsets.UTF_8); } catch (IOException ioException) { throw new UncheckedIOException(ioException); } } private VfsDocumentAccessContext accessContextFor(final SupportedDocument supportedDocument) { final var attributes = accessContextAttributes.computeIfAbsent(supportedDocument.path(), ignored -> Map.of()); return new VfsDocumentAccessContext( supportedDocument.path(), supportedDocument.typeId(), supportedDocument.frontendDocument(), supportedDocument.accessMode(), attributes); } private record DocumentKind( String typeId, boolean frontendDocument, VfsDocumentAccessMode accessMode) { } private record SupportedDocument( Path path, String documentName, String typeId, boolean frontendDocument, VfsDocumentAccessMode accessMode, String content, String lineSeparator) { private SupportedDocument withContent(final String updatedContent, final String updatedLineSeparator) { return new SupportedDocument( path, documentName, typeId, frontendDocument, accessMode, updatedContent, updatedLineSeparator); } } private static final class EditableDocumentSnapshot { private String savedContent; private String content; private String lineSeparator; private EditableDocumentSnapshot( final String savedContent, final String content, final String lineSeparator) { this.savedContent = savedContent; this.content = content; this.lineSeparator = lineSeparator; } private boolean isDirty() { return !Objects.equals(savedContent, content); } } private static final class UnsupportedDocumentException extends IllegalArgumentException { private final Path path; private final VfsUnsupportedReason reason; private UnsupportedDocumentException(final Path path, final VfsUnsupportedReason reason) { super(reason.name() + ": " + path); this.path = path; this.reason = reason; } private UnsupportedDocumentException( final Path path, final VfsUnsupportedReason reason, final Throwable cause) { super(reason.name() + ": " + path, cause); this.path = path; this.reason = reason; } private Path path() { return path; } private VfsUnsupportedReason reason() { return reason; } } }