diff --git a/tools/tinyexpression-p4-lsp-vscode/README.md b/tools/tinyexpression-p4-lsp-vscode/README.md
index a1a61fc7..b3cdee25 100644
--- a/tools/tinyexpression-p4-lsp-vscode/README.md
+++ b/tools/tinyexpression-p4-lsp-vscode/README.md
@@ -127,6 +127,11 @@ hand-written adapter only supplies language-specific runtime binding through the
`runtimeVariables(...)` hook. Debug Console evaluation delegates to TinyExpression itself; it
does not contain a second arithmetic parser.
+The module's vocabulary conformance test extracts word literals from the UBNF and compares them
+with the static LSP completion vocabulary. An intentional omission must carry a reason in the
+test allow-list. The extractor is kept independent of LSP internals so the same check can later
+cover TextMate grammar scopes, DAP value vocabularies, and files bundled in the VSIX.
+
AST stepping is currently structural: the formula result and parity snapshot are evaluated with
the launch variables, while F10 changes the selected AST node. Per-node mutable runtime state and
reverse/time-travel debugging are not provided.
diff --git a/tools/tinyexpression-p4-lsp-vscode/pom.xml b/tools/tinyexpression-p4-lsp-vscode/pom.xml
index de6845e9..48b2fb18 100644
--- a/tools/tinyexpression-p4-lsp-vscode/pom.xml
+++ b/tools/tinyexpression-p4-lsp-vscode/pom.xml
@@ -210,7 +210,7 @@
org.apache.maven.plugins
maven-surefire-plugin
- org.unlaxer.tinyexpression.lsp.p4.TinyExpressionP4LanguageServerExtTest
+ org.unlaxer.tinyexpression.lsp.p4.TinyExpressionP4LanguageServerExtTest,org.unlaxer.tinyexpression.lsp.p4.TinyExpressionP4VocabularyConformanceTest
true
diff --git a/tools/tinyexpression-p4-lsp-vscode/server-dist/tinyexpression-p4-lsp-server.jar b/tools/tinyexpression-p4-lsp-vscode/server-dist/tinyexpression-p4-lsp-server.jar
index ba9bb6e4..48e4366e 100644
Binary files a/tools/tinyexpression-p4-lsp-vscode/server-dist/tinyexpression-p4-lsp-server.jar and b/tools/tinyexpression-p4-lsp-vscode/server-dist/tinyexpression-p4-lsp-server.jar differ
diff --git a/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExt.java b/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExt.java
index a9d2064e..1895d593 100644
--- a/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExt.java
+++ b/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExt.java
@@ -141,7 +141,7 @@ public class TinyExpressionP4LanguageServerExt extends TinyExpressionP4LanguageS
"var", "variable", "as",
"number", "string", "boolean", "object", "float",
"set", "not", "exists", "description", "call",
- "import", "external", "returning",
+ "import", "external", "returning", "internal",
"true", "false");
// ── Operator set ──
@@ -157,10 +157,15 @@ public class TinyExpressionP4LanguageServerExt extends TinyExpressionP4LanguageS
"if", "else", "match", "default",
"var", "variable", "as",
"number", "string", "boolean", "object", "float",
- "set", "not", "exists", "call",
- "import", "external", "returning",
+ "set", "not", "exists", "description", "call",
+ "import", "external", "returning", "internal",
"true", "false");
+ /** Closed grammar values that are useful as expression completions. */
+ private static final List COMPLETION_VALUES = List.of(
+ "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY",
+ "FRIDAY", "SATURDAY", "SUNDAY");
+
/**
* Snippet completions for callable functions. Accepting one of these inserts
* the function name with balanced parens and a tab-stop on the first argument
@@ -180,7 +185,7 @@ public class TinyExpressionP4LanguageServerExt extends TinyExpressionP4LanguageS
FUNCTION_SNIPPETS.put(f, f + "($1)$0");
}
// Math / string — two arguments
- for (String f : List.of("pow", "indexOf", "startsWith", "endsWith", "contains")) {
+ for (String f : List.of("pow", "startsWith", "endsWith", "contains")) {
FUNCTION_SNIPPETS.put(f, f + "($1, $2)$0");
}
// Variadic — surface a 2-arg starter
@@ -218,6 +223,19 @@ public class TinyExpressionP4LanguageServerExt extends TinyExpressionP4LanguageS
"external returning as ${1:number} ${2:name}($3)$0");
}
+ /**
+ * Package-private conformance seam. Tests compare this vocabulary with the
+ * UBNF source so a grammar addition cannot silently disappear from LSP
+ * completion.
+ */
+ static Set staticCompletionVocabulary() {
+ Set vocabulary = new LinkedHashSet<>(COMPLETION_KEYWORDS);
+ vocabulary.addAll(FUNCTION_SNIPPETS.keySet());
+ vocabulary.addAll(BLOCK_SNIPPETS.keySet());
+ vocabulary.addAll(COMPLETION_VALUES);
+ return Collections.unmodifiableSet(vocabulary);
+ }
+
/** Pattern for extracting $variable references from document text. */
private static final Pattern VARIABLE_PATTERN =
Pattern.compile("\\$([a-zA-Z_][a-zA-Z0-9_]*)");
@@ -1514,6 +1532,15 @@ public CompletableFuture, CompletionList>> completio
}
}
+ // 1a. Closed grammar values (currently DayOfWeek).
+ for (String value : COMPLETION_VALUES) {
+ if (value.startsWith(prefix)) {
+ CompletionItem item = new CompletionItem(value);
+ item.setKind(CompletionItemKind.EnumMember);
+ items.add(item);
+ }
+ }
+
// 1b. Function snippets — paren-balanced completions (issue #11 §3)
for (Map.Entry e : FUNCTION_SNIPPETS.entrySet()) {
String fn = e.getKey();
diff --git a/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExtTest.java b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExtTest.java
index 8e576273..21f07359 100644
--- a/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExtTest.java
+++ b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4LanguageServerExtTest.java
@@ -241,6 +241,31 @@ public void testCompletionFunctionSnippet() throws Exception {
assertEquals("sin($1)$0", sin.getInsertText());
}
+ @Test
+ public void testCompletionIncludesGrammarOnlyValuesAndInternalKeyword() throws Exception {
+ String content = "";
+ server.parseAndEnrich(TEST_URI, content, 0, content);
+
+ CompletionParams params = new CompletionParams();
+ params.setTextDocument(new TextDocumentIdentifier(TEST_URI));
+ params.setPosition(new Position(0, 0));
+
+ List items = service.completion(params).get().getLeft();
+ CompletionItem monday = items.stream()
+ .filter(i -> "MONDAY".equals(i.getLabel()))
+ .findFirst()
+ .orElse(null);
+
+ assertNotNull("DayOfWeek grammar values should be suggested", monday);
+ assertEquals(CompletionItemKind.EnumMember, monday.getKind());
+ assertTrue("internal invocation keyword should be suggested",
+ items.stream().anyMatch(i -> "internal".equals(i.getLabel())));
+ assertTrue("description declaration keyword should be suggested",
+ items.stream().anyMatch(i -> "description".equals(i.getLabel())));
+ assertFalse("unsupported indexOf must not be suggested",
+ items.stream().anyMatch(i -> "indexOf".equals(i.getLabel())));
+ }
+
/**
* Block-keyword snippet completion — semicolon completion 由来。issue #11
* §3 セミコロン補完を declaration テンプレート経由で復元する。"var" を
diff --git a/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4VocabularyConformanceTest.java b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4VocabularyConformanceTest.java
new file mode 100644
index 00000000..ebe1d28c
--- /dev/null
+++ b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/TinyExpressionP4VocabularyConformanceTest.java
@@ -0,0 +1,72 @@
+package org.unlaxer.tinyexpression.lsp.p4;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+
+public class TinyExpressionP4VocabularyConformanceTest {
+ /**
+ * Compatibility spellings are accepted by the grammar, but completion
+ * deliberately teaches the canonical lower-case type spelling.
+ */
+ private static final Map INTENTIONALLY_OMITTED = Map.of(
+ "Number", "compatibility alias; suggest canonical number",
+ "Float", "compatibility alias; suggest canonical float",
+ "String", "compatibility alias; suggest canonical string",
+ "Boolean", "compatibility alias; suggest canonical boolean",
+ "Object", "compatibility alias; suggest canonical object");
+
+ @Test
+ public void everyGrammarWordIsCompletedOrExplicitlyOmitted() throws IOException {
+ String grammar = Files.readString(grammarPath());
+ Set completions = TinyExpressionP4LanguageServerExt.staticCompletionVocabulary();
+ Set missing = UbnfVocabulary.missingCompletions(
+ grammar, completions, INTENTIONALLY_OMITTED.keySet());
+
+ assertTrue("UBNF words missing from LSP completion classification: " + missing,
+ missing.isEmpty());
+
+ Set grammarWords = UbnfVocabulary.wordLiterals(grammar);
+ Set stale = new java.util.LinkedHashSet<>(completions);
+ stale.removeAll(grammarWords);
+ assertTrue("LSP static completions absent from UBNF: " + stale, stale.isEmpty());
+ }
+
+ @Test
+ public void syntheticGrammarAdditionIsReportedAsMissing() {
+ String fixture = "grammar Fixture { Root ::= 'existing' | 'futureKeyword' ; }";
+ Set missing = UbnfVocabulary.missingCompletions(
+ fixture, Set.of("existing"), Set.of());
+
+ assertEquals(Set.of("futureKeyword"), missing);
+ }
+
+ @Test
+ public void intentionalDifferencesAlwaysCarryAReason() {
+ Map missingReasons = new LinkedHashMap<>();
+ INTENTIONALLY_OMITTED.forEach((word, reason) -> {
+ if (reason == null || reason.isBlank()) {
+ missingReasons.put(word, reason);
+ }
+ });
+ assertTrue("Every allow-list entry needs a reason: " + missingReasons,
+ missingReasons.isEmpty());
+ }
+
+ private static Path grammarPath() {
+ Path modulePath = Path.of(System.getProperty("basedir", "."),
+ "grammar", "tinyexpression-p4.ubnf");
+ if (Files.isRegularFile(modulePath)) {
+ return modulePath;
+ }
+ return Path.of("tools", "tinyexpression-p4-lsp-vscode", "grammar",
+ "tinyexpression-p4.ubnf");
+ }
+}
diff --git a/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/UbnfVocabulary.java b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/UbnfVocabulary.java
new file mode 100644
index 00000000..0a75dca1
--- /dev/null
+++ b/tools/tinyexpression-p4-lsp-vscode/src/test/java/org/unlaxer/tinyexpression/lsp/p4/UbnfVocabulary.java
@@ -0,0 +1,97 @@
+package org.unlaxer.tinyexpression.lsp.p4;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Lightweight UBNF literal extraction used by tooling conformance tests. */
+final class UbnfVocabulary {
+ private static final Pattern QUOTED_LITERAL =
+ Pattern.compile("'((?:\\\\.|[^'\\\\])*)'");
+ private static final Pattern WORD = Pattern.compile("[A-Za-z][A-Za-z0-9]*");
+
+ private UbnfVocabulary() {}
+
+ static Set wordLiterals(String ubnf) {
+ Set words = new LinkedHashSet<>();
+ boolean inRule = false;
+
+ for (String rawLine : ubnf.split("\\R", -1)) {
+ String line = stripLineComment(rawLine);
+ if (!inRule && line.contains("::=")) {
+ inRule = true;
+ }
+ if (!inRule) {
+ continue;
+ }
+
+ Matcher matcher = QUOTED_LITERAL.matcher(line);
+ while (matcher.find()) {
+ String literal = matcher.group(1).replace("\\'", "'");
+ if (WORD.matcher(literal).matches()) {
+ words.add(literal);
+ }
+ }
+
+ if (hasUnquotedSemicolon(line)) {
+ inRule = false;
+ }
+ }
+ return words;
+ }
+
+ static Set missingCompletions(
+ String ubnf, Set completions, Set intentionallyOmitted) {
+ Set missing = wordLiterals(ubnf);
+ missing.removeAll(completions);
+ missing.removeAll(intentionallyOmitted);
+ return missing;
+ }
+
+ private static String stripLineComment(String line) {
+ boolean quoted = false;
+ boolean escaped = false;
+ for (int i = 0; i + 1 < line.length(); i++) {
+ char current = line.charAt(i);
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (current == '\\' && quoted) {
+ escaped = true;
+ continue;
+ }
+ if (current == '\'') {
+ quoted = !quoted;
+ continue;
+ }
+ if (!quoted && current == '/' && line.charAt(i + 1) == '/') {
+ return line.substring(0, i);
+ }
+ }
+ return line;
+ }
+
+ private static boolean hasUnquotedSemicolon(String line) {
+ boolean quoted = false;
+ boolean escaped = false;
+ for (int i = 0; i < line.length(); i++) {
+ char current = line.charAt(i);
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (current == '\\' && quoted) {
+ escaped = true;
+ continue;
+ }
+ if (current == '\'') {
+ quoted = !quoted;
+ } else if (!quoted && current == ';') {
+ return true;
+ }
+ }
+ return false;
+ }
+}