prometeu-vsf

This commit is contained in:
bQUARKz 2026-03-31 09:23:36 +01:00
parent a13d0a54c2
commit b7beef4c7e
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
21 changed files with 675 additions and 22 deletions

View File

@ -0,0 +1,24 @@
package p.studio.workspaces.editor;
import org.fxmisc.richtext.model.StyleSpans;
import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
record EditorDocumentPresentation(
String styleKey,
List<String> stylesheetUrls,
EditorDocumentSyntaxHighlighting syntaxHighlighting) {
EditorDocumentPresentation {
styleKey = Objects.requireNonNull(styleKey, "styleKey");
stylesheetUrls = List.copyOf(Objects.requireNonNull(stylesheetUrls, "stylesheetUrls"));
syntaxHighlighting = Objects.requireNonNull(syntaxHighlighting, "syntaxHighlighting");
}
StyleSpans<Collection<String>> highlight(final String content) {
return syntaxHighlighting.highlight(content);
}
}

View File

@ -0,0 +1,54 @@
package p.studio.workspaces.editor;
import p.studio.compiler.FrontendRegistryService;
import p.studio.vfs.VfsDocumentTypeIds;
import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting;
import java.util.Locale;
final class EditorDocumentPresentationRegistry {
private static final EditorDocumentPresentation TEXT_PRESENTATION = new EditorDocumentPresentation(
"text",
java.util.List.of(),
EditorDocumentSyntaxHighlighting.plainText());
private static final EditorDocumentPresentation JSON_PRESENTATION = new EditorDocumentPresentation(
"json",
java.util.List.of(stylesheet("presentations/json.css")),
EditorDocumentSyntaxHighlighting.json());
private static final EditorDocumentPresentation BASH_PRESENTATION = new EditorDocumentPresentation(
"bash",
java.util.List.of(stylesheet("presentations/bash.css")),
EditorDocumentSyntaxHighlighting.bash());
private static final EditorDocumentPresentation FRONTEND_PRESENTATION = new EditorDocumentPresentation(
"fe",
java.util.List.of(),
EditorDocumentSyntaxHighlighting.plainText());
EditorDocumentPresentation resolve(final String typeId) {
final String normalizedTypeId = normalize(typeId);
if (normalizedTypeId.isBlank()) {
return TEXT_PRESENTATION;
}
if (FrontendRegistryService.getFrontendSpec(normalizedTypeId).isPresent()) {
return FRONTEND_PRESENTATION;
}
if (VfsDocumentTypeIds.JSON.equals(normalizedTypeId)) {
return JSON_PRESENTATION;
}
if (VfsDocumentTypeIds.BASH.equals(normalizedTypeId)) {
return BASH_PRESENTATION;
}
return TEXT_PRESENTATION;
}
private String normalize(final String typeId) {
return typeId == null ? "" : typeId.trim().toLowerCase(Locale.ROOT);
}
private static String stylesheet(final String relativePath) {
return java.util.Objects.requireNonNull(
EditorDocumentPresentationRegistry.class.getResource("/themes/editor/" + relativePath),
"missing editor presentation stylesheet: " + relativePath)
.toExternalForm();
}
}

View File

@ -0,0 +1,28 @@
package p.studio.workspaces.editor;
import javafx.scene.Node;
import javafx.scene.control.Label;
final class EditorDocumentPresentationStyles {
private static final String CODE_AREA_TYPE_CLASS_PREFIX = "editor-workspace-code-area-type-";
private static final String STATUS_CHIP_TYPE_CLASS_PREFIX = "editor-workspace-status-chip-type-";
private EditorDocumentPresentationStyles() {
}
static void applyToCodeArea(final Node node, final EditorDocumentPresentation presentation) {
replaceTypeClass(node, CODE_AREA_TYPE_CLASS_PREFIX, presentation.styleKey());
}
static void applyToStatusChip(final Label label, final EditorDocumentPresentation presentation) {
replaceTypeClass(label, STATUS_CHIP_TYPE_CLASS_PREFIX, presentation.styleKey());
}
private static void replaceTypeClass(
final Node node,
final String prefix,
final String styleKey) {
node.getStyleClass().removeIf(styleClass -> styleClass.startsWith(prefix));
node.getStyleClass().add(prefix + styleKey);
}
}

View File

@ -6,11 +6,13 @@ import java.util.Objects;
public record EditorOpenFileBuffer(
Path path,
String tabLabel,
String typeId,
String content,
String lineSeparator) {
public EditorOpenFileBuffer {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
tabLabel = Objects.requireNonNull(tabLabel, "tabLabel");
typeId = Objects.requireNonNull(typeId, "typeId");
content = Objects.requireNonNull(content, "content");
lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator");
}

View File

@ -53,26 +53,30 @@ public final class EditorStatusBar extends HBox {
readOnly
);
showPlaceholder();
}
public void showFile(final ProjectReference projectReference, final EditorOpenFileBuffer fileBuffer) {
public void showFile(
final ProjectReference projectReference,
final EditorOpenFileBuffer fileBuffer,
final EditorDocumentPresentation presentation) {
showBreadcrumb(projectReference, fileBuffer.path());
showMetadata(true);
bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION);
setText(lineSeparator, fileBuffer.lineSeparator());
bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION);
setText(language, extensionText(fileBuffer.path()));
setText(language, fileBuffer.typeId());
EditorDocumentPresentationStyles.applyToStatusChip(language, presentation);
bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY);
}
public void showPlaceholder() {
public void showPlaceholder(final EditorDocumentPresentation presentation) {
breadcrumb.getChildren().clear();
showMetadata(false);
bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION);
bindDefault(lineSeparator, I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR);
bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION);
bindDefault(language, I18n.CODE_EDITOR_STATUS_LANGUAGE);
EditorDocumentPresentationStyles.applyToStatusChip(language, presentation);
bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY);
}
@ -180,12 +184,4 @@ public final class EditorStatusBar extends HBox {
label.getStyleClass().add("editor-workspace-status-chip");
}
}
private String extensionText(final java.nio.file.Path path) {
final var fileName = path.getFileName().toString();
final var dot = fileName.lastIndexOf('.');
return dot >= 0 && dot < fileName.length() - 1
? fileName.substring(dot + 1)
: fileName;
}
}

View File

@ -17,6 +17,8 @@ import p.studio.vfs.VfsDocumentOpenResult;
import p.studio.vfs.VfsProjectNode;
import p.studio.vfs.VfsTextDocument;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class EditorWorkspace extends Workspace {
@ -27,8 +29,10 @@ public final class EditorWorkspace extends Workspace {
private final EditorHelperPanel helperPanel = new EditorHelperPanel();
private final EditorStatusBar statusBar = new EditorStatusBar();
private final EditorTabStrip tabStrip = new EditorTabStrip();
private final EditorDocumentPresentationRegistry presentationRegistry = new EditorDocumentPresentationRegistry();
private final ProjectDocumentVfs projectDocumentVfs;
private final EditorOpenFileSession openFileSession = new EditorOpenFileSession();
private final List<String> activePresentationStylesheets = new ArrayList<>();
public EditorWorkspace(
final ProjectReference projectReference,
@ -40,6 +44,7 @@ public final class EditorWorkspace extends Workspace {
codeArea.setEditable(false);
codeArea.setWrapText(false);
codeArea.getStyleClass().add("editor-workspace-code-area");
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
showEditorPlaceholder();
navigatorPanel.setRefreshAction(this::refreshNavigator);
navigatorPanel.setRevealActiveFileAction(this::revealActiveFileInNavigator);
@ -50,7 +55,7 @@ public final class EditorWorkspace extends Workspace {
});
root.setCenter(buildLayout());
statusBar.showPlaceholder();
statusBar.showPlaceholder(presentationRegistry.resolve("text"));
}
@Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; }
@ -86,6 +91,7 @@ public final class EditorWorkspace extends Workspace {
openFileSession.open(new EditorOpenFileBuffer(
textDocument.path(),
textDocument.documentName(),
textDocument.typeId(),
textDocument.content(),
textDocument.lineSeparator()));
renderSession();
@ -99,13 +105,20 @@ public final class EditorWorkspace extends Workspace {
activeFile.map(EditorOpenFileBuffer::path).orElse(null));
if (activeFile.isEmpty()) {
showEditorPlaceholder();
statusBar.showPlaceholder();
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
applyPresentationStylesheets(presentation);
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
statusBar.showPlaceholder(presentation);
return;
}
final var fileBuffer = activeFile.orElseThrow();
final EditorDocumentPresentation presentation = presentationRegistry.resolve(fileBuffer.typeId());
applyPresentationStylesheets(presentation);
codeArea.replaceText(fileBuffer.content());
statusBar.showFile(projectReference, fileBuffer);
codeArea.setStyleSpans(0, presentation.highlight(fileBuffer.content()));
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
statusBar.showFile(projectReference, fileBuffer, presentation);
}
private void revealActiveFileInNavigator() {
@ -126,10 +139,22 @@ public final class EditorWorkspace extends Workspace {
}
private void showEditorPlaceholder() {
codeArea.replaceText("""
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
final String placeholder = """
// Read-only first wave
// Open a supported text file from the project navigator.
""");
""";
applyPresentationStylesheets(presentation);
codeArea.replaceText(placeholder);
codeArea.setStyleSpans(0, presentation.highlight(placeholder));
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
}
private void applyPresentationStylesheets(final EditorDocumentPresentation presentation) {
root.getStylesheets().removeAll(activePresentationStylesheets);
activePresentationStylesheets.clear();
activePresentationStylesheets.addAll(presentation.stylesheetUrls());
root.getStylesheets().addAll(activePresentationStylesheets);
}
private VBox buildLayout() {

View File

@ -0,0 +1,25 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
public record EditorDocumentHighlightToken(
String groupName,
Collection<String> styleClasses) {
public EditorDocumentHighlightToken(final String groupName, final String styleClass) {
this(groupName, List.of(styleClass));
}
public EditorDocumentHighlightToken {
groupName = Objects.requireNonNull(groupName, "groupName");
styleClasses = List.copyOf(Objects.requireNonNull(styleClasses, "styleClasses"));
if (groupName.isBlank()) {
throw new IllegalArgumentException("groupName cannot be blank");
}
if (styleClasses.isEmpty()) {
throw new IllegalArgumentException("styleClasses cannot be empty");
}
}
}

View File

@ -0,0 +1,67 @@
package p.studio.workspaces.editor.syntaxhighlight;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public record EditorDocumentSyntaxHighlighting(
Pattern tokenPattern,
List<EditorDocumentHighlightToken> tokens) {
public EditorDocumentSyntaxHighlighting {
tokens = List.copyOf(Objects.requireNonNull(tokens, "tokens"));
if (tokenPattern == null && !tokens.isEmpty()) {
throw new IllegalArgumentException("plain text highlighting cannot define tokens");
}
if (tokenPattern != null && tokens.isEmpty()) {
throw new IllegalArgumentException("token-based highlighting requires tokens");
}
}
public static EditorDocumentSyntaxHighlighting plainText() {
return EditorDocumentSyntaxHighlightingPlainText.PLAIN_TEXT;
}
public static EditorDocumentSyntaxHighlighting json() {
return EditorDocumentSyntaxHighlightingJson.JSON;
}
public static EditorDocumentSyntaxHighlighting bash() {
return EditorDocumentSyntaxHighlightingBash.BASH;
}
public StyleSpans<Collection<String>> highlight(final String content) {
final StyleSpansBuilder<Collection<String>> builder = new StyleSpansBuilder<>();
if (tokenPattern == null) {
builder.add(Collections.emptyList(), content.length());
return builder.create();
}
final Matcher matcher = tokenPattern.matcher(content);
int lastMatchEnd = 0;
while (matcher.find()) {
builder.add(Collections.emptyList(), matcher.start() - lastMatchEnd);
builder.add(styleClassFor(matcher), matcher.end() - matcher.start());
lastMatchEnd = matcher.end();
}
builder.add(Collections.emptyList(), content.length() - lastMatchEnd);
return builder.create();
}
private Collection<String> styleClassFor(final Matcher matcher) {
for (final EditorDocumentHighlightToken token : tokens) {
if (matcher.group(token.groupName()) != null) {
return token.styleClasses();
}
}
throw new IllegalStateException("matched token without registered style");
}
}

View File

@ -0,0 +1,26 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
import java.util.regex.Pattern;
public class EditorDocumentSyntaxHighlightingBash {
public static final EditorDocumentSyntaxHighlighting BASH = new EditorDocumentSyntaxHighlighting(
Pattern.compile(
"(?<SHEBANG>^#![^\\n]*)"
+ "|(?<COMMENT>(?m)(?<!\\S)#[^\\n]*)"
+ "|(?<STRING>\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
+ "|(?<VARIABLE>\\$\\{?[A-Za-z_][A-Za-z0-9_]*}?|\\$[0-9@*#?!$-])"
+ "|(?<COMMAND>(?m)^[ \\t]*[A-Za-z_./-][A-Za-z0-9_./-]*)"
+ "|(?<KEYWORD>\\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time)\\b)"
+ "|(?<BUILTIN>\\b(?:echo|printf|read|cd|pwd|export|local|readonly|unset|return|shift|source|trap|exit|test)\\b)"
+ "|(?<OPERATOR>\\|\\||&&|;;|<<-?|>>|[|&;<>~=(){}\\[\\]])"),
List.of(
new EditorDocumentHighlightToken("SHEBANG", "editor-syntax-bash-shebang"),
new EditorDocumentHighlightToken("COMMENT", "editor-syntax-bash-comment"),
new EditorDocumentHighlightToken("STRING", "editor-syntax-bash-string"),
new EditorDocumentHighlightToken("VARIABLE", "editor-syntax-bash-variable"),
new EditorDocumentHighlightToken("KEYWORD", "editor-syntax-bash-keyword"),
new EditorDocumentHighlightToken("BUILTIN", "editor-syntax-bash-builtin"),
new EditorDocumentHighlightToken("COMMAND", "editor-syntax-bash-command"),
new EditorDocumentHighlightToken("OPERATOR", "editor-syntax-bash-operator")));
}

View File

@ -0,0 +1,22 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
import java.util.regex.Pattern;
public class EditorDocumentSyntaxHighlightingJson {
public static final EditorDocumentSyntaxHighlighting JSON = new EditorDocumentSyntaxHighlighting(
Pattern.compile(
"(?<KEY>\"(?:\\\\.|[^\"\\\\])*\"(?=\\s*:))"
+ "|(?<STRING>\"(?:\\\\.|[^\"\\\\])*\")"
+ "|(?<NUMBER>-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b)"
+ "|(?<BOOLEAN>\\b(?:true|false)\\b)"
+ "|(?<NULL>\\bnull\\b)"
+ "|(?<PUNCTUATION>[\\{\\}\\[\\],:])"),
List.of(
new EditorDocumentHighlightToken("KEY", "editor-syntax-json-key"),
new EditorDocumentHighlightToken("STRING", "editor-syntax-json-string"),
new EditorDocumentHighlightToken("NUMBER", "editor-syntax-json-number"),
new EditorDocumentHighlightToken("BOOLEAN", "editor-syntax-json-boolean"),
new EditorDocumentHighlightToken("NULL", "editor-syntax-json-null"),
new EditorDocumentHighlightToken("PUNCTUATION", "editor-syntax-json-punctuation")));
}

View File

@ -0,0 +1,9 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
public class EditorDocumentSyntaxHighlightingPlainText {
public static final EditorDocumentSyntaxHighlighting PLAIN_TEXT = new EditorDocumentSyntaxHighlighting(
null,
List.of());
}

View File

@ -536,6 +536,10 @@
.editor-workspace-code-area {
-fx-background-color: #171c22;
-fx-font-family: "JetBrains Mono", "Iosevka", "Cascadia Mono", "IBM Plex Mono", monospace;
-fx-font-size: 14px;
-fx-highlight-fill: #26405c;
-fx-highlight-text-fill: #eef4fb;
}
.editor-workspace-tab-button {
@ -606,6 +610,28 @@
-fx-background-color: #171c22;
}
.editor-workspace-code-area .paragraph-box {
-fx-background-color: #171c22;
}
.editor-workspace-code-area .lineno {
-fx-background-color: #12161c;
-fx-text-fill: #6f7a86;
-fx-padding: 0 12 0 12;
}
.editor-workspace-code-area .text {
-fx-fill: #f2f6fb;
}
.editor-workspace-code-area-type-text .text {
-fx-fill: #eef3f8;
}
.editor-workspace-code-area-type-fe .text {
-fx-fill: #f2f6fb;
}
.workspace-dock-pane {
-fx-collapsible: true;
-fx-background-color: transparent;
@ -714,6 +740,18 @@
-fx-max-height: 28;
}
.editor-workspace-status-chip-type-text {
-fx-background-color: #11151b;
-fx-border-color: #2a313c;
-fx-text-fill: #c5d2de;
}
.editor-workspace-status-chip-type-fe {
-fx-background-color: #271a35;
-fx-border-color: #8d67c7;
-fx-text-fill: #f3ecff;
}
.editor-workspace-status-chip-position {
-fx-min-width: 44;
-fx-pref-width: 44;

View File

@ -0,0 +1,39 @@
.editor-workspace-code-area-type-bash .text {
-fx-fill: #edf2f7;
}
.editor-workspace-code-area-type-bash .lineno {
-fx-text-fill: #788595;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-shebang {
-fx-fill: #f0ae63;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-comment {
-fx-fill: #7d8a98;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-string {
-fx-fill: #dcbf88;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-variable {
-fx-fill: #8fd4ff;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-keyword {
-fx-fill: #d7a6ff;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-builtin {
-fx-fill: #9fe2a0;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-command {
-fx-fill: #7fc1ff;
}
.editor-workspace-code-area-type-bash .text.editor-syntax-bash-operator {
-fx-fill: #c7d3de;
}

View File

@ -0,0 +1,41 @@
.editor-workspace-code-area-type-json {
-fx-highlight-fill: #204766;
}
.editor-workspace-code-area-type-json .text {
-fx-fill: #dff3ff;
}
.editor-workspace-code-area-type-json .lineno {
-fx-text-fill: #7e92a6;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-key {
-fx-fill: #8fd4ff;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-string {
-fx-fill: #d9c48f;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-number {
-fx-fill: #9ee39f;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-boolean {
-fx-fill: #f3a6d6;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-null {
-fx-fill: #c89cff;
}
.editor-workspace-code-area-type-json .text.editor-syntax-json-punctuation {
-fx-fill: #c8d5e2;
}
.editor-workspace-status-chip-type-json {
-fx-background-color: #132433;
-fx-border-color: #4e88b8;
-fx-text-fill: #e2f4ff;
}

View File

@ -0,0 +1,35 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class EditorDocumentPresentationRegistryTest {
private final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
@Test
void resolvesFrontendTypeIdsToFrontendPresentation() {
assertEquals("fe", registry.resolve("pbs").styleKey());
}
@Test
void resolvesJsonTypeIdsToJsonPresentation() {
final EditorDocumentPresentation presentation = registry.resolve("json");
assertEquals("json", presentation.styleKey());
assertEquals(1, presentation.stylesheetUrls().size());
}
@Test
void resolvesBashTypeIdsToBashPresentation() {
final EditorDocumentPresentation presentation = registry.resolve("bash");
assertEquals("bash", presentation.styleKey());
assertEquals(1, presentation.stylesheetUrls().size());
}
@Test
void fallsBackToTextPresentationForUnknownTypeIds() {
assertEquals("text", registry.resolve("markdown").styleKey());
}
}

View File

@ -11,7 +11,7 @@ final class EditorOpenFileSessionTest {
@Test
void openAddsNewFileAndMarksItActive() {
final var session = new EditorOpenFileSession();
final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "fn main(): void\n", "LF");
final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "fn main(): void\n", "LF");
session.open(file);
@ -22,8 +22,8 @@ final class EditorOpenFileSessionTest {
@Test
void openDoesNotDuplicateExistingTab() {
final var session = new EditorOpenFileSession();
final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF");
final var second = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "b", "LF");
final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "a", "LF");
final var second = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "b", "LF");
session.open(first);
session.open(second);
@ -36,8 +36,8 @@ final class EditorOpenFileSessionTest {
@Test
void activateSwitchesTheActiveTabWithinTheCurrentSession() {
final var session = new EditorOpenFileSession();
final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF");
final var second = new EditorOpenFileBuffer(Path.of("src/other.pbs"), "other.pbs", "b", "LF");
final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "pbs", "a", "LF");
final var second = new EditorOpenFileBuffer(Path.of("src/other.pbs"), "other.pbs", "pbs", "b", "LF");
session.open(first);
session.open(second);

View File

@ -0,0 +1,10 @@
package p.studio.vfs;
public final class VfsDocumentTypeIds {
public static final String TEXT = "text";
public static final String JSON = "json";
public static final String BASH = "bash";
private VfsDocumentTypeIds() {
}
}

View File

@ -6,12 +6,14 @@ import java.util.Objects;
public record VfsTextDocument(
Path path,
String documentName,
String typeId,
String content,
String lineSeparator) implements VfsDocumentOpenResult {
public VfsTextDocument {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
Objects.requireNonNull(documentName, "documentName");
Objects.requireNonNull(typeId, "typeId");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(lineSeparator, "lineSeparator");
}

View File

@ -1,6 +1,7 @@
package p.studio.vfs;
import p.studio.compiler.FrontendRegistryService;
import p.studio.compiler.models.FrontendSpec;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -12,6 +13,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@ -98,6 +100,7 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
return new VfsTextDocument(
normalizedPath,
normalizedPath.getFileName().toString(),
documentTypeId(normalizedPath, content),
content,
lineSeparator(content));
}
@ -118,6 +121,20 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
.collect(Collectors.toSet());
}
private String documentTypeId(final Path path, final String content) {
final String extension = extensionOf(path);
if ("json".equals(extension) || "ndjson".equals(extension)) {
return VfsDocumentTypeIds.JSON;
}
if (isBashDocument(path, extension, content)) {
return VfsDocumentTypeIds.BASH;
}
if (isFrontendSourceDocument(extension)) {
return projectContext.languageId();
}
return VfsDocumentTypeIds.TEXT;
}
private VfsProjectNode buildNode(
final Path path,
final String displayName,
@ -194,6 +211,39 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
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) {

View File

@ -43,10 +43,50 @@ final class FilesystemProjectDocumentVfsTest {
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals("main.pbs", document.documentName());
assertEquals("pbs", document.typeId());
assertEquals("LF", document.lineSeparator());
assertTrue(document.content().contains("fn main()"));
}
@Test
void openDocumentClassifiesJsonFilesWithJsonTypeIdentifier() throws Exception {
final Path file = tempDir.resolve("prometeu.json");
Files.writeString(file, "{\n \"name\": \"Example\"\n}\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.JSON, document.typeId());
}
@Test
void openDocumentClassifiesNdjsonFilesWithJsonTypeIdentifier() throws Exception {
final Path file = tempDir.resolve("events.ndjson");
Files.writeString(file, "{\"event\":\"start\"}\n{\"event\":\"done\"}\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.JSON, document.typeId());
}
@Test
void openDocumentClassifiesBashScriptsWithBashTypeIdentifier() throws Exception {
final Path file = tempDir.resolve("script.sh");
Files.writeString(file, "#!/usr/bin/env bash\nprintf \"hi\"\n");
final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.BASH, document.typeId());
}
@Test
void openDocumentRejectsBinaryLikeFiles() throws Exception {
final Path file = tempDir.resolve("sprite.bin");

View File

@ -208,6 +208,126 @@
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Assets",
"message" : "0 assets loaded",
"severity" : "SUCCESS",
"sticky" : false
}, {
"source" : "Assets",
"message" : "Asset scan started",
"severity" : "INFO",
"sticky" : false
}, {
"source" : "Studio",
"message" : "Project ready",