From e456bb8d7a9c246cadcfced1ecfc8dea3d912516 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 22 Sep 2026 09:13:40 +0100 Subject: [PATCH] implements PLN-0137 (6/7) module-ref-completion Completion inside a ModuleRef lists project and stdlib modules the current resolver can already name. Completion after a dot stays the existing member list. Items remain eager, resolveProvider stays false, and there is no snippet or ranking score. --- ...ler Pipeline Entry Points Specification.md | 2 + .../compiler/PBSFrontendLanguageService.java | 41 ++++++ .../ResourceStdlibEnvironmentResolver.java | 73 ++++++++++ .../pbs/stdlib/StdlibEnvironment.java | 4 + .../services/PBSFrontendPhaseService.java | 35 ++++- .../PBSFrontendModuleCompletionTest.java | 127 ++++++++++++++++++ .../compiler/services/FrontendSymbolKind.java | 3 +- .../messages/BaselineCompletionItemKind.java | 3 +- .../CompilerLanguageServiceBridge.java | 1 + .../mapping/Lsp4jProtocolMessageMapper.java | 1 + 10 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendModuleCompletionTest.java diff --git a/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md b/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md index 84eba27a..da97f4d6 100644 --- a/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md +++ b/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md @@ -294,6 +294,8 @@ When a frontend exposes folding ranges, the ranges MUST come from recovered synt When a frontend exposes selection ranges, the chain at a cursor MUST run from the inside outward in this order: identifier, argument or parameter list, block or `Doc` text block, declaration. A layer that does not contain the cursor MUST be omitted. A missing folding or selection capability MUST NOT be advertised and MUST produce an empty list rather than a protocol error. +When a frontend exposes completion inside a module reference, the candidates MUST be project and stdlib modules the current module resolver can already name. Completion outside a module reference MUST keep its existing member and general candidates. Completion items MUST stay eager. `resolveProvider` MUST stay false. Snippets and a new ranking score MUST NOT be added. + When a frontend exposes semantic tokens, the lexical token provider remains the baseline. An identifier token MAY be replaced with an existing semantic key when the same resolution used by hover names that symbol and an existing key matches it. Resolution failure, a broken file, or a symbol with no existing key MUST keep the baseline token. This overlay MUST NOT add a semantic-token key. When a frontend exposes formatting, it MUST be full-document formatting only. Range formatting and on-type formatting MUST NOT be advertised. The formatter MUST reprint the existing token stream. It MUST NOT reorder declarations, join lines, or split lines. Indentation MUST be four spaces and MUST change only where brace or parenthesis nesting changes. The interior of a `Doc` text block MUST be copied unchanged. A comment MUST stay on the line of the token it already follows. A missing formatting capability MUST NOT be advertised and MUST produce an empty edit list rather than a protocol error. diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java index 9b674375..194cd5f1 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java @@ -24,6 +24,7 @@ import p.studio.compiler.pbs.semantics.PbsQuickFixCollector; import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface; import p.studio.compiler.services.FrontendCodeAction; import p.studio.compiler.services.FrontendCompletionCandidate; +import p.studio.compiler.services.FrontendSymbolKind; import p.studio.compiler.services.FrontendDocumentLink; import p.studio.compiler.services.FrontendFoldingRange; import p.studio.compiler.services.FrontendSelectionRange; @@ -135,6 +136,10 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService return List.of(); } + if (insideModuleRef(ast, offset) + && editorialContext.projectSurface() instanceof PbsSemanticReadSurface surface) { + return moduleCompletions(surface.moduleCompletionLabels()); + } @SuppressWarnings("unchecked") final ReadOnlyList typedSupplementalTopDecls = (ReadOnlyList) supplementalTopDecls; @@ -595,6 +600,42 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService return semanticTokenProvider.tokenize(documentText == null ? "" : documentText); } + private static boolean insideModuleRef(final PbsAst.File ast, final int offset) { + if (ast == null || ast.imports() == null || offset < 0) { + return false; + } + for (final PbsAst.ImportDecl importDecl : ast.imports()) { + if (importDecl == null || importDecl.moduleRef() == null || importDecl.moduleRef().span() == null) { + continue; + } + final long start = importDecl.moduleRef().span().getStart(); + final long end = importDecl.moduleRef().span().getEnd(); + if (end > start && start <= offset && offset <= end) { + return true; + } + } + return false; + } + + private static List moduleCompletions(final List labels) { + if (labels == null || labels.isEmpty()) { + return List.of(); + } + final ArrayList candidates = new ArrayList<>(); + for (final String label : labels) { + if (label == null || label.isBlank()) { + continue; + } + candidates.add(new FrontendCompletionCandidate( + label, + FrontendSymbolKind.MODULE, + "module", + "", + "")); + } + return List.copyOf(candidates); + } + private FrontendCompletionCandidate toFrontendCompletionCandidate(final PbsEditorialCompletionCandidate candidate) { return new FrontendCompletionCandidate( candidate.label(), diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/ResourceStdlibEnvironmentResolver.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/ResourceStdlibEnvironmentResolver.java index 982fb845..e439e147 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/ResourceStdlibEnvironmentResolver.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/ResourceStdlibEnvironmentResolver.java @@ -5,9 +5,15 @@ import p.studio.utilities.structures.ReadOnlyList; import java.io.IOException; import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.jar.JarEntry; public final class ResourceStdlibEnvironmentResolver implements StdlibEnvironmentResolver { private static final String STDLIB_ROOT = "stdlib"; @@ -37,6 +43,73 @@ public final class ResourceStdlibEnvironmentResolver implements StdlibEnvironmen barrelSource.get())); } + @Override + public ReadOnlyList modules() { + final String root = target.exportReferenceName().toLowerCase() + + "/" + STDLIB_ROOT + "/" + stdlibVersion; + final ClassLoader classLoader = ResourceStdlibEnvironmentResolver.class.getClassLoader(); + if (classLoader == null) { + return ReadOnlyList.empty(); + } + final URL url = classLoader.getResource(root); + if (url == null) { + return ReadOnlyList.empty(); + } + try { + final List> modulePaths = "jar".equals(url.getProtocol()) + ? jarModulePaths(url, root) + : fileModulePaths(url); + final ArrayList modules = new ArrayList<>(); + for (final List modulePath : modulePaths) { + if (modulePath.isEmpty()) { + continue; + } + final String project = modulePath.getFirst(); + final ReadOnlyList segments = ReadOnlyList.wrap(modulePath.subList(1, modulePath.size())); + resolveModule(project, segments).ifPresent(modules::add); + } + return ReadOnlyList.wrap(modules); + } catch (final Exception ignored) { + return ReadOnlyList.empty(); + } + } + + private List> fileModulePaths(final URL url) throws Exception { + final Path rootPath = Path.of(url.toURI()); + final ArrayList> modulePaths = new ArrayList<>(); + try (var walk = Files.walk(rootPath)) { + walk.filter(path -> "main.pbs".equals(String.valueOf(path.getFileName())) + && Files.isRegularFile(path.getParent().resolve("mod.barrel"))) + .forEach(path -> modulePaths.add(segments(rootPath.relativize(path.getParent())))); + } + return modulePaths; + } + + private List> jarModulePaths(final URL url, final String root) throws IOException { + final JarURLConnection connection = (JarURLConnection) url.openConnection(); + final var jarFile = connection.getJarFile(); + final String prefix = root + "/"; + final ArrayList> modulePaths = new ArrayList<>(); + jarFile.stream() + .map(JarEntry::getName) + .filter(name -> name.startsWith(prefix) && name.endsWith("/main.pbs")) + .forEach(name -> { + final String directory = name.substring(prefix.length(), name.length() - "/main.pbs".length()); + if (!directory.isBlank() && jarFile.getEntry(prefix + directory + "/mod.barrel") != null) { + modulePaths.add(List.of(directory.split("/"))); + } + }); + return modulePaths; + } + + private static List segments(final Path relative) { + final ArrayList segments = new ArrayList<>(); + for (final Path segment : relative) { + segments.add(segment.toString()); + } + return segments; + } + private String moduleBasePath( final String project, final ReadOnlyList pathSegments) { diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/StdlibEnvironment.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/StdlibEnvironment.java index 88c72513..a27c9216 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/StdlibEnvironment.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/stdlib/StdlibEnvironment.java @@ -6,5 +6,9 @@ import java.util.Optional; public interface StdlibEnvironment { Optional resolveModule(String project, ReadOnlyList pathSegments); + + default ReadOnlyList modules() { + return ReadOnlyList.empty(); + } } diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java index afa5edca..b0ed3b90 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java @@ -152,12 +152,14 @@ public class PBSFrontendPhaseService implements FrontendPhaseService { inlineHintsByFile.put(parsedSourceFile.fileId(), inlineHints); } } + final Map physicalModuleFiles = physicalModuleFiles(ctx, assembly); return new PbsSemanticReadSurface( Map.copyOf(astByFile), Map.copyOf(supplementalTopDeclsByFile), Map.copyOf(inlineHintsByFile), Map.copyOf(sourceKindByFile), - physicalModuleFiles(ctx, assembly)); + physicalModuleFiles, + moduleCompletionLabels(ctx, physicalModuleFiles)); } private static Map physicalModuleFiles( @@ -190,12 +192,41 @@ public class PBSFrontendPhaseService implements FrontendPhaseService { return Map.copyOf(files); } + private static List moduleCompletionLabels( + final FrontendPhaseContext ctx, + final Map physicalModuleFiles) { + final var labels = new java.util.TreeSet(); + for (final ModuleReference reference : physicalModuleFiles.keySet()) { + labels.add(moduleLabel(reference.project(), reference.pathSegments())); + } + if (ctx != null) { + final var environment = new ResourceStdlibEnvironmentResolver().resolve(ctx.stdlibVersion(), ctx.target()); + for (final var module : environment.modules()) { + labels.add(moduleLabel(module.project(), module.pathSegments())); + } + } + return List.copyOf(labels); + } + + private static String moduleLabel( + final String project, + final ReadOnlyList pathSegments) { + if (project == null || project.isBlank()) { + return ""; + } + if (pathSegments == null || pathSegments.isEmpty()) { + return "@" + project; + } + return "@" + project + ":" + String.join("/", pathSegments.asList()); + } + public record PbsSemanticReadSurface( Map astByFile, Map> supplementalTopDeclsByFile, Map> inlineHintsByFile, Map sourceKindByFile, - Map physicalModuleFileByReference) { + Map physicalModuleFileByReference, + List moduleCompletionLabels) { } private IRBackend mergeCompiledSources( diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendModuleCompletionTest.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendModuleCompletionTest.java new file mode 100644 index 00000000..56268c60 --- /dev/null +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendModuleCompletionTest.java @@ -0,0 +1,127 @@ +package p.studio.compiler; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.AppMode; +import p.studio.compiler.messages.FrontendPhaseContext; +import p.studio.compiler.models.BuildStack; +import p.studio.compiler.models.ProjectDescriptor; +import p.studio.compiler.models.SourceHandle; +import p.studio.compiler.services.FrontendCompletionCandidate; +import p.studio.compiler.services.FrontendDocumentRequest; +import p.studio.compiler.services.FrontendSymbolKind; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.compiler.source.identifiers.ProjectId; +import p.studio.compiler.source.tables.FileTable; +import p.studio.compiler.source.tables.ProjectTable; +import p.studio.compiler.utilities.SourceProviderFactory; +import p.studio.utilities.structures.ReadOnlyList; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PBSFrontendModuleCompletionTest { + + @TempDir + Path tempDir; + + @Test + void moduleRefCompletionListsKnownModulesAndMemberCompletionStays() throws IOException { + final Path projectRoot = tempDir.resolve("app"); + final Path sourceRoot = projectRoot.resolve("src"); + final Path moduleA = sourceRoot.resolve("a").resolve("source.pbs"); + final Path main = sourceRoot.resolve("main.pbs"); + Files.createDirectories(moduleA.getParent()); + Files.writeString(moduleA, """ + fn target() -> int { + return 1; + } + """); + Files.writeString(moduleA.getParent().resolve("mod.barrel"), "pub fn target() -> int;\n"); + final String caller = """ + import { Log } from @sdk:log; + + declare struct Vec() { + fn blend(dx: int, dy: int) -> int { return dx; } + } + + fn frame(vec: Vec) -> void { + vec.blend(1, 2); + } + """; + Files.writeString(main, caller); + Files.writeString(sourceRoot.resolve("mod.barrel"), """ + pub struct Vec(); + pub fn frame(vec: Vec) -> void; + """); + + final var projectTable = new ProjectTable(); + final var fileTable = new FileTable(1); + final ProjectId projectId = projectTable.register(ProjectDescriptor.builder() + .rootPath(projectRoot) + .name("app") + .version("1.0.0") + .sourceRoots(ReadOnlyList.wrap(List.of(sourceRoot))) + .build()); + register(projectId, projectRoot, moduleA, fileTable); + register(projectId, projectRoot, moduleA.getParent().resolve("mod.barrel"), fileTable); + final FileId mainId = register(projectId, projectRoot, main, fileTable); + register(projectId, projectRoot, sourceRoot.resolve("mod.barrel"), fileTable); + + final var phaseContext = new FrontendPhaseContext( + projectTable, + fileTable, + new BuildStack(ReadOnlyList.wrap(List.of(projectId))), + AppMode.Game); + final var service = new PBSFrontendLanguageService(); + final var editorialContext = service.prepareEditorialContext(phaseContext, mainId).orElseThrow(); + final var request = new FrontendDocumentRequest(projectRoot, main, caller); + + final var moduleCompletion = service.completion(request, editorialContext, byteOffset(caller, "@sdk:log")); + assertTrue(labels(moduleCompletion).contains("@sdk:log")); + assertTrue(labels(moduleCompletion).contains("@app:a")); + assertTrue(moduleCompletion.stream().allMatch(candidate -> candidate.kind() == FrontendSymbolKind.MODULE)); + + final int afterDot = caller.indexOf("vec.") + "vec.".length(); + final var memberCompletion = service.completion( + request, + editorialContext, + caller.substring(0, afterDot).getBytes(StandardCharsets.UTF_8).length); + assertTrue(labels(memberCompletion).contains("blend"), labels(memberCompletion).toString()); + assertFalse(labels(memberCompletion).contains("@sdk:log")); + } + + private static List labels(final List candidates) { + return candidates.stream().map(FrontendCompletionCandidate::label).toList(); + } + + private static int byteOffset(final String source, final String marker) { + final int index = source.indexOf(marker); + if (index < 0) { + throw new IllegalArgumentException(marker); + } + return source.substring(0, index + 1).getBytes(StandardCharsets.UTF_8).length; + } + + private static FileId register( + final ProjectId projectId, + final Path projectRoot, + final Path file, + final FileTable fileTable) throws IOException { + final BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); + return fileTable.register(new SourceHandle( + projectId, + projectRoot.relativize(file), + file, + attributes.size(), + attributes.lastModifiedTime().toMillis(), + SourceProviderFactory.filesystem())); + } +} diff --git a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSymbolKind.java b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSymbolKind.java index cfec8cda..b90f5b31 100644 --- a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSymbolKind.java +++ b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSymbolKind.java @@ -17,5 +17,6 @@ public enum FrontendSymbolKind { ENUM, ERROR, GLOBAL, - CONST + CONST, + MODULE } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineCompletionItemKind.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineCompletionItemKind.java index 28da0724..5794c8a4 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineCompletionItemKind.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineCompletionItemKind.java @@ -16,5 +16,6 @@ public enum BaselineCompletionItemKind { ENUM, ERROR, GLOBAL, - CONST + CONST, + MODULE } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java index dd54c7fb..e311c457 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java @@ -884,6 +884,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg case ERROR -> BaselineCompletionItemKind.ERROR; case GLOBAL -> BaselineCompletionItemKind.GLOBAL; case CONST -> BaselineCompletionItemKind.CONST; + case MODULE -> BaselineCompletionItemKind.MODULE; }; } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java index 95dd0311..18b10959 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java @@ -479,6 +479,7 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { case ENUM -> CompletionItemKind.Enum; case ERROR -> CompletionItemKind.EnumMember; case GLOBAL, CONST -> CompletionItemKind.Constant; + case MODULE -> CompletionItemKind.Module; }; }