implements PLN-0075

This commit is contained in:
bQUARKz 2026-05-08 10:04:39 +01:00
parent 240525286b
commit 7f40541e96
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
9 changed files with 1661 additions and 0 deletions

View File

@ -0,0 +1,30 @@
package p.studio.compiler.pbs.semantics;
import java.util.Objects;
public record PbsEditorialCompletionCandidate(
String label,
PbsEditorialSymbolKind kind,
String detail,
String origin) {
public PbsEditorialCompletionCandidate {
label = requireText(label, "label");
kind = Objects.requireNonNull(kind, "kind");
detail = normalize(detail);
origin = normalize(origin);
}
private static String requireText(
final String value,
final String field) {
final String candidate = normalize(value);
if (candidate.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return candidate;
}
private static String normalize(final String value) {
return value == null ? "" : value.trim();
}
}

View File

@ -0,0 +1,35 @@
package p.studio.compiler.pbs.semantics;
import java.util.List;
import java.util.Objects;
public record PbsEditorialResolvedSymbol(
String displayName,
PbsEditorialSymbolKind kind,
String detail,
String origin,
List<PbsEditorialSignature> signatures,
String documentation) {
public PbsEditorialResolvedSymbol {
displayName = requireText(displayName, "displayName");
kind = Objects.requireNonNull(kind, "kind");
detail = normalize(detail);
origin = normalize(origin);
signatures = List.copyOf(Objects.requireNonNull(signatures, "signatures"));
documentation = normalize(documentation);
}
private static String requireText(
final String value,
final String field) {
final String candidate = normalize(value);
if (candidate.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return candidate;
}
private static String normalize(final String value) {
return value == null ? "" : value.trim();
}
}

View File

@ -0,0 +1,27 @@
package p.studio.compiler.pbs.semantics;
import java.util.List;
import java.util.Objects;
public record PbsEditorialSignature(
String callableName,
String label,
List<String> parameterLabels,
String returnLabel) {
public PbsEditorialSignature {
callableName = requireText(callableName, "callableName");
label = requireText(label, "label");
parameterLabels = List.copyOf(Objects.requireNonNull(parameterLabels, "parameterLabels"));
returnLabel = requireText(returnLabel, "returnLabel");
}
private static String requireText(
final String value,
final String field) {
final String candidate = Objects.requireNonNull(value, field).trim();
if (candidate.isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return candidate;
}
}

View File

@ -0,0 +1,22 @@
package p.studio.compiler.pbs.semantics;
import java.util.List;
import java.util.Objects;
public record PbsEditorialSignatureHelp(
List<PbsEditorialSignature> signatures,
int activeSignature,
int activeParameter) {
public PbsEditorialSignatureHelp {
signatures = List.copyOf(Objects.requireNonNull(signatures, "signatures"));
if (signatures.isEmpty()) {
throw new IllegalArgumentException("signatures must not be empty");
}
if (activeSignature < 0 || activeSignature >= signatures.size()) {
throw new IllegalArgumentException("activeSignature must reference one of the provided signatures");
}
if (activeParameter < 0) {
throw new IllegalArgumentException("activeParameter must be >= 0");
}
}
}

View File

@ -0,0 +1,21 @@
package p.studio.compiler.pbs.semantics;
public enum PbsEditorialSymbolKind {
KEYWORD,
LOCAL,
PARAMETER,
FIELD,
FUNCTION,
METHOD,
CONSTRUCTOR,
STRUCT,
BUILTIN_TYPE,
SERVICE,
HOST,
CONTRACT,
CALLBACK,
ENUM,
ERROR,
GLOBAL,
CONST
}

View File

@ -584,5 +584,9 @@ final class PbsFlowSemanticSupport {
final var symbol = names.get(name);
return symbol != null && symbol.mutable();
}
Map<String, LocalSymbol> entries() {
return Map.copyOf(names);
}
}
}

View File

@ -133,12 +133,14 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
assembly.parsedSourceFiles(),
assembly.moduleTable());
final var flowSemanticsValidator = new PbsFlowSemanticsValidator();
final Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile = new LinkedHashMap<>();
final Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile = new LinkedHashMap<>();
final Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile = new LinkedHashMap<>();
for (final var entry : importedSemanticContexts.entrySet()) {
supplementalTopDeclsByFile.put(entry.getKey(), entry.getValue().supplementalTopDecls());
}
for (final var parsedSourceFile : assembly.parsedSourceFiles()) {
astByFile.put(parsedSourceFile.fileId(), parsedSourceFile.ast());
final var importedSemanticContext = importedSemanticContexts.getOrDefault(
parsedSourceFile.fileId(),
PbsImportedSemanticContext.empty());
@ -152,11 +154,13 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
}
}
return new PbsSemanticReadSurface(
Map.copyOf(astByFile),
Map.copyOf(supplementalTopDeclsByFile),
Map.copyOf(inlineHintsByFile));
}
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) {
}

View File

@ -0,0 +1,215 @@
package p.studio.compiler.pbs.semantics;
import org.junit.jupiter.api.Test;
import p.studio.compiler.pbs.ast.PbsAst;
import p.studio.compiler.pbs.lexer.PbsLexer;
import p.studio.compiler.pbs.parser.PbsParser;
import p.studio.compiler.source.diagnostics.DiagnosticSink;
import p.studio.compiler.source.identifiers.FileId;
import p.studio.utilities.structures.ReadOnlyList;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class PbsEditorialSupportServiceTest {
private final PbsEditorialSupportService service = new PbsEditorialSupportService();
@Test
void shouldResolveGeneralCompletionAndHoverAcrossLocalAndImportedSymbols() {
final var source = """
import { Log } from @sdk:log;
fn helper(value: int) -> int {
let total = value;
Log.info("hello");
return total;
}
""";
final var ast = parseOrdinary(source);
final var supplementalTopDecls = supplementalTopDecls("""
declare service Log {
fn info(message: str) -> void {
return;
}
}
""");
final var completions = service.completion(
source,
ast,
supplementalTopDecls,
source.indexOf("return total"));
assertTrue(completions.stream().anyMatch(candidate ->
candidate.label().equals("fn") && candidate.kind() == PbsEditorialSymbolKind.KEYWORD));
assertTrue(completions.stream().anyMatch(candidate ->
candidate.label().equals("value") && candidate.kind() == PbsEditorialSymbolKind.PARAMETER));
assertTrue(completions.stream().anyMatch(candidate ->
candidate.label().equals("total") && candidate.kind() == PbsEditorialSymbolKind.LOCAL));
assertTrue(completions.stream().anyMatch(candidate ->
candidate.label().equals("Log") && candidate.kind() == PbsEditorialSymbolKind.SERVICE));
assertTrue(completions.stream().anyMatch(candidate ->
candidate.label().equals("helper") && candidate.kind() == PbsEditorialSymbolKind.FUNCTION));
final var parameterHover = requireHover(source, ast, supplementalTopDecls, "value;");
assertEquals(PbsEditorialSymbolKind.PARAMETER, parameterHover.kind());
assertEquals("int", parameterHover.detail());
final var importedHover = requireHover(source, ast, supplementalTopDecls, "Log.info");
assertEquals(PbsEditorialSymbolKind.SERVICE, importedHover.kind());
assertEquals("@sdk:log", importedHover.origin());
}
@Test
void shouldResolveMemberCompletionAndHoverForImportedHostAndBuiltinTypes() {
final var source = """
import { Input } from @sdk:input;
fn main() -> void {
let touch: InputTouch = Input.touch();
Input.touch();
touch.x_axis();
}
""";
final var ast = parseOrdinary(source);
final var supplementalTopDecls = supplementalTopDecls("""
declare builtin type InputTouch(
pub x: int
) {
fn x_axis() -> int;
}
declare host Input {
fn touch() -> InputTouch;
fn pad() -> InputPad;
}
declare builtin type InputPad(
pub id: int
) {
fn strength() -> int;
}
""");
final var hostMembers = service.completion(
source,
ast,
supplementalTopDecls,
source.indexOf("touch();"));
assertTrue(hostMembers.stream().anyMatch(candidate ->
candidate.label().equals("touch") && candidate.kind() == PbsEditorialSymbolKind.METHOD));
assertTrue(hostMembers.stream().anyMatch(candidate ->
candidate.label().equals("pad") && candidate.kind() == PbsEditorialSymbolKind.METHOD));
final var builtinMembers = service.completion(
source,
ast,
supplementalTopDecls,
source.indexOf("x_axis();"));
assertTrue(builtinMembers.stream().anyMatch(candidate ->
candidate.label().equals("x") && candidate.kind() == PbsEditorialSymbolKind.FIELD));
assertTrue(builtinMembers.stream().anyMatch(candidate ->
candidate.label().equals("x_axis") && candidate.kind() == PbsEditorialSymbolKind.METHOD));
final var builtinTypeHover = requireHover(source, ast, supplementalTopDecls, "InputTouch");
assertEquals(PbsEditorialSymbolKind.BUILTIN_TYPE, builtinTypeHover.kind());
final var hostMethodHover = requireHover(source, ast, supplementalTopDecls, "touch();");
assertEquals(PbsEditorialSymbolKind.METHOD, hostMethodHover.kind());
assertEquals("Input", hostMethodHover.origin());
}
@Test
void shouldResolveSignatureHelpForFunctionMethodAndConstructorCalls() {
final var source = """
declare struct Vec() {
fn blend(dx: int, dy: int) -> int { return dx; }
}
fn mix(left: int, right: int) -> int { return left; }
fn main(vec: Vec) -> void {
mix(1, 2);
vec.blend(1, 2);
new Vec();
}
""";
final var ast = parseOrdinary(source);
final var functionHelp = service.signatureHelp(
source,
ast,
ReadOnlyList.empty(),
source.indexOf("2);"));
assertTrue(functionHelp.isPresent());
assertEquals("mix(left: int, right: int) -> int", functionHelp.orElseThrow().signatures().getFirst().label());
assertEquals(1, functionHelp.orElseThrow().activeParameter());
final var methodHelp = service.signatureHelp(
source,
ast,
ReadOnlyList.empty(),
source.lastIndexOf("2);"));
assertTrue(methodHelp.isPresent());
assertEquals("blend(arg0: int, arg1: int) -> int", methodHelp.orElseThrow().signatures().getFirst().label());
assertEquals(1, methodHelp.orElseThrow().activeParameter());
final var constructorHelp = service.signatureHelp(
source,
ast,
ReadOnlyList.empty(),
source.indexOf("Vec();") + 4);
assertTrue(constructorHelp.isPresent());
assertEquals("Vec()", constructorHelp.orElseThrow().signatures().getFirst().label());
final var constructorHover = requireHover(source, ast, ReadOnlyList.empty(), "Vec();");
assertEquals(PbsEditorialSymbolKind.CONSTRUCTOR, constructorHover.kind());
assertEquals("Vec()", constructorHover.signatures().getFirst().label());
}
private PbsEditorialResolvedSymbol requireHover(
final String source,
final PbsAst.File ast,
final ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls,
final String needle) {
final int offset = source.indexOf(needle);
assertTrue(offset >= 0, "Expected to find needle: " + needle);
return service.hover(source, ast, supplementalTopDecls, offset)
.orElseThrow();
}
private ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls(final String... interfaceModuleSources) {
final ArrayList<PbsAst.TopDecl> topDecls = new ArrayList<>();
int fileNumber = 100;
for (final var source : interfaceModuleSources) {
topDecls.addAll(parseInterface(source, new FileId(fileNumber)).topDecls().asList());
fileNumber += 1;
}
return ReadOnlyList.wrap(topDecls);
}
private PbsAst.File parseOrdinary(final String source) {
return parse(source, new FileId(0), PbsParser.ParseMode.ORDINARY);
}
private PbsAst.File parseInterface(
final String source,
final FileId fileId) {
return parse(source, fileId, PbsParser.ParseMode.INTERFACE_MODULE);
}
private PbsAst.File parse(
final String source,
final FileId fileId,
final PbsParser.ParseMode parseMode) {
final var diagnostics = DiagnosticSink.empty();
final var tokens = PbsLexer.lex(source, fileId, diagnostics);
final var ast = PbsParser.parse(tokens, fileId, diagnostics, parseMode);
assertFalse(diagnostics.hasErrors(), () -> "Unexpected parse errors: " + diagnostics.stream().toList());
return ast;
}
}