prometeu-studio/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java
2026-03-31 16:45:47 +01:00

466 lines
18 KiB
Java

package p.studio.vfs;
import p.studio.compiler.FrontendRegistryService;
import p.studio.compiler.models.FrontendSpec;
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.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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 final Map<Path, EditableDocumentSnapshot> editableSnapshots = new LinkedHashMap<>();
private final Map<Path, Map<String, String>> accessContextAttributes = new HashMap<>();
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) {
try {
final var supportedDocument = requireSupportedDocument(path);
return openSupportedDocument(supportedDocument);
} catch (UnsupportedDocumentException unsupportedDocumentException) {
return new 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<String, String> attributes) {
final var supportedDocument = requireSupportedDocument(path);
accessContextAttributes.put(supportedDocument.path(), Map.copyOf(Objects.requireNonNull(attributes, "attributes")));
return accessContextFor(supportedDocument);
}
@Override
public 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<VfsDocumentSaveResult> 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 VfsTextDocument toVfsTextDocument(
final SupportedDocument supportedDocument,
final boolean dirty,
final VfsDocumentAccessContext accessContext) {
return new 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<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 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<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 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;
}
}
}