466 lines
18 KiB
Java
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;
|
|
}
|
|
}
|
|
}
|