diff --git a/.github/workflows/embedded-languages.yml b/.github/workflows/embedded-languages.yml new file mode 100644 index 00000000..f46ee2dc --- /dev/null +++ b/.github/workflows/embedded-languages.yml @@ -0,0 +1,41 @@ +name: Embedded language consumer +on: + pull_request: + push: + branches: [master] +jobs: + diagnostics: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21.0.9' + - uses: actions/checkout@v6 + with: + repository: opaopa6969/unlaxer-parser + ref: a3d1b131f1509361e94924e64c0f2cf65da9fb5a + path: .integration/unlaxer-parser + - name: Install source-map and diagnostic APIs + working-directory: .integration/unlaxer-parser + run: mvn -B -pl unlaxer-common,unlaxer-dsl -am install -DskipTests -Dgpg.skip=true + - name: Install production Tiny with current default dependencies + run: mvn -B install -DskipTests -Dtinyexpression.skipRailroad=true -Dgpg.skip=true + - name: Default LSP and DAP compatibility + working-directory: tools/tinyexpression-p4-lsp-vscode + run: mvn -B clean test -Dtest=TinyExpressionP4LanguageServerExtTest,TinyExpressionP4DebugAdapterExtTest + - name: Real javac through existing opt-in LSP consumer + working-directory: tools/tinyexpression-p4-lsp-vscode + run: | + mvn -B clean package -Pembedded-languages -Dgpg.skip=true -Dtest=ProductionEmbeddedDiagnosticsTest,TinyExpressionP4LanguageServerExtTest,TinyExpressionP4DebugAdapterExtTest + python3 scripts/check-embedded-lsp.py + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Extension setting and TypeScript checks + working-directory: tools/tinyexpression-p4-lsp-vscode + run: | + npm ci --ignore-scripts + npm run compile + npm test diff --git a/README.en.md b/README.en.md index 13c82609..b6e4b954 100644 --- a/README.en.md +++ b/README.en.md @@ -299,6 +299,8 @@ Full specification: [docs/language-guide.md](docs/language-guide.md) ## LSP / DAP +The existing **Java** LSP consumer offers [opt-in embedded Java diagnostics and EOF completion](tools/tinyexpression-p4-lsp-vscode/EMBEDDED-LANGUAGES.md). Its current Tiny grammar adaptation and the fixed-revision Java/Rust bridge fixtures have separate validation scopes; they do not establish a Rust Tiny LSP consumer. + The [tinyexpression-p4-lsp-vscode](tools/tinyexpression-p4-lsp-vscode/README.md) VS Code extension provides: - Syntax highlighting and semantic tokens diff --git a/README.md b/README.md index 2aeb6a69..f55b630d 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,8 @@ VS Code 拡張 [tinyexpression-p4-lsp-vscode](tools/tinyexpression-p4-lsp-vscode - 宣言・import・method・`.tecatalog` を使う補完とホバー - DAP デバッグ(6 バックエンドのパリティ比較) +既存の **Java** LSP consumer へ埋め込み Java 診断・EOF 補完を接続する [opt-in 設定と検証範囲](tools/tinyexpression-p4-lsp-vscode/EMBEDDED-LANGUAGES.md) も提供します。現行 Tiny grammar への接続と、固定版の Java / Rust 共通 bridge fixture は別の検証対象です。Rust Tiny LSP consumer の対応を示すものではありません。 + DAP 0.2.33 は生成AST上の停止・ブレークポイント・実ランタイム評価に加え、 人向けの期待値付き構文診断と、LLM/editor向けの構造化診断dataに対応します。 `launch.json` の `variables` は `CalculationContext` に型付きで注入され、選択バックエンド、 diff --git a/tools/tinyexpression-p4-lsp-vscode/EMBEDDED-LANGUAGES.md b/tools/tinyexpression-p4-lsp-vscode/EMBEDDED-LANGUAGES.md new file mode 100644 index 00000000..4e309f4c --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/EMBEDDED-LANGUAGES.md @@ -0,0 +1,31 @@ +# Embedded Java diagnostics in the existing P4 server + +This is an explicit migration profile. The default dependencies follow the current root POM (unlaxer 3.3.0-SNAPSHOT and Tiny 2.1.0); the diagnostics feature remains disabled by default, and P4/ubnfc and DAP behavior are unchanged. The profile uses the new source-map/provider API from unlaxer-parser 3.3.0-SNAPSHOT (validated at a3d1b131) and JDK 21.0.9. It never evaluates a Tiny formula or Java body; the external javac adapter disables annotation processing. + +Build unlaxer-parser's `unlaxer-common` and `unlaxer-dsl` modules with `mvn -pl unlaxer-common,unlaxer-dsl -am install -DskipTests -Dgpg.skip=true`. Install this repository's root artifact using the current root dependencies with `mvn install -DskipTests -Dtinyexpression.skipRailroad=true -Dgpg.skip=true`. Then, from this directory: + +```sh +mvn clean package -Pembedded-languages -Dgpg.skip=true +``` + +Point `tinyExpressionP4Lsp.server.jarPath` at the resulting server jar, use JDK 21.0.9, and enable `tinyExpressionP4Lsp.embeddedLanguageDiagnostics`. Other LSP clients can set `initializationOptions.embeddedLanguageDiagnostics: true`. Enabling it with a server that lacks the profile fails initialization explicitly. Merely including the profile does not enable analysis. + +The existing `TinyExpressionP4LanguageServerExt` now sends untouched, versioned full documents to an optional diagnostics provider. The production FormulaInfo and P4 parsers construct all section regions; javac diagnostics from each Java body are mapped to original UTF-16 positions. Updating later sections does not reuse first-section cache results. Stale updates are rejected, save revalidates, and close clears diagnostics. Invalid or incomplete region extraction and unavailable providers emit `EMBEDDED_UNAVAILABLE`, not empty success. Inside an owned Java region, completion now uses the same production editor binding and returns source-mapped javac edits, including at unfinished host EOF. Outside Java, existing first-section Tiny completion, formatting and other methods retain their prior behavior. + +The bridge is a namespaced port of unlaxer-parser production editor bridge commit f2fc6faa (the strict bridge originated in PR #470), adapted to the current Tiny grammar revision 0d84f0dc. The shared Java/Rust bridge contract is independently tested in that repository at the fixed Tiny revision `f86ce8a5ab0ab7d23fdba2cd477187df8aa7607c`; this current-consumer adaptation has a separate validation scope. This repository's existing LSP consumer is Java; no Rust LSP consumer is claimed here. Foreign dependency diagnostics need a later consumer extension. Workspace ownership is rejected explicitly by this single-document adapter. + +The validation targets are distinct: + +| Tiny source revision | Hosts / consumer | Independent evidence | +| --- | --- | --- | +| `f86ce8a5ab0ab7d23fdba2cd477187df8aa7607c` | Strict Java / Rust production bridge in unlaxer-parser [#470](https://github.com/opaopa6969/unlaxer-parser/pull/470) | 16 shared inputs / 177 independent observations per host, including original code-point / UTF-16 positions and query edits. | +| `f86ce8a5ab0ab7d23fdba2cd477187df8aa7607c` | Editor Java / Rust production bridge in unlaxer-parser [#479](https://github.com/opaopa6969/unlaxer-parser/pull/479) | Nine shared inputs / 29 independent observations per host; edited unfinished inputs still fail strict parsing. These fixed-grammar fixtures do not certify current long-fence consumer parity. | +| `0d84f0dc5c0331c09f46350cceb21cb57ee25d79` | Existing **Java** Tiny P4 LSP consumer, [#252](https://github.com/opaopa6969/tinyexpression/pull/252) / [#254](https://github.com/opaopa6969/tinyexpression/pull/254) | The paired PRs add opt-in javac diagnostics and EOF completion with current retained-source long fences and original UTF-16 positions. No current Rust Tiny LSP consumer or current-long-fence Java/Rust protocol parity is implemented or verified by this profile. | + +Run `mvn test -Pembedded-languages -Dtest=ProductionEmbeddedDiagnosticsTest,TinyExpressionP4LanguageServerExtTest,TinyExpressionP4DebugAdapterExtTest` here. The integration test runs real javac on production fixture 69 (ten formulas, two Java bodies), checks both error positions, a second-section-only update, Unicode/CRLF, stale updates, save, close and unavailable extraction. Run the 40 existing LSP/DAP tests without the profile as a separate clean build to verify the default dependency path. Use an isolated Maven repository if other checkouts install Tiny artifacts with different generated AST versions. + +The explicit editor entry certifies leading CodeBlock prefixes using the real production parser. Recognition-only synthetic closing text never enters the snapshot or edits. Incomplete Java diagnostics and EOF completion stay inside the original body; valid siblings remain usable when another formula fails. The strict parser is unchanged and still reports unfinished formulas as FAILED. The packaged stdio smoke additionally verifies the Unicode/CRLF unfinished-body completion edit at line 3, characters 41–44. +The new completion method is optional on the dependency-neutral SPI. The default build does not load a provider or alter its Tiny completion behavior. Both diagnostics and completion use the same immutable full snapshot under the existing server's lifecycle lock, so an intervening update cannot relabel an old result with a new version. + + +Current Tiny grammar support includes the retained-source long CodeBlock form: exact-width standalone fences keep shorter triple backticks opaque inside Java text. The adapter uses the production LongCodeFence layout after the production parser certifies the block. FormulaInfo hash-comment masking never rewrites an opaque Java body. The current Tiny grammar revision is carried in FormulaInfo/Tiny region identities; the Java profile identity remains `lang/java@0.1.0`. diff --git a/tools/tinyexpression-p4-lsp-vscode/README.md b/tools/tinyexpression-p4-lsp-vscode/README.md index e0a0c669..5f85a7b9 100644 --- a/tools/tinyexpression-p4-lsp-vscode/README.md +++ b/tools/tinyexpression-p4-lsp-vscode/README.md @@ -19,6 +19,8 @@ for TinyExpression formulas using the P4 grammar (UBNF-generated, type-safe). - `_tinyP4AstNodePath` — breadth-first path through the AST - `parity.*` — 6-backend evaluation comparison (JAVA_CODE / AST_EVALUATOR / DSL_JAVA_CODE / P4_AST / P4_DSL) +Opt-in embedded Java diagnostics and EOF completion in the existing **Java** LSP consumer are described in [EMBEDDED-LANGUAGES.md](EMBEDDED-LANGUAGES.md). That document distinguishes the current Tiny grammar adaptation from the fixed-revision Java/Rust bridge fixtures; it does not claim a Rust Tiny LSP consumer. + ## Requirements - Java 21+ diff --git a/tools/tinyexpression-p4-lsp-vscode/package.json b/tools/tinyexpression-p4-lsp-vscode/package.json index 5991d9ca..64cadf59 100644 --- a/tools/tinyexpression-p4-lsp-vscode/package.json +++ b/tools/tinyexpression-p4-lsp-vscode/package.json @@ -296,6 +296,11 @@ "type": "object", "title": "TinyExpression P4 LSP", "properties": { + "tinyExpressionP4Lsp.embeddedLanguageDiagnostics": { + "type": "boolean", + "default": false, + "description": "Diagnose and complete embedded Java in every FormulaInfo section without executing user code. Requires the explicitly built embedded-languages server profile and JDK 21.0.9. Changing this setting restarts the server." + }, "tinyExpressionP4Lsp.server.javaPath": { "type": "string", "default": "java", diff --git a/tools/tinyexpression-p4-lsp-vscode/pom.xml b/tools/tinyexpression-p4-lsp-vscode/pom.xml index cb834ed5..252dea14 100644 --- a/tools/tinyexpression-p4-lsp-vscode/pom.xml +++ b/tools/tinyexpression-p4-lsp-vscode/pom.xml @@ -217,6 +217,23 @@ + + + embedded-languages + org.unlaxerunlaxer-common${unlaxer.version} + + src/embedded/resources + + org.codehaus.mojobuild-helper-maven-plugin + + embedded-sourcesgenerate-sourcesadd-source + src/embedded/java + embedded-testsgenerate-test-sourcesadd-test-source + src/embedded-test/java + + + + p4-smoke diff --git a/tools/tinyexpression-p4-lsp-vscode/scripts/check-embedded-lsp.py b/tools/tinyexpression-p4-lsp-vscode/scripts/check-embedded-lsp.py new file mode 100644 index 00000000..7ea60e0e --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/scripts/check-embedded-lsp.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Verify the packaged existing launcher, opt-in provider service, and actual javac diagnostics.""" +import json +import pathlib +import subprocess +import threading +import queue + +root = pathlib.Path(__file__).resolve().parents[1] +messages = queue.Queue() +with (root / 'target/embedded-stdio.log').open('wb') as errors: + process = subprocess.Popen(['java', '--enable-preview', '-jar', str(root / 'target/tinyexpression-p4-lsp-server.jar')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors) + def read(): + try: + while True: + headers = {} + while True: + line = process.stdout.readline() + if not line: + raise EOFError('server exited') + if line == b'\r\n': + break + key, value = line.decode('ascii').split(':', 1) + headers[key.lower()] = value.strip() + messages.put(json.loads(process.stdout.read(int(headers['content-length'])))) + except BaseException as error: + messages.put(error) + threading.Thread(target=read, daemon=True).start() + def send(method, params, request_id=None): + message = {'jsonrpc': '2.0', 'method': method, 'params': params} + if request_id is not None: + message['id'] = request_id + body = json.dumps(message, ensure_ascii=False).encode('utf-8') + process.stdin.write(f'Content-Length: {len(body)}\r\n\r\n'.encode('ascii') + body) + process.stdin.flush() + def receive(predicate): + while True: + message = messages.get(timeout=40) + if isinstance(message, BaseException): + raise message + if predicate(message): + return message + try: + send('initialize', {'capabilities': {}, 'initializationOptions': {'embeddedLanguageDiagnostics': True}}, 1) + initialize = receive(lambda value: value.get('id') == 1) + assert initialize['result']['capabilities']['experimental']['embeddedLanguageDiagnostics']['enabled'] + send('initialized', {}) + uri = 'file:///production.formulainfo' + send('textDocument/didOpen', {'textDocument': {'uri': uri, 'languageId': 'tinyexpressionP4', 'version': 1, 'text': (root / 'src/embedded-test/resources/type-error.txt').read_text()}}) + published = receive(lambda value: value.get('method') == 'textDocument/publishDiagnostics')['params'] + assert published['version'] == 1, published + diagnostics = [item for item in published['diagnostics'] if item.get('source') == 'tinyexpression-java'] + assert [(item['range']['start']['line'], item['range']['start']['character']) for item in diagnostics] == [(41, 9), (69, 9)], published + assert all(item['code'] == 'compiler.err.prob.found.req' and item['severity'] == 1 for item in diagnostics) + partial = (root / 'src/embedded-test/resources/partial-eof.txt').read_bytes().decode('utf-8') + send('textDocument/didChange', {'textDocument': {'uri': uri, 'version': 2}, 'contentChanges': [{'text': partial}]}) + assert receive(lambda value: value.get('method') == 'textDocument/publishDiagnostics')['params']['version'] == 2 + send('textDocument/completion', {'textDocument': {'uri': uri}, 'position': {'line': 3, 'character': 44}}, 3) + completions = receive(lambda value: value.get('id') == 3)['result'] + target = next(item for item in completions if item['label'] == 'target') + assert target['textEdit'] == {'range': {'start': {'line': 3, 'character': 41}, 'end': {'line': 3, 'character': 44}}, 'newText': 'target'}, target + send('textDocument/didClose', {'textDocument': {'uri': uri}}) + assert receive(lambda value: value.get('method') == 'textDocument/publishDiagnostics')['params']['diagnostics'] == [] + send('shutdown', {}, 2) + receive(lambda value: value.get('id') == 2) + send('exit', {}) + print('PASS: packaged Tiny P4 launcher -> real javac -> two original FormulaInfo diagnostic positions -> partial EOF completion -> close clear') + finally: + process.kill() + process.wait(timeout=5) diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/java/org/unlaxer/tinyexpression/lsp/p4/ProductionEmbeddedDiagnosticsTest.java b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/java/org/unlaxer/tinyexpression/lsp/p4/ProductionEmbeddedDiagnosticsTest.java new file mode 100644 index 00000000..1ad6653d --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/java/org/unlaxer/tinyexpression/lsp/p4/ProductionEmbeddedDiagnosticsTest.java @@ -0,0 +1,82 @@ +package org.unlaxer.tinyexpression.lsp.p4; + +import static org.junit.Assert.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; +import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.services.LanguageClient; + +public class ProductionEmbeddedDiagnosticsTest { + @Test public void existingConsumerPublishesBothProductionJavaSectionsAndTracksSnapshots() throws Exception { + var server = new TinyExpressionP4LanguageServerExt(); + var notifications = new ArrayList(); + server.connect(new LanguageClient() { + public void publishDiagnostics(PublishDiagnosticsParams params) { notifications.add(params); } + public void telemetryEvent(Object value) {} + public void showMessage(MessageParams value) {} + public CompletableFuture showMessageRequest(ShowMessageRequestParams value) { return CompletableFuture.completedFuture(null); } + public void logMessage(MessageParams value) {} + }); + var defaults = new com.google.gson.Gson().toJsonTree(server.initialize(new InitializeParams()).join().getCapabilities().getExperimental()).getAsJsonObject(); + assertFalse(defaults.getAsJsonObject("embeddedLanguageDiagnostics").get("enabled").getAsBoolean()); + var initialize = new InitializeParams();initialize.setInitializationOptions(Map.of("embeddedLanguageDiagnostics", true)); + var caps = server.initialize(initialize).join().getCapabilities(); + assertNotNull(caps.getExperimental()); + var service = server.getTextDocumentService();String uri="file:///production.formulainfo"; + Path fixtures=Path.of("src/embedded-test/resources"); + String error=Files.readString(fixtures.resolve("type-error.txt")); + service.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri,"tinyexpression",1,error))); + var published=last(notifications);assertEquals(Integer.valueOf(1),published.getVersion()); + var java=javaDiagnostics(published);assertEquals(published.toString(),2,java.size()); + assertEquals(new Range(new Position(41,9),new Position(41,10)),java.get(0).getRange()); + assertEquals(new Range(new Position(69,9),new Position(69,10)),java.get(1).getRange()); + for(var diagnostic:java) { assertEquals("compiler.err.prob.found.req",diagnostic.getCode().getLeft());assertEquals(DiagnosticSeverity.Error,diagnostic.getSeverity()); } + int count=notifications.size(); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,1),List.of(new TextDocumentContentChangeEvent("stale")))); + assertEquals(count,notifications.size()); + // The first formula remains byte-for-byte identical; the changed second section must be reanalyzed. + String secondFixed = error.substring(0,error.lastIndexOf("return 1;")) + error.substring(error.lastIndexOf("return 1;")).replace("return 1;", "return true;"); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,2),List.of(new TextDocumentContentChangeEvent(secondFixed)))); + assertEquals(1,javaDiagnostics(last(notifications)).size()); + assertEquals(41,javaDiagnostics(last(notifications)).get(0).getRange().getStart().getLine()); + String unicode=Files.readString(fixtures.resolve("unicode-crlf-error.txt")); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,3),List.of(new TextDocumentContentChangeEvent(unicode)))); + published=last(notifications);assertEquals(Integer.valueOf(3),published.getVersion());java=javaDiagnostics(published); + assertEquals(published.toString(),1,java.size());assertEquals(new Range(new Position(42,9),new Position(42,10)),java.get(0).getRange()); + service.didSave(new DidSaveTextDocumentParams(new TextDocumentIdentifier(uri)));assertEquals(Integer.valueOf(3),last(notifications).getVersion()); + String valid=Files.readString(Path.of("../../src/test/resources/formulaInfo-test/69/formulaInfo.txt")); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,4),List.of(new TextDocumentContentChangeEvent(valid)))); + assertTrue(last(notifications).toString(),javaDiagnostics(last(notifications)).isEmpty()); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,5),List.of(new TextDocumentContentChangeEvent("formula:\ninvalid\n")))); + assertTrue(last(notifications).getDiagnostics().stream().anyMatch(d->d.getCode()!=null && "EMBEDDED_UNAVAILABLE".equals(d.getCode().getLeft()))); + String partial=Files.readString(fixtures.resolve("partial-eof.txt")); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,6),List.of(new TextDocumentContentChangeEvent(partial)))); + assertFalse(last(notifications).toString(),javaDiagnostics(last(notifications)).isEmpty()); + var completion=service.completion(new CompletionParams(new TextDocumentIdentifier(uri),new Position(3,44))).join().getLeft(); + var target=completion.stream().filter(item->item.getLabel().equals("target")).findFirst().orElseThrow(); + assertEquals(new Range(new Position(3,41),new Position(3,44)),target.getTextEdit().getLeft().getRange()); + assertEquals("target",target.getTextEdit().getLeft().getNewText()); + var snapshot=new org.unlaxer.source.DocumentSnapshot(uri,7,partial+"get"); + assertTrue(org.unlaxer.tinyexpression.lsp.p4.embedded.TinyProductionBridge.parse(snapshot).regions().stream().anyMatch(region->region.parseState()==org.unlaxer.source.LanguageRegions.State.FAILED)); + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,7),List.of(new TextDocumentContentChangeEvent(snapshot.text())))); + assertEquals(Integer.valueOf(7),last(notifications).getVersion()); + String longSource="formula:\n````java:Main\nclass Main { String marker=\"```\"; int f(){return \"bad\";}}\n````\n1\n---END_OF_PART---\n"; + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,100),List.of(new TextDocumentContentChangeEvent(longSource)))); + assertEquals(last(notifications).toString(),1,javaDiagnostics(last(notifications)).size()); + assertEquals("compiler.err.prob.found.req",javaDiagnostics(last(notifications)).get(0).getCode().getLeft()); + assertEquals(new Range(new Position(2,49),new Position(2,54)),javaDiagnostics(last(notifications)).get(0).getRange()); + String longPartial="formula:\n````java:Main\nclass Main { int target; int f(){ return tar"; + service.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri,101),List.of(new TextDocumentContentChangeEvent(longPartial)))); + var longItems=service.completion(new CompletionParams(new TextDocumentIdentifier(uri),new Position(2,44))).join().getLeft(); + assertEquals("target",longItems.stream().filter(item->item.getLabel().equals("target")).findFirst().orElseThrow().getTextEdit().getLeft().getNewText()); + var forged=new org.unlaxer.source.DocumentSnapshot(uri,200,"formula:\n```java:Main\n# ```\nclass Main{}\n```\n1\n---END_OF_PART---\n"); + var rejected=org.unlaxer.tinyexpression.lsp.p4.embedded.TinyProductionBridge.parse(forged); + assertTrue(rejected.javaFiles().isEmpty()); + assertTrue(rejected.regions().stream().anyMatch(region->region.parseState()==org.unlaxer.source.LanguageRegions.State.FAILED)); + service.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(uri)));assertTrue(last(notifications).getDiagnostics().isEmpty()); + } + private static PublishDiagnosticsParams last(List values) { return values.get(values.size()-1); } + private static List javaDiagnostics(PublishDiagnosticsParams value) { return value.getDiagnostics().stream().filter(d->"tinyexpression-java".equals(d.getSource())).toList(); } +} diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/partial-eof.txt b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/partial-eof.txt new file mode 100644 index 00000000..632c6c38 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/partial-eof.txt @@ -0,0 +1,4 @@ +formula: +# 😀日本語 +```java:Main +class Main { int target; int f(){ return tar \ No newline at end of file diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/type-error.txt b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/type-error.txt new file mode 100644 index 00000000..f1e33938 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/type-error.txt @@ -0,0 +1,200 @@ +tags:NORMAL +description:JavaCodeBlockで呼び出しをします +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +# +# calculatorNameが指定できるようになった。この名前はdependsOnで指定できる +# +calculatorName:callJavaCodeBlock + +# +# 非標準項目。ResultConsumerでここに指定された名前でCalculationContextの変数に結果を格納する +# +var:matchNumber + +# +# ここに他の式名(CalculatorName)を指定する事で指定した式を実行した後にこの式を実行する +# 複数指定する場合はカンマ区切りで指定する +# 実際はorder numberで実行順を管理していてdependsOnで指定されたCalculatorのorder numberを +# このCalculatorのorder number-1に設定するだけである。このorder numberでsortされて順番に実行される +# +dependsOn: + +# +# 式の戻り値を指定する +# string,boolean,byte,short,int,long,float,doubleを指定する +# +resultType:float + +# +# JavaCodeBlockを指定できる。 複数記述可能 +# java:の後にfull package class nameを指定する。この例ではdefault packageを指定している +# 同一のクラス名でロジックを変更したい場合classのunloadは出来ないのでpackage名にversionを入れてversion管理をする +# +formula: +```java:CheckDigits +//package sample.v1;//version1. if logic updates then update package. +import org.unlaxer.tinyexpression.CalculationContext; + +public class CheckDigits{ + public boolean check(CalculationContext calculationContext,String target){ + return 1; + } +} +``` +import CheckDigits#check as checkDigits; +var $input as string set if not exists 'not number' description='入力値'; +if(external returning as boolean checkDigits($input)){ + 1 +}else{ + 0 +} +---END_OF_PART--- +tags:NORMAL +description:JavaCodeBlockで呼び出しをします。packageを使用します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:callJavaCodeBlockWithPackage +var:matchAlphabet +dependsOn: +resultType:float +formula: +```java:sample.v1.CheckAlphabets +package sample.v1;//version1. if logic updates then update package. +import org.unlaxer.tinyexpression.CalculationContext; + +public class CheckAlphabets{ + public boolean check(CalculationContext calculationContext,String target){ + return 1; + } +} +``` +import sample.v1.CheckAlphabets#check as checkAlphabets; +var $inputName as string set if not exists '1234' description='入力値'; +if(external returning as boolean checkAlphabets($inputName)){ + 1 +}else{ + 0 +} +---END_OF_PART--- +tags:NORMAL +description:最終的な点数をcheckKindとして出力します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +# outputToでcheckKindやfieldやvarなどをしてするけどいらないかな? +calculatorName:setFloatToMintia +checkKind:Mintia + +# +# ここに他の式名(CalculatorName)を指定する事で指定した式を実行した後にこの式を実行する +# 実際はorder numberで実行順を管理していてdependsOnで指定されたCalculatorのorder numberを +# このCalculatorのorder number-1に設定するだけである。このorder numberでsortされて順番に実行される +# 複数指定する場合はカンマ区切りで指定する +# この場合setSqrt2ToVarを実行してからこの式が実行される +# +dependsOn:setSqrt2ToVar +resultType:float +formula: +match{ + $mintia == 2 -> $sqrt , + default -> 3.141592 +} +---END_OF_PART--- +tags:NORMAL +description:$sqrtにsqrt(2)を代入します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:setSqrt2ToVar +var:sqrt +dependsOn: +resultType:float +formula: +sqrt(2) +---END_OF_PART--- +tags:NORMAL +description:CheckResultのtheScoreに値をセットします +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:setTheScore +field:theScore +dependsOn: +resultType:float +formula: +6969 +---END_OF_PART--- +tags:NORMAL +description:$nameに文字列を代入します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 + +# +# 非標準項目。ResultConsumerでここに指定された名前でCheckResultのfieldに結果を格納する +# +field:theName + +# +# 非標準項目。ResultConsumerでここに指定された名前でCalculationContextの変数に結果を格納する +# +var:name +dependsOn: + +# +# この式ではstringを返す +# +resultType:String +formula: +"opaopa" +---END_OF_PART--- +tags:NORMAL +description:longで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcLong +var:ロング計算結果 +dependsOn: +resultType:Long +formula: +423372036854775807+423372036854775807 +---END_OF_PART--- +tags:NORMAL +description:doubleで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcDouble +var:ダブル計算結果 +dependsOn: +resultType:double +formula: +423372036854775807+423372036854775807 +---END_OF_PART--- +tags:NORMAL +description:booleanで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcBoolean +var:真偽値計算結果 +dependsOn: +resultType:boolean +formula: +(1==1)&true|('肉'!='魚') +---END_OF_PART--- +var:numberType指定真偽値計算結果 +dependsOn: +resultType:boolean + +# +# 数値の型を指定できる +# +numberType:long +formula: +(423372036854775807==423372036854775807)&true|('肉'!='魚') +---END_OF_PART--- \ No newline at end of file diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/unicode-crlf-error.txt b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/unicode-crlf-error.txt new file mode 100644 index 00000000..d197ee7c --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded-test/resources/unicode-crlf-error.txt @@ -0,0 +1,201 @@ +tags:NORMAL +description:😀日本語JavaCodeBlockで呼び出しをします +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +# +# calculatorNameが指定できるようになった。この名前はdependsOnで指定できる +# +calculatorName:callJavaCodeBlock + +# +# 非標準項目。ResultConsumerでここに指定された名前でCalculationContextの変数に結果を格納する +# +var:matchNumber + +# +# ここに他の式名(CalculatorName)を指定する事で指定した式を実行した後にこの式を実行する +# 複数指定する場合はカンマ区切りで指定する +# 実際はorder numberで実行順を管理していてdependsOnで指定されたCalculatorのorder numberを +# このCalculatorのorder number-1に設定するだけである。このorder numberでsortされて順番に実行される +# +dependsOn: + +# +# 式の戻り値を指定する +# string,boolean,byte,short,int,long,float,doubleを指定する +# +resultType:float + +# +# JavaCodeBlockを指定できる。 複数記述可能 +# java:の後にfull package class nameを指定する。この例ではdefault packageを指定している +# 同一のクラス名でロジックを変更したい場合classのunloadは出来ないのでpackage名にversionを入れてversion管理をする +# +formula: +```java:CheckDigits +//😀日本語 +//package sample.v1;//version1. if logic updates then update package. +import org.unlaxer.tinyexpression.CalculationContext; + +public class CheckDigits{ + public boolean check(CalculationContext calculationContext,String target){ + return 1; + } +} +``` +import CheckDigits#check as checkDigits; +var $input as string set if not exists 'not number' description='入力値'; +if(external returning as boolean checkDigits($input)){ + 1 +}else{ + 0 +} +---END_OF_PART--- +tags:NORMAL +description:JavaCodeBlockで呼び出しをします。packageを使用します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:callJavaCodeBlockWithPackage +var:matchAlphabet +dependsOn: +resultType:float +formula: +```java:sample.v1.CheckAlphabets +package sample.v1;//version1. if logic updates then update package. +import org.unlaxer.tinyexpression.CalculationContext; + +public class CheckAlphabets{ + public boolean check(CalculationContext calculationContext,String target){ + return target.matches("[a-zA-Z]+"); + } +} +``` +import sample.v1.CheckAlphabets#check as checkAlphabets; +var $inputName as string set if not exists '1234' description='入力値'; +if(external returning as boolean checkAlphabets($inputName)){ + 1 +}else{ + 0 +} +---END_OF_PART--- +tags:NORMAL +description:最終的な点数をcheckKindとして出力します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +# outputToでcheckKindやfieldやvarなどをしてするけどいらないかな? +calculatorName:setFloatToMintia +checkKind:Mintia + +# +# ここに他の式名(CalculatorName)を指定する事で指定した式を実行した後にこの式を実行する +# 実際はorder numberで実行順を管理していてdependsOnで指定されたCalculatorのorder numberを +# このCalculatorのorder number-1に設定するだけである。このorder numberでsortされて順番に実行される +# 複数指定する場合はカンマ区切りで指定する +# この場合setSqrt2ToVarを実行してからこの式が実行される +# +dependsOn:setSqrt2ToVar +resultType:float +formula: +match{ + $mintia == 2 -> $sqrt , + default -> 3.141592 +} +---END_OF_PART--- +tags:NORMAL +description:$sqrtにsqrt(2)を代入します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:setSqrt2ToVar +var:sqrt +dependsOn: +resultType:float +formula: +sqrt(2) +---END_OF_PART--- +tags:NORMAL +description:CheckResultのtheScoreに値をセットします +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:setTheScore +field:theScore +dependsOn: +resultType:float +formula: +6969 +---END_OF_PART--- +tags:NORMAL +description:$nameに文字列を代入します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 + +# +# 非標準項目。ResultConsumerでここに指定された名前でCheckResultのfieldに結果を格納する +# +field:theName + +# +# 非標準項目。ResultConsumerでここに指定された名前でCalculationContextの変数に結果を格納する +# +var:name +dependsOn: + +# +# この式ではstringを返す +# +resultType:String +formula: +"opaopa" +---END_OF_PART--- +tags:NORMAL +description:longで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcLong +var:ロング計算結果 +dependsOn: +resultType:Long +formula: +423372036854775807+423372036854775807 +---END_OF_PART--- +tags:NORMAL +description:doubleで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcDouble +var:ダブル計算結果 +dependsOn: +resultType:double +formula: +423372036854775807+423372036854775807 +---END_OF_PART--- +tags:NORMAL +description:booleanで計算します +periodStartInclusive:2018-05-01_00:00:00 +periodEndExclusive:2037-05-01_00:00:00 +siteId:69 +calculatorName:calcBoolean +var:真偽値計算結果 +dependsOn: +resultType:boolean +formula: +(1==1)&true|('肉'!='魚') +---END_OF_PART--- +var:numberType指定真偽値計算結果 +dependsOn: +resultType:boolean + +# +# 数値の型を指定できる +# +numberType:long +formula: +(423372036854775807==423372036854775807)&true|('肉'!='魚') +---END_OF_PART--- \ No newline at end of file diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/ProductionEmbeddedDiagnostics.java b/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/ProductionEmbeddedDiagnostics.java new file mode 100644 index 00000000..da3baca1 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/ProductionEmbeddedDiagnostics.java @@ -0,0 +1,92 @@ +package org.unlaxer.tinyexpression.lsp.p4.embedded; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; +import org.eclipse.lsp4j.*; +import org.unlaxer.source.*; +import org.unlaxer.source.LanguageRegions.Operation; +import org.unlaxer.tinyexpression.lsp.p4.EmbeddedLanguageDiagnostics; + +/** javac parse/analyze only (-proc:none); no class loading or user code evaluation. */ +public final class ProductionEmbeddedDiagnostics implements EmbeddedLanguageDiagnostics { + private final String classpath = System.getProperty("java.class.path"); + private final ProviderProcess provider = new ProviderProcess( + List.of(Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-cp", classpath, + "org.unlaxer.dsl.provider.JavacProvider"), + new ProviderProtocol.Identity("javac", "21.0.9"), Set.of(Operation.VALIDATE, Operation.COMPLETION), Duration.ofSeconds(30)); + + @Override public List analyze(String uri, int version, String fullContent) { + if (!fullContent.lines().anyMatch(line -> line.stripTrailing().equals("formula:"))) return List.of(); + var host = new DocumentSnapshot(uri, version, fullContent); + var binding = TinyProductionBridge.parseEditor(host); + var project = new LanguageQueries.Project("tiny-lsp", version, Map.of(uri, host), Map.of("classpath", classpath)); + var queries = binding.queries(project, provider); + var diagnostics = new ArrayList(); + for (var region : binding.regions()) if (region.parseState() == org.unlaxer.source.LanguageRegions.State.FAILED) + diagnostics.add(unavailable(host, region.body().start(), "Invalid production region: " + region.id())); + for (var result : queries.diagnosticsAll(host, project, Map.of())) { + if (!binding.javaFiles().containsKey(result.region())) continue; + if (result.state() != org.unlaxer.source.LanguageRegions.State.COMPLETE && result.state() != org.unlaxer.source.LanguageRegions.State.PARTIAL) { + diagnostics.add(unavailable(host, 0, result.region() + ": " + result.state())); + continue; + } + for (var item : result.diagnostics()) { + for (var mapping : item.locations()) { + var location = mapping.location(); + if (!location.snapshot().equals(host)) throw new IllegalArgumentException("foreign diagnostic requires workspace consumer"); + var start = host.lsp(location.span().start()); var end = host.lsp(location.span().end()); + var diagnostic = new Diagnostic(new Range(new Position(start.line(), start.character()), new Position(end.line(), end.character())), + item.message(), severity(item.severity()), "tinyexpression-java"); + diagnostic.setCode(item.code()); + diagnostic.setData(Map.of("region", result.region(), "version", Integer.toString(version), "exact", mapping.exact(), "severity", item.severity())); + diagnostics.add(diagnostic); + } + } + } + return diagnostics; + } + @Override public Optional> complete(String uri, int version, String fullContent, Position position) { + if (!fullContent.lines().anyMatch(line -> line.stripTrailing().equals("formula:"))) return Optional.empty(); + var host = new DocumentSnapshot(uri, version, fullContent); + int cursor=host.fromLsp(new DocumentSnapshot.Position(position.getLine(),position.getCharacter())); + var binding=TinyProductionBridge.parseEditor(host);var owner=binding.tree().at(cursor); + if (owner==null || !owner.language().equals(TinyProductionBridge.JAVA)) return Optional.empty(); + int end=host.utf16(cursor),start=end; + while(start>0 && Character.isJavaIdentifierPart(fullContent.codePointBefore(start))) start-=Character.charCount(fullContent.codePointBefore(start)); + var project=new LanguageQueries.Project("tiny-lsp",version,Map.of(uri,host),Map.of("classpath",classpath)); + var result=binding.queries(project,provider).query(host,project,cursor,Operation.COMPLETION,Map.of("prefix",fullContent.substring(start,end))); + if(result.state()!=org.unlaxer.source.LanguageRegions.State.COMPLETE && result.state()!=org.unlaxer.source.LanguageRegions.State.PARTIAL) + throw new IllegalArgumentException(result.state().name()); + var items=new ArrayList(); + for(var candidate:result.items()) { + var item=new CompletionItem(candidate.label());item.setDetail(candidate.detail()); + item.setData(Map.of("region",result.region(),"version",Integer.toString(version),"state",result.state().name())); + var edits=new ArrayList(); + for(var edit:candidate.edits()) edits.add(new TextEdit(range(host,edit.span()),edit.replacement())); + if(edits.isEmpty()) item.setInsertText(""); + else { + item.setTextEdit(org.eclipse.lsp4j.jsonrpc.messages.Either.forLeft(edits.get(0))); + if(edits.size()>1)item.setAdditionalTextEdits(edits.subList(1,edits.size())); + } + items.add(item); + } + return Optional.of(items); + } + private static Range range(DocumentSnapshot host,DocumentSnapshot.Span span) { + var start=host.lsp(span.start());var end=host.lsp(span.end()); + return new Range(new Position(start.line(),start.character()),new Position(end.line(),end.character())); + } + private static Diagnostic unavailable(DocumentSnapshot host,int point,String message) { + var diagnostic=new Diagnostic(range(host,new DocumentSnapshot.Span(point,point)),message,DiagnosticSeverity.Warning,"tinyexpression-embedded"); + diagnostic.setCode("EMBEDDED_UNAVAILABLE");return diagnostic; + } + private static DiagnosticSeverity severity(String value) { + return switch(value) { + case "ERROR" -> DiagnosticSeverity.Error; + case "WARNING", "MANDATORY_WARNING" -> DiagnosticSeverity.Warning; + case "HINT", "HELP", "SUGGESTION" -> DiagnosticSeverity.Hint; + default -> DiagnosticSeverity.Information; + }; + } +} diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/TinyProductionBridge.java b/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/TinyProductionBridge.java new file mode 100644 index 00000000..c20eb3c7 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded/java/org/unlaxer/tinyexpression/lsp/p4/embedded/TinyProductionBridge.java @@ -0,0 +1,200 @@ +package org.unlaxer.tinyexpression.lsp.p4.embedded; + +// Port of unlaxer-parser production bridge f2fc6faa with explicit editor recovery. +import java.util.*; +import org.unlaxer.*; +import org.unlaxer.context.ParseContext; +import org.unlaxer.tinyexpression.loader.*; +import org.unlaxer.tinyexpression.p4.ubnfc.generated.TinyExpressionP4Parser; +import org.unlaxer.tinyexpression.p4.ubnfc.generated.api.ParseOptions; +import org.unlaxer.source.*; +import org.unlaxer.source.DocumentSnapshot.Span; +import org.unlaxer.source.LanguageRegions.*; + +/** Explicit native integration: production TinyExpression parsers, no evaluation or compilation. */ +public final class TinyProductionBridge { + public static final Language FORMULA = new Language("formulainfo", "tinyexpression", "0d84f0dc5c0331c09f46350cceb21cb57ee25d79", "FormulaInfo", "Document"); + public static final Language TINY = new Language("tinyexpression", "tinyexpression", "0d84f0dc5c0331c09f46350cceb21cb57ee25d79", "TinyExpressionP4", "Formula"); + public static final Language JAVA = new Language("java", "lang/java", "0.1.0", "Java21", "CompilationUnit"); + public record Binding(DocumentSnapshot host, List regions, Map javaFiles, Set openEnds) { + public Binding { regions = List.copyOf(regions); javaFiles = Map.copyOf(javaFiles); openEnds = Set.copyOf(openEnds); } + public Binding(DocumentSnapshot host, List regions, Map javaFiles) { this(host,regions,javaFiles,Set.of()); } + public LanguageRegions tree() { return new LanguageRegions(host, regions, openEnds); } + public LanguageQueries queries(LanguageQueries.Project project, LanguageQueries.Provider javaProvider) { + return new LanguageQueries(tree(), project, Map.of(JAVA, new LanguageQueries.Provider() { + public Set capabilities() { return javaProvider.capabilities(); } + public LanguageQueries.Response query(LanguageQueries.Request request) { return javaProvider.query(javaRequest(request)); } + public LanguageQueries.DiagnosticResponse diagnostics(LanguageQueries.Request request) { return javaProvider.diagnostics(javaRequest(request)); } + })); + } + public LanguageQueries.Request javaRequest(LanguageQueries.Request request) { + if (!regions.contains(request.region()) || !JAVA.equals(request.region().language())) throw new IllegalArgumentException("foreign region"); + var parameters = new HashMap<>(request.parameters()); + String file = javaFiles.get(request.region().id()); + if (file == null || parameters.containsKey("fileName") && !file.equals(parameters.get("fileName"))) throw new IllegalArgumentException("foreign Java filename"); + parameters.put("fileName", file); + return new LanguageQueries.Request(request.region(), request.operation(), request.cursor(), request.project(), parameters); + } + } + public static Binding parse(DocumentSnapshot host) { return parse(host, false); } + /** Explicit editor recovery retains only CodeBlock prefixes certified by the production entry parser. */ + public static Binding parseEditor(DocumentSnapshot host) { return parse(host, true); } + private static Binding parse(DocumentSnapshot host, boolean editor) { + if (host.length() > 1_048_576) throw new IllegalArgumentException("production document limit"); + Parsed parsed; + try (var context = new ParseContext(StringSource.createRootSource(host.text()))) { + parsed = new FormulaInfoBlocksParser().parse(context); + } + if (!parsed.isSucceeded() || parsed.getConsumed() == null || !parsed.getConsumed().source.sourceAsString().equals(host.text())) + throw new IllegalArgumentException("invalid FormulaInfo document"); + var regions = new ArrayList(); var files = new LinkedHashMap(); var openEnds = new HashSet(); + regions.add(region(host, "root", null, FORMULA, new Span(0,host.length()), new Span(0,host.length()), State.COMPLETE)); + int index = 0; + for (var block : parsed.getRootToken().typed(FormulaInfoBlocksParser.class).getChildrenWithParserAsListTyped(FormulaInfoBlockParser.class)) { + Token formula = null; + for (Token token : FormulaInfoElementOrCommentParser.elements(block)) { + if (!(token.parser instanceof FormulaInfoElementParser)) continue; + var element = token.typed(FormulaInfoElementParser.class); + FormulaInfoElementParser.KeyValue pair; + try { pair = element.getParser().extract(element); } + catch (FormulaInfoParseException failure) { throw new IllegalArgumentException("invalid FormulaInfo value", failure); } + if (pair.getKey().equals("formula")) { + if (formula != null) throw new IllegalArgumentException("duplicate formula"); + formula = element.getChild(TokenPredicators.hasTag(FormulaInfoParser.Kind.value.tag())); + } + } + if (formula == null) continue; + if (++index > 256) throw new IllegalArgumentException("production section limit"); + int start = formula.source.offsetFromRoot().value(); + String raw = formula.source.sourceAsString(); + int skip = raw.startsWith("\r\n") ? 2 : raw.startsWith("\n") || raw.startsWith("\r") ? 1 : 0; + start += skip; raw = raw.substring(skip); + Span body = new Span(start, start + raw.codePointCount(0,raw.length())); + if (!host.slice(body).equals(raw)) throw new IllegalArgumentException("FormulaInfo source mismatch"); + String id = "formula/" + index; + String input = maskComments(raw); + var p4 = TinyExpressionP4Parser.parse(input, new ParseOptions(true,true,true,true,org.unlaxer.tinyexpression.p4.ubnfc.P4Scanners.ALL,512)); + regions.add(region(host,id,"root",TINY,body,body,p4.ok() ? State.COMPLETE : State.FAILED)); + if (!p4.ok()) { + if (editor) { + int parentIndex = regions.size()-1; + recoverPrefix(host, id, start, input, regions, files, openEnds); + if (regions.size() > parentIndex+1) regions.set(parentIndex, region(host,id,"root",TINY,body,body,State.PARTIAL)); + } + continue; + } + var tokens = p4.lexical().stream().filter(t -> t.token() != null && t.ruleId().equals("TinyExpressionP4::CodeBlock")).toList(); + for (int n=0,number=0;n=tokens.size())throw new IllegalArgumentException("P4 CodeBlock lexical contract changed"); + var close=tokens.get(n+2);n+=3; + String header=input.substring(at,input.offsetByCodePoints(0,open.span().end())).strip(); + if (!header.startsWith("```java:")) continue; + String file=header.substring(Math.max(8,header.lastIndexOf('.')+1))+".java"; + String child=id+"/java/"+(number+1); + Span full=new Span(start+open.span().start(),start+close.span().end()); + Span javaBody=new Span(start+open.span().end(),start+close.span().start()); + regions.add(region(host,child,id,JAVA,full,javaBody,State.COMPLETE)); files.put(child,file); + } + } + if (index==0) throw new IllegalArgumentException("no formula section"); + var result = new Binding(host,regions,files,openEnds); result.tree(); return result; + } + private static void recoverPrefix(DocumentSnapshot host, String parent, int start, String input, + List regions, Map files, Set openEnds) { + int from = 0, number = 0; + var options = new ParseOptions(false,false,true,true,org.unlaxer.tinyexpression.p4.ubnfc.P4Scanners.ALL,512); + while (from < input.length()) { + if (number >= 256) throw new IllegalArgumentException("production code block limit"); + String remaining = input.substring(from), recognized = remaining; + var parsed = TinyExpressionP4Parser.parseEntry("TinyExpressionP4","CodeBlock",recognized,options); + boolean partial = !parsed.ok(); + if (partial) { + var widths=new LinkedHashSet();widths.add(3); + for(String line:remaining.split("[\\r\\n]",-1)) { + int width=0;while(width=4)widths.add(width); + if(widths.size()>=256)break; + } + for(int width:widths) { + recognized = remaining + "\n"+"`".repeat(width)+"\n"; + parsed = TinyExpressionP4Parser.parseEntry("TinyExpressionP4","CodeBlock",recognized,options); + if(parsed.ok())break; + } + } + if (!parsed.ok()) break; + var tokens = parsed.lexical().stream().filter(t -> t.token()!=null && t.ruleId().equals("TinyExpressionP4::CodeBlock")).toList(); + if(tokens.size()==1) { + var token=tokens.get(0);int at=recognized.offsetByCodePoints(0,token.span().start()); + var layout=org.unlaxer.tinyexpression.codeblock.LongCodeFence.scan(recognized,at); + if(layout==null)throw new IllegalArgumentException("P4 long CodeBlock lexical contract changed"); + int size=remaining.codePointCount(0,remaining.length()); + int bodyStart=recognized.codePointCount(0,layout.bodyStart()),bodyEnd=recognized.codePointCount(0,layout.bodyEnd()); + if(partial && (bodyEndsize))break; + int consumed=partial?size:token.span().end(); + if(consumed<=0 || consumed>size)break; + String header=recognized.substring(at+layout.width(),layout.headerEnd());number++; + if(header.startsWith("java:")) { + int offset=start+input.codePointCount(0,from);String child=parent+"/java/"+number; + regions.add(region(host,child,parent,JAVA,new Span(offset+token.span().start(),offset+consumed), + new Span(offset+bodyStart,offset+(partial?size:bodyEnd)),partial?State.PARTIAL:State.COMPLETE)); + String className=header.substring(5);files.put(child,className.substring(className.lastIndexOf('.')+1)+".java"); + if(partial)openEnds.add(child); + } + if(partial)break; + from+=remaining.offsetByCodePoints(0,consumed);continue; + } + if (tokens.size()!=3) throw new IllegalArgumentException("P4 CodeBlock lexical contract changed"); + var open=tokens.get(0);var close=tokens.get(2); + int size=remaining.codePointCount(0,remaining.length()); + if (partial && (close.span().start()size)) break; + int consumed=partial ? size : close.span().end(); + if (consumed<=0 || consumed>size) break; + String header=recognized.substring(recognized.offsetByCodePoints(0,open.span().start()),recognized.offsetByCodePoints(0,open.span().end())).strip(); + number++; + if (header.startsWith("```java:")) { + int offset=start+input.codePointCount(0,from); + String child=parent+"/java/"+number; + Span full=new Span(offset+open.span().start(),offset+consumed); + Span body=new Span(offset+open.span().end(),offset+(partial?size:close.span().start())); + regions.add(region(host,child,parent,JAVA,full,body,partial?State.PARTIAL:State.COMPLETE)); + files.put(child,header.substring(Math.max(8,header.lastIndexOf('.')+1))+".java"); + if (partial) openEnds.add(child); + } + if (partial) break; + from += remaining.offsetByCodePoints(0,consumed); + } + } + private static String maskComments(String raw) { + var out = new StringBuilder();int fence=0; + for (String line : raw.split("(?<=\n)",-1)) { + if(fence>0) { + out.append(line); + if(fence==3 ? line.contains("```") : line.replaceFirst("\\r?\\n$", "").equals("`".repeat(fence)))fence=0; + } else if(line.startsWith("```")) { + while(fence out.appendCodePoint(c=='\r'||c=='\n'?c:' ')); + else out.append(line); + } + return out.toString(); + } + private static Region region(DocumentSnapshot host,String id,String parent,Language language,Span full,Span body,State state) { + var virtual=new DocumentSnapshot(host.uri()+"#production/"+id,host.version(),host.slice(body)); + var map=new SegmentSourceMap(virtual,List.of(new SegmentSourceMap.Segment(new Span(0,virtual.length()),SegmentSourceMap.Kind.COPY,new SegmentSourceMap.Location(host,body)))); + return new Region(id,parent,language,full,body,map,state); + } +} diff --git a/tools/tinyexpression-p4-lsp-vscode/src/embedded/resources/META-INF/services/org.unlaxer.tinyexpression.lsp.p4.EmbeddedLanguageDiagnostics b/tools/tinyexpression-p4-lsp-vscode/src/embedded/resources/META-INF/services/org.unlaxer.tinyexpression.lsp.p4.EmbeddedLanguageDiagnostics new file mode 100644 index 00000000..04cada72 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/embedded/resources/META-INF/services/org.unlaxer.tinyexpression.lsp.p4.EmbeddedLanguageDiagnostics @@ -0,0 +1 @@ +org.unlaxer.tinyexpression.lsp.p4.embedded.ProductionEmbeddedDiagnostics diff --git a/tools/tinyexpression-p4-lsp-vscode/src/extension.ts b/tools/tinyexpression-p4-lsp-vscode/src/extension.ts index dd8a08df..db0922b6 100644 --- a/tools/tinyexpression-p4-lsp-vscode/src/extension.ts +++ b/tools/tinyexpression-p4-lsp-vscode/src/extension.ts @@ -112,7 +112,8 @@ export async function activate( // Pass catalog path via initializationOptions so the server can use it // even if system property is not available (e.g. wrapped JVM) const initializationOptions: Record = { - useBundledVariables: catalog.useBundledVariables + useBundledVariables: catalog.useBundledVariables, + embeddedLanguageDiagnostics: vscode.workspace.getConfiguration("tinyExpressionP4Lsp").get("embeddedLanguageDiagnostics", false) }; if (catalog.catalogPath.length > 0) { initializationOptions.catalogPath = catalog.catalogPath; @@ -312,7 +313,7 @@ function registerCatalogFeatures(context: vscode.ExtensionContext, restartClient context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("tinyExpressionP4Lsp.catalog")) { + if (event.affectsConfiguration("tinyExpressionP4Lsp.catalog") || event.affectsConfiguration("tinyExpressionP4Lsp.embeddedLanguageDiagnostics")) { updateCatalogStatus(); void restartClient(); } diff --git a/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/EmbeddedLanguageDiagnostics.java b/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/EmbeddedLanguageDiagnostics.java new file mode 100644 index 00000000..c4ec56b5 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/src/main/java/org/unlaxer/tinyexpression/lsp/p4/EmbeddedLanguageDiagnostics.java @@ -0,0 +1,14 @@ +package org.unlaxer.tinyexpression.lsp.p4; + +import java.util.List; +import org.eclipse.lsp4j.Diagnostic; + +/** Optional analysis of the untouched full document; implementations must never evaluate user code. */ +public interface EmbeddedLanguageDiagnostics { + List analyze(String uri, int version, String fullContent); + /** Empty optional means no owned embedded region; a present empty list is an owned region without candidates. */ + default java.util.Optional> complete( + String uri, int version, String fullContent, org.eclipse.lsp4j.Position position) { + return java.util.Optional.empty(); + } +} 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 13a36e86..98d89645 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 @@ -353,6 +353,15 @@ String keywordHover(String name) { // LanguageClientAware // ========================================================================= + private EmbeddedLanguageDiagnostics embeddedDiagnostics; + private final Map embeddedVersions = new java.util.concurrent.ConcurrentHashMap<>(); + + /** Explicit opt-in. The default released server keeps its existing parser/backend behavior. */ + public void useEmbeddedDiagnostics(EmbeddedLanguageDiagnostics provider) { + this.embeddedDiagnostics = java.util.Objects.requireNonNull(provider); + incrementalCaches.clear(); + } + @Override public void connect(LanguageClient client) { super.connect(client); @@ -367,7 +376,22 @@ public void connect(LanguageClient client) { public CompletableFuture initialize(InitializeParams params) { // Call super to trigger initCatalogResolver(params) in the generated class. // We discard the returned capabilities and build our own below. - super.initialize(params); + var initialized = super.initialize(params); + if (initialized.isCompletedExceptionally()) return initialized; + Object raw = params.getInitializationOptions(); + var options = new com.google.gson.Gson().toJsonTree(raw); + if (options.isJsonObject() && options.getAsJsonObject().has("embeddedLanguageDiagnostics")) { + var enabled = options.getAsJsonObject().get("embeddedLanguageDiagnostics"); + if (!enabled.isJsonPrimitive() || !enabled.getAsJsonPrimitive().isBoolean()) + throw new IllegalArgumentException("embeddedLanguageDiagnostics must be a boolean"); + if (enabled.getAsBoolean()) { + var providers = java.util.ServiceLoader.load(EmbeddedLanguageDiagnostics.class).stream().toList(); + if (providers.size() != 1) throw new IllegalArgumentException("embedded-language profile requires exactly one diagnostics provider"); + useEmbeddedDiagnostics(providers.get(0).get()); + } else { + embeddedDiagnostics = null; + } + } ServerCapabilities cap = new ServerCapabilities(); cap.setTextDocumentSync(TextDocumentSyncKind.Full); @@ -398,6 +422,8 @@ public CompletableFuture initialize(InitializeParams params) { cap.setWorkspaceSymbolProvider(true); cap.setDocumentFormattingProvider(true); + cap.setExperimental(Map.of("embeddedLanguageDiagnostics", Map.of( + "enabled", embeddedDiagnostics != null, "fullDocument", true, "executesUserCode", false))); return CompletableFuture.completedFuture(new InitializeResult(cap)); } @@ -445,12 +471,14 @@ static FormulaSection extractFormulaSection(String fullContent) { // ========================================================================= @Override - public ParseResult parseDocument(String uri, String content) { + public synchronized ParseResult parseDocument(String uri, String content) { + // Every full snapshot must reach the provider, including changes outside the first formula. + if (embeddedDiagnostics != null) incrementalCaches.remove(uri); FormulaSection fs = documentFilter.extract(content); if (fs != null) { // FormulaInfo file: suppress the parent's whole-file diagnostics, then parse formula portion. - if (extClient != null) { + if (extClient != null && embeddedDiagnostics == null) { extClient.publishDiagnostics(new PublishDiagnosticsParams(uri, List.of())); } return parseAndEnrich(uri, fs.content(), fs.lineOffset(), content); @@ -812,7 +840,20 @@ private void publishEnrichedDiagnostics(String uri, String content, d.setData(diagnosticData(code, "syntax", start, details)); diagnostics.add(d); } - extClient.publishDiagnostics(new PublishDiagnosticsParams(uri, diagnostics)); + if (embeddedDiagnostics != null) { + try { + diagnostics.addAll(embeddedDiagnostics.analyze(uri, embeddedVersions.getOrDefault(uri, 0), + extDocuments.get(uri).fullContent())); + } catch (RuntimeException failure) { + Diagnostic diagnostic = new Diagnostic(new Range(new Position(0, 0), new Position(0, 0)), + "Embedded language analysis unavailable: " + failure.getMessage(), DiagnosticSeverity.Warning, "tinyexpression-embedded"); + diagnostic.setCode("EMBEDDED_UNAVAILABLE"); + diagnostics.add(diagnostic); + } + } + var published = new PublishDiagnosticsParams(uri, diagnostics); + if (embeddedDiagnostics != null) published.setVersion(embeddedVersions.getOrDefault(uri, 0)); + extClient.publishDiagnostics(published); } private List computeStrictMatchSemanticIssues( @@ -1550,27 +1591,56 @@ class ExtTextDocumentService implements TextDocumentService { @Override public void didOpen(DidOpenTextDocumentParams params) { - server.parseDocument( - params.getTextDocument().getUri(), - params.getTextDocument().getText()); + synchronized (server) { + if (server.embeddedDiagnostics != null) { + String uri = params.getTextDocument().getUri(); + int version = params.getTextDocument().getVersion(); + if (server.embeddedVersions.containsKey(uri) && version <= server.embeddedVersions.get(uri)) return; + server.embeddedVersions.put(uri, version); + } + server.parseDocument(params.getTextDocument().getUri(), params.getTextDocument().getText()); + } } @Override public void didChange(DidChangeTextDocumentParams params) { - server.parseDocument( - params.getTextDocument().getUri(), - params.getContentChanges().get(0).getText()); + synchronized (server) { + if (server.embeddedDiagnostics != null) { + String uri = params.getTextDocument().getUri(); + Integer version = params.getTextDocument().getVersion(); + if (!server.embeddedVersions.containsKey(uri) || version == null || version <= server.embeddedVersions.get(uri) + || params.getContentChanges().isEmpty() || params.getContentChanges().stream().anyMatch(change -> change.getRange() != null)) return; + server.embeddedVersions.put(uri, version); + server.parseDocument(uri, params.getContentChanges().get(params.getContentChanges().size() - 1).getText()); + return; + } + server.parseDocument(params.getTextDocument().getUri(), params.getContentChanges().get(0).getText()); + } } @Override public void didClose(DidCloseTextDocumentParams params) { - String closedUri = params.getTextDocument().getUri(); - server.extDocuments.remove(closedUri); - server.incrementalCaches.remove(closedUri); + synchronized (server) { + String closedUri = params.getTextDocument().getUri(); + server.extDocuments.remove(closedUri); + server.incrementalCaches.remove(closedUri); + if (server.embeddedDiagnostics != null) { + server.embeddedVersions.remove(closedUri); + if (server.extClient != null) server.extClient.publishDiagnostics(new PublishDiagnosticsParams(closedUri, List.of())); + } + } } @Override - public void didSave(DidSaveTextDocumentParams params) {} + public void didSave(DidSaveTextDocumentParams params) { + synchronized (server) { + if (server.embeddedDiagnostics != null) { + String uri = params.getTextDocument().getUri(); + var state = server.extDocuments.get(uri); + if (state != null) server.parseDocument(uri, state.fullContent()); + } + } + } // ── completion (enhanced with method/variable autocomplete + metadata) ── @@ -1578,6 +1648,21 @@ public void didSave(DidSaveTextDocumentParams params) {} public CompletableFuture, CompletionList>> completion( CompletionParams params) { + synchronized (server) { + String queryUri = params.getTextDocument().getUri(); + var queryState = server.extDocuments.get(queryUri); + if (server.embeddedDiagnostics != null && queryState != null) { + try { + var embedded = server.embeddedDiagnostics.complete(queryUri, + server.embeddedVersions.getOrDefault(queryUri, 0), queryState.fullContent(), params.getPosition()); + if (embedded.isPresent()) return CompletableFuture.completedFuture(Either.forLeft(embedded.get())); + } catch (RuntimeException failure) { + if (server.extClient != null) server.extClient.logMessage(new org.eclipse.lsp4j.MessageParams(org.eclipse.lsp4j.MessageType.Warning, + "Embedded completion unavailable: " + failure.getMessage())); + return CompletableFuture.completedFuture(Either.forLeft(List.of())); + } + } + } List items = new ArrayList<>(); String uri = params.getTextDocument().getUri(); ExtDocumentState state = server.extDocuments.get(uri);