dev/pbs-lsp-remaining-editor-surface #31

Merged
bquarkz merged 8 commits from dev/pbs-lsp-remaining-editor-surface into master 2026-09-22 09:00:26 +00:00
10 changed files with 286 additions and 4 deletions
Showing only changes of commit e456bb8d7a - Show all commits

View File

@ -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.

View File

@ -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<PbsAst.TopDecl> typedSupplementalTopDecls =
(ReadOnlyList<PbsAst.TopDecl>) 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<FrontendCompletionCandidate> moduleCompletions(final List<String> labels) {
if (labels == null || labels.isEmpty()) {
return List.of();
}
final ArrayList<FrontendCompletionCandidate> 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(),

View File

@ -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<StdlibModuleSource> 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<List<String>> modulePaths = "jar".equals(url.getProtocol())
? jarModulePaths(url, root)
: fileModulePaths(url);
final ArrayList<StdlibModuleSource> modules = new ArrayList<>();
for (final List<String> modulePath : modulePaths) {
if (modulePath.isEmpty()) {
continue;
}
final String project = modulePath.getFirst();
final ReadOnlyList<String> 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<List<String>> fileModulePaths(final URL url) throws Exception {
final Path rootPath = Path.of(url.toURI());
final ArrayList<List<String>> 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<List<String>> 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<List<String>> 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<String> segments(final Path relative) {
final ArrayList<String> segments = new ArrayList<>();
for (final Path segment : relative) {
segments.add(segment.toString());
}
return segments;
}
private String moduleBasePath(
final String project,
final ReadOnlyList<String> pathSegments) {

View File

@ -6,5 +6,9 @@ import java.util.Optional;
public interface StdlibEnvironment {
Optional<StdlibModuleSource> resolveModule(String project, ReadOnlyList<String> pathSegments);
default ReadOnlyList<StdlibModuleSource> modules() {
return ReadOnlyList.empty();
}
}

View File

@ -152,12 +152,14 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
inlineHintsByFile.put(parsedSourceFile.fileId(), inlineHints);
}
}
final Map<ModuleReference, Path> 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<ModuleReference, Path> physicalModuleFiles(
@ -190,12 +192,41 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
return Map.copyOf(files);
}
private static List<String> moduleCompletionLabels(
final FrontendPhaseContext ctx,
final Map<ModuleReference, Path> physicalModuleFiles) {
final var labels = new java.util.TreeSet<String>();
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<String> pathSegments) {
if (project == null || project.isBlank()) {
return "";
}
if (pathSegments == null || pathSegments.isEmpty()) {
return "@" + project;
}
return "@" + project + ":" + String.join("/", pathSegments.asList());
}
public record PbsSemanticReadSurface(
Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile,
Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile,
Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile,
Map<FileId, SourceKind> sourceKindByFile,
Map<ModuleReference, Path> physicalModuleFileByReference) {
Map<ModuleReference, Path> physicalModuleFileByReference,
List<String> moduleCompletionLabels) {
}
private IRBackend mergeCompiledSources(

View File

@ -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<String> labels(final List<FrontendCompletionCandidate> 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()));
}
}

View File

@ -17,5 +17,6 @@ public enum FrontendSymbolKind {
ENUM,
ERROR,
GLOBAL,
CONST
CONST,
MODULE
}

View File

@ -16,5 +16,6 @@ public enum BaselineCompletionItemKind {
ENUM,
ERROR,
GLOBAL,
CONST
CONST,
MODULE
}

View File

@ -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;
};
}

View File

@ -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;
};
}