diff --git a/.github/unlaxer-source-pin b/.github/unlaxer-source-pin index 31e72ac7..6d696722 100644 --- a/.github/unlaxer-source-pin +++ b/.github/unlaxer-source-pin @@ -1,3 +1,3 @@ -# unlaxer-parser commit CI builds for the declarative-module development dependency. -# Retain until pom.xml selects a published version with the same lexical capabilities. -commit=f825740396a6181654191de5ba040e1f83533526 +# Exact source dependency for named layout and package origins. +# Update to a published compatible artifact when available. +commit=fc75fe1e25e538d47908f05ea2039f585aef696e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3a97a88..f0ebd420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,12 +132,14 @@ jobs: run: | task_maven_repo=$(mktemp -d "$RUNNER_TEMP/ubnfc-parser.XXXXXX") echo "MAVEN_REPO=$task_maven_repo" >> "$GITHUB_ENV" - # Declarative lexical modules require the pinned 3.2.0-SNAPSHOT generator; + # Declarative lexical modules require the pinned 3.3.0-SNAPSHOT generator; # published 3.1.1 cannot read this grammar. Build into the job-local repository. - name: Install unlaxer from source if it is not on Maven Central yet run: bash scripts/ci/install-unlaxer-if-unpublished.sh # Grammar sha256 + every vendored file against UBNFC_PIN. ubnfc is private, so the full # grammar -> IR -> Java regeneration runs where it is readable (UBNFC_DIR); see the script. + - name: Verify strict layout compatibility adapter + run: python3 scripts/test_ubnfc_layout.py - name: Verify vendored ubnfc parser against its pin run: bash scripts/regenerate-ubnfc-parser.sh --check - name: Verify the generated AST converter @@ -172,6 +174,9 @@ jobs: with: java-version: '21' distribution: 'temurin' + - name: Set up Rust 1.85 for the shared source fixtures + if: matrix.generator == 'source' + run: bash tinyexpression/.github/scripts/setup-rust.sh 1.85.0 - name: Create an empty Maven repository on the assigned runner run: | task_maven_repo=$(mktemp -d "$RUNNER_TEMP/mapper-compatibility.XXXXXX") @@ -181,7 +186,7 @@ jobs: with: repository: opaopa6969/unlaxer-parser # Declarative lexical modules under a distinct development artifact version. - ref: f825740396a6181654191de5ba040e1f83533526 + ref: fc75fe1e25e538d47908f05ea2039f585aef696e path: unlaxer-parser - name: Install pinned development generator into its isolated repository if: matrix.generator == 'source' @@ -215,19 +220,26 @@ jobs: -Dtinyexpression.expected.mapper.snapshot=true -Dtinyexpression.expected.mapper.nodeAlias=true -Dtinyexpression.expected.mapper.optionalQualifier=true - -Dtest=P4SourceMappingTest,P4OwnedSliceSourceTest,P4TypedAstEvaluatorTest,P4JavaCodeEmitterSourceTextTest,DslGeneratedAstJavaEmitterTest,P4PreferredAstMapperPrecedenceTest,P4OptionalExternalQualifierTest,P4RustRootExpressionAcceptanceTest,P4RustNumericEvaluatorOracleTest,P4RustScalarEvaluatorOracleTest,CodeBlockSourceTest,CodeBlockNoCompilerTest + -Dtest=P4LayoutMigrationTest,P4SourceMappingTest,P4OwnedSliceSourceTest,P4TypedAstEvaluatorTest,P4JavaCodeEmitterSourceTextTest,DslGeneratedAstJavaEmitterTest,P4PreferredAstMapperPrecedenceTest,P4OptionalExternalQualifierTest,P4RustRootExpressionAcceptanceTest,P4RustNumericEvaluatorOracleTest,P4RustScalarEvaluatorOracleTest,CodeBlockSourceTest,CodeBlockNoCompilerTest - name: Verify shared Java/Rust parser fixtures with the pinned source generator if: matrix.generator == 'source' working-directory: tinyexpression run: >- mvn -B test -Dgpg.skip=true -Dmaven.repo.local="$MAVEN_REPO" -Dunlaxer.version="$UNLAXER_VERSION" -Dtinyexpression.skipRailroad=true -Dtinyexpression.rust.shared=true - -Dtest=P4RustSharedFixtureAcceptanceTest + -Dtinyexpression.layout.conformance=true -Dtinyexpression.unlaxer.source=../unlaxer-parser + -Dtest=P4RustSharedFixtureAcceptanceTest,P4LayoutGeneratedConformanceTest + - name: Require the shared layout comparison evidence + if: matrix.generator == 'source' + working-directory: tinyexpression + run: test -s target/p4-layout-generated.tsv - uses: actions/upload-artifact@v6 if: always() with: name: mapper-compatibility-${{ matrix.generator }} - path: tinyexpression/target/surefire-reports/ + path: | + tinyexpression/target/surefire-reports/ + tinyexpression/target/p4-layout-generated.tsv if-no-files-found: warn # issue #173: the Java CI above resolves org.unlaxer:unlaxer-* from Maven Central, so a diff --git a/.gitignore b/.gitignore index 2aa42bb3..e1339a32 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ buildNumber.properties /node_modules/ /package.json /package-lock.json + +# Python test bytecode +/scripts/__pycache__/ diff --git a/docs/imported-layout-migration.md b/docs/imported-layout-migration.md new file mode 100644 index 00000000..6b3728e6 --- /dev/null +++ b/docs/imported-layout-migration.md @@ -0,0 +1,32 @@ +# P4 の共有空白・コメント定義 + +P4 の grammar default と `Formula`、`ImportDeclaration`、`VariableDeclaration` は、固定した `std/layout@1.0.0` の `layout.SPACES_AND_COMMENTS` を参照する。rule の明示指定は `@whitespace(layout.SPACES_AND_COMMENTS)` に置換した。従来の `@interleave(profile=javaStyle)` と同じ場所で同じ集合を使い、子 rule に独自の指定が追加された場合も rule の優先順位を保てるため、冗長に見える指定も残した。 + +`grammar/ubnf.json` は builtin の正確な版を指定する。`ubnf.lock.json` の identity は `std/layout` / `1.0.0` / SHA-256 `2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67`。この小さな標準 artifact 自体を grammar の `.ubnf-cache/packages` に同梱する。生成は lock と検証済み bytes のみを読み、ネットワーク・認証・個人設定を必要としない。上流 generator は Maven `3.3.0-SNAPSHOT` と `.github/unlaxer-source-pin` の exact commit で固定する。CI の専用 Maven repository へその commit を install して検証する。 + +標準定義は ASCII space と TAB..CR、CR/LF 手前までの `//`、最初の `*/` までの block comment。閉じていない block comment は読み飛ばさない。文字列と fenced Java code の token 内部へ自動 skip は入らない。AST の文字列正規化、raw CST、source-preserving model の既存の comment mask と位置契約を維持する。 + +## backend の範囲 + +| 経路 | 実 P4 | 名前付き空白と固定 package | +|---|---|---| +| unlaxer Java 生成 parser | 対応。旧 grammar と全 AST field/node span・cursor・CST 原文を比較 | 直接対応 | +| unlaxer Java 経由 Rust 生成 / native Rust 生成 | P4 の token/layout projection で同じ入力・独立期待値・生成物 bytes を比較。実 P4 全体には従来の portability 制限がある(native は `literalBoundary: none` を拒否) | 直接対応。局所 `none` も共通 fixture で比較 | +| TinyExpression 本番 ubnfc Java / Rust | 固定生成物を使う。共有実 P4 fixture と source positions、memo OFF/ON を検証 | **std/layout@1.0.0 の限定互換処理**。任意 package/named policy の直接対応ではない | + +`scripts/prepare-ubnfc-layout.py` は manifest・lock graph・artifact hash・全定義・global 1箇所/rule 3箇所を確認し、この標準定義だけを旧 javaStyle/interleave へ戻す。混在設定、追加 package/comment、未知の定義、改ざんを変換前に拒否する。導出 grammar は旧 SHA-256 と一致し、Java/Rust ubnfc の grammar→IR→生成物も byte 一致する。再生成スクリプトの両経路から同じ互換処理を呼び、文法・manifest・lock・artifact・互換処理の hash を両 pin に含めた。汎用 ubnfc 対応は [#249](https://github.com/opaopa6969/tinyexpression/issues/249) で追跡する。 + +## 検証と定義表示 + +`actual-p4.json` は実 P4 の空白/TAB/LF/CRLF/コメント/未閉鎖/文字列/Java fence/import/declaration の14入力。Java の旧/新 parser と Tiny 本番 Java/Rust が同じ cursor 期待値を使う。Java の旧/新 AST 全 field/node span と retained CST 原文、owned source の位置を比較する。`corpus.json` は実 P4 の lexical modules を用いる29入力で、旧/新 layout と局所 `none` を Java・Java経由Rust・native Rust へ入力し、位置・診断・AST の独立期待値を比較する。 + +```sh +python3 scripts/test_ubnfc_layout.py +MAVEN_REPO=/tmp/tiny-layout-m2 scripts/ci/install-unlaxer-if-unpublished.sh +mvn test -Dmaven.repo.local=/tmp/tiny-layout-m2 -Dtinyexpression.skipRailroad=true \ + -Dtinyexpression.layout.conformance=true -Dtinyexpression.unlaxer.source=/path/to/pinned/unlaxer-parser \ + -Dtest=P4LayoutMigrationTest,P4LayoutGeneratedConformanceTest +cargo test --locked --manifest-path rust/Cargo.toml -p tinyexpression-rs shared_imported_layout +``` + +文法作者は UBNF authoring extension の `@whitespace` 補完から `layout.SPACES_AND_COMMENTS` を選び、hover で定義と ID/version/hash、定義への移動で読み取り専用の標準 module を確認できる。CLI `deps inspect --grammar tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf` も同じ定義・出典・code-point 位置を返す。`P4LayoutMigrationTest` は実 grammar のこの snapshot と4 policy scopeを確認する。TinyExpression の runtime LSP/DAP と文法作者向け UBNF LSP は別の役割を持つ。 diff --git a/pom.xml b/pom.xml index ab1ad89f..15c5c722 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ installed into a job-local, isolated Maven repo (issue #173: unlaxer-parser master, non-blocking) so the GAV actually resolves from that isolated repo instead of silently falling back to the published artifact. --> - 3.2.0-SNAPSHOT + 3.3.0-SNAPSHOT diff --git a/rust/check-generated.sh b/rust/check-generated.sh index 7d897565..0810a7d3 100755 --- a/rust/check-generated.sh +++ b/rust/check-generated.sh @@ -17,6 +17,12 @@ set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repo_dir=$(cd -- "$script_dir/.." && pwd) +# The offline check validates the restricted layout bridge even without ubnfc. +layout_check=$(mktemp -d) +trap 'rm -rf "$layout_check"' EXIT +python3 "$repo_dir/scripts/prepare-ubnfc-layout.py" \ + "$repo_dir/tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf" \ + "$layout_check/tinyexpression-p4.ubnf" >/dev/null # The offline check must notice a changed imported grammar too. (cd "$repo_dir" && sed -n 's/^grammar_file //p' rust/ubnfc-pin.txt | sha256sum -c -) if [ -d "${UBNFC_DIR:-$repo_dir/../ubnfc}" ]; then diff --git a/rust/scripts/regenerate-ubnfc.sh b/rust/scripts/regenerate-ubnfc.sh index c2e0f6d1..48f993d6 100755 --- a/rust/scripts/regenerate-ubnfc.sh +++ b/rust/scripts/regenerate-ubnfc.sh @@ -78,7 +78,7 @@ export CARGO_TARGET_DIR="$work/target" sha() { sha256sum "$1" | cut -d' ' -f1; } grammar_manifest() { - (cd "$repo_dir" && { find "$(dirname "$grammar_rel")" -type f -name '*.ubnf'; echo "$fi_grammar_rel"; } | + (cd "$repo_dir" && { find "$(dirname "$grammar_rel")" -type f \( -name '*.ubnf' -o -name '*.json' \); echo "$fi_grammar_rel"; echo scripts/prepare-ubnfc-layout.py; } | LC_ALL=C sort -u | while IFS= read -r f; do echo "$(sha "$f") $f"; done) } @@ -100,8 +100,10 @@ if [[ "$mode" == --check ]]; then diff <(sed -n 's/^grammar_file //p' "$pin") <(grammar_manifest) fi +python3 "$repo_dir/scripts/prepare-ubnfc-layout.py" "$repo_dir/$grammar_rel" "$work/compat/$grammar_rel" >/dev/null +touch "$work/compat/.git" # preserve the original logical source root (cd "$ubnfc_dir" && cargo run -q --offline -p ubnfc-front --bin ubnfc -- \ - ir --grammar "$repo_dir/$grammar_rel" \ + ir --grammar "$work/compat/$grammar_rel" \ --extern-first-chars "$ubnfc_dir/scanners/first-chars.json" \ --out "$work/p4.ir.json" >/dev/null) check_sha "$work/p4.ir.json" "$(value ir_sha256)" @@ -177,6 +179,16 @@ case "$mode" in rm -rf "$vendored" cp -r "$work/generated" "$vendored" (cd "$vendored" && find . -type f | LC_ALL=C sort | xargs sha256sum) > "$manifest" + python3 - "$rust_dir/tinyexpression-rs/src/api.rs" "$(sha "$repo_dir/$grammar_rel")" <<'PY' +from pathlib import Path +import re, sys +path = Path(sys.argv[1]) +text, count = re.subn(r'pub const GRAMMAR_SHA256: &str = "[0-9a-f]{64}";', + 'pub const GRAMMAR_SHA256: &str = "' + sys.argv[2] + '";', path.read_text()) +if count != 1: + raise SystemExit("expected exactly one GRAMMAR_SHA256 constant") +path.write_text(text) +PY echo "vendored ubnfc parser: updated" rm -rf "$vendored_fi" cp -r "$work/generated-formula-info" "$vendored_fi" diff --git a/rust/tinyexpression-rs/src/api.rs b/rust/tinyexpression-rs/src/api.rs index 6dbaeb2e..8ea11ea6 100644 --- a/rust/tinyexpression-rs/src/api.rs +++ b/rust/tinyexpression-rs/src/api.rs @@ -39,7 +39,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// fails when it differs from `ubnfc_commit` in `rust/ubnfc-pin.txt`. pub const UBNFC_COMMIT: &str = "f54d6b90466ffde14066d32f911ed642b1e49201"; /// Exact P4 grammar source identity; checked against `rust/ubnfc-pin.txt` in CI. -pub const GRAMMAR_SHA256: &str = "4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a"; +pub const GRAMMAR_SHA256: &str = "184cce54c70b0e3ebbdedaca5a0af0068ec56b2d7e33cda39d94f861c6433f46"; /// One JSON response and the exit code that goes with it. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/rust/tinyexpression-rs/src/frontend/declarative_tests.rs b/rust/tinyexpression-rs/src/frontend/declarative_tests.rs index ffdbd733..b2bfc4ba 100644 --- a/rust/tinyexpression-rs/src/frontend/declarative_tests.rs +++ b/rust/tinyexpression-rs/src/frontend/declarative_tests.rs @@ -4,6 +4,59 @@ use crate::generated::{ compat, ubnfc, }; +#[test] +fn shared_imported_layout_keeps_production_p4_positions() { + use crate::request::{parse_json, Json}; + let Json::Arr(rows) = parse_json(include_str!( + "../../../../src/test/resources/p4-layout/actual-p4.json" + )) + .unwrap() else { + panic!("array") + }; + for row in rows { + let id = row.str_field("id").unwrap(); + let input = row.str_field("input").unwrap(); + let Some(Json::Bool(ok)) = row.get("ok") else { + panic!("{id}") + }; + let end = if *ok { input.chars().count() } else { 0 }; + for memo in [false, true] { + let result = ubnfc::parse_entry_with_options( + "TinyExpressionP4", + Some("Formula"), + input, + ubnfc::ParseOptions { + require_eof: false, + memo, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + (result.ok, result.consumed_cp, result.matched_cp), + (*ok, end, end), + "{id}" + ); + if *ok { + let converted = compat::convert(result.ast.as_ref().unwrap()).unwrap(); + let Ast::FormulaExpr { span, .. } = converted else { + panic!("{id}") + }; + assert_eq!( + (span.start, span.end), + (0, end), + "{id} owned source position" + ); + assert_eq!( + input.chars().take(span.end).collect::(), + input, + "{id} owned source" + ); + } + } + } +} + #[test] fn shared_lexical_cases_need_no_host_parser_mapping() { use crate::request::{parse_json, Json}; diff --git a/rust/ubnfc-pin.txt b/rust/ubnfc-pin.txt index 4d1e4c50..f33fa8d8 100644 --- a/rust/ubnfc-pin.txt +++ b/rust/ubnfc-pin.txt @@ -1,14 +1,18 @@ # Generated by rust/scripts/regenerate-ubnfc.sh --write; do not edit hashes by hand. ubnfc_commit=f54d6b90466ffde14066d32f911ed642b1e49201 grammar=tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf -grammar_sha256=4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a +grammar_sha256=184cce54c70b0e3ebbdedaca5a0af0068ec56b2d7e33cda39d94f861c6433f46 grammar_file 28bcfc9f2e684b7326561f281155a1d0140dc67145d178764841bc7dc87a81cb grammar/formula-info.ubnf +grammar_file 492cafad9f8f3278480d862d0f1fadeb5f61bf5c627b8072b32f6d90c91d3ff1 scripts/prepare-ubnfc-layout.py +grammar_file 2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67 tools/tinyexpression-p4-lsp-vscode/grammar/.ubnf-cache/packages/2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67.json grammar_file e33b8166d0a5b399150b583b0e466fbe2a4194325ef65c79c082297156796b76 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/characters.ubnf grammar_file 53ad774e87bd95a498ac744a721f22bdaf877721c0da13e8045aaeaea09b8179 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/fences.ubnf grammar_file 55f5f3b9289cb41b74d5c617e0b2ec1adbf24eb7fd8ca11aa02eda311b92bf2b tools/tinyexpression-p4-lsp-vscode/grammar/lexical/identifiers.ubnf grammar_file 1b1fcaa633f9477ad5414ecfed64693d1d50bf0aaa02775c5d67b39a18e8244f tools/tinyexpression-p4-lsp-vscode/grammar/lexical/numbers.ubnf grammar_file 08f209d29c84aa1addcb57eba24d7738c27bc8921cb13f1fbd5040dff6296a26 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/strings.ubnf -grammar_file 4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf +grammar_file 184cce54c70b0e3ebbdedaca5a0af0068ec56b2d7e33cda39d94f861c6433f46 tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf +grammar_file 2074b51b078eecd61c5a0bbd20399f423b20faada77d331b96f212692c27bdf7 tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.json +grammar_file 25745248036e459f3b69ad202f12b52d857b6f70abc02dac23caf4e7745f4db0 tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.lock.json ir_sha256=55d5981a9da79a887034778aa1853dffea92162cb5e9be18c840b072cafb13a0 scanners=scanners/rust/scanners.rs scanners_sha256=593a2736f7f20fa5d61456e25e7d961cefbd427d93b05a7ced46b41506e6a412 diff --git a/scripts/ci/install-unlaxer-if-unpublished.sh b/scripts/ci/install-unlaxer-if-unpublished.sh index 6459b344..d6fecbd3 100755 --- a/scripts/ci/install-unlaxer-if-unpublished.sh +++ b/scripts/ci/install-unlaxer-if-unpublished.sh @@ -4,7 +4,7 @@ # # MAVEN_REPO=/path/to/repo scripts/ci/install-unlaxer-if-unpublished.sh # -# Declarative P4 lexical modules need unlaxer 3.2.0-SNAPSHOT, not the published 3.1.1. +# Declarative P4 lexical modules need unlaxer 3.3.0-SNAPSHOT, not the published 3.1.1. # Until a compatible version is published, CI builds the pinned unlaxer-parser commit # (.github/unlaxer-source-pin) whose must equal pom.xml's unlaxer.version. Once the # version is on Central this script does nothing, and the pin file can be deleted. diff --git a/scripts/prepare-ubnfc-layout.py b/scripts/prepare-ubnfc-layout.py new file mode 100644 index 00000000..d481edbe --- /dev/null +++ b/scripts/prepare-ubnfc-layout.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Lower only pinned std/layout 1.0.0 to ubnfc's existing javaStyle policy. + +This is a TinyExpression compatibility bridge, not a package resolver. It never +reads credentials or downloads anything, and refuses every other named policy. +""" +import hashlib +import json +from pathlib import Path +import re +import shutil +import sys + +STANDARD_HASH = "2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67" +STANDARD_TEXT = ( + "grammar StandardLayout {\n @ubnf: v2\n" + " token SPACES ::= (' ' | CHAR_RANGE('\\t', '\\r'))+;\n" + " token LINE_COMMENT ::= '//' NEGATION('\\r\\n')*;\n" + " token BLOCK_COMMENT ::= '/*' (NEGATIVE_LOOKAHEAD('*/') ANY)* '*/';\n" + " token SPACES_AND_COMMENTS ::= SPACES | LINE_COMMENT | BLOCK_COMMENT;\n}\n" +) + + +def strict_object(pairs): + result = {} + for name, value in pairs: + if name in result: + raise ValueError("duplicate JSON key") + if name == "schemaVersion" and type(value) is not int: + raise ValueError("schemaVersion must be an integer") + result[name] = value + return result + + +def read_json(path): + def invalid_constant(value): + raise ValueError("invalid JSON numeric constant") + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=strict_object, + parse_constant=invalid_constant) + + +def prepare(source, destination): + base = source.parent + expected_dependency = {"version": "1.0.0", "source": "builtin:std/layout@1.0.0"} + if read_json(base / "ubnf.json") != { + "schemaVersion": 1, "dependencies": {"std/layout": expected_dependency} + }: + raise ValueError("only the exact builtin std/layout dependency is supported") + if read_json(base / "ubnf.lock.json") != { + "schemaVersion": 1, "roots": {"std/layout": "1.0.0"}, + "packages": {"std/layout": {"id": "std/layout", "version": "1.0.0", + "sha256": STANDARD_HASH, "dependencies": {}}} + }: + raise ValueError("layout lock identity or dependency graph differs") + artifact_path = base / ".ubnf-cache" / "packages" / (STANDARD_HASH + ".json") + artifact = artifact_path.read_bytes() + if hashlib.sha256(artifact).hexdigest() != STANDARD_HASH: + raise ValueError("layout artifact SHA-256 mismatch") + if read_json(artifact_path) != { + "schemaVersion": 1, "id": "std/layout", "version": "1.0.0", + "entry": "layout.ubnf", "files": {"layout.ubnf": STANDARD_TEXT}, "dependencies": {} + }: + raise ValueError("layout definitions differ from the javaStyle compatibility set") + + text = source.read_text(encoding="utf-8") + imported = " @import layout from 'pkg:std/layout'\n\n" + global_policy = "@whitespace: layout.SPACES_AND_COMMENTS" + local_policy = "@whitespace(layout.SPACES_AND_COMMENTS)" + if text.count(imported) != 1 or text.count(global_policy) != 1 or text.count(local_policy) != 3: + raise ValueError("P4 layout policy scopes differ from the verified migration") + lowered = text.replace(imported, "").replace(global_policy, "@whitespace: javaStyle") + lowered = lowered.replace(local_policy, "@interleave(profile=javaStyle)") + # Reject mixed/custom policies and extra package imports before invoking ubnfc. + if "pkg:" in lowered or "@comment" in lowered: + raise ValueError("additional package/comment configuration is unsupported") + if re.findall(r"@whitespace\s*:\s*(\S+)", lowered) != ["javaStyle"]: + raise ValueError("mixed or unknown global whitespace policy") + if re.search(r"@whitespace\s*\(", lowered): + raise ValueError("additional local whitespace policy is unsupported") + if re.findall(r"@interleave\s*\(([^)]*)\)", lowered) != ["profile=javaStyle"] * 3: + raise ValueError("mixed or unknown interleave policy") + # Keep imported module paths identical in the derived tree; no external files. + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(base, destination.parent, dirs_exist_ok=True) + destination.write_text(lowered, encoding="utf-8") + return hashlib.sha256(lowered.encode("utf-8")).hexdigest() + + +if __name__ == "__main__": + try: + if len(sys.argv) != 3: + raise ValueError("usage: prepare-ubnfc-layout.py SOURCE.ubnf DESTINATION.ubnf") + print(prepare(Path(sys.argv[1]), Path(sys.argv[2]))) + except (ValueError, OSError, KeyError) as error: + print("E-UBNFC-LAYOUT: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/scripts/regenerate-ubnfc-parser.sh b/scripts/regenerate-ubnfc-parser.sh index d1a8fe65..afb5f5dc 100755 --- a/scripts/regenerate-ubnfc-parser.sh +++ b/scripts/regenerate-ubnfc-parser.sh @@ -48,7 +48,7 @@ sha() { sha256sum "$1" | cut -d' ' -f1; } # Include imported modules, not only the entry grammar, in the reproducibility pin. grammar_manifest() { - (cd "$repo" && find "$(dirname "$grammar_rel")" -type f -name '*.ubnf' | LC_ALL=C sort | + (cd "$repo" && { find "$(dirname "$grammar_rel")" -type f \( -name '*.ubnf' -o -name '*.json' \); echo scripts/prepare-ubnfc-layout.py; } | LC_ALL=C sort | while IFS= read -r f; do echo "$(sha "$f") $f"; done) } @@ -64,6 +64,10 @@ pinned_commit=$(pin_value ubnfc_commit) fail=0 offline_check() { local expected actual + local layout_check + layout_check=$(mktemp -d) + python3 "$repo/scripts/prepare-ubnfc-layout.py" "$repo/$grammar_rel" "$layout_check/$grammar_rel" >/dev/null + rm -rf "$layout_check" expected=$(pin_value grammar_sha256) actual=$(sha "$repo/$grammar_rel") if [[ "$expected" != "$actual" ]]; then @@ -111,8 +115,10 @@ regenerate() { echo "building ubnfc Java backend at $rev ..." # shellcheck disable=SC2086 mvn -q ${MVN_FLAGS:-} -f "$scratch/ubnfc/ubnfc-java/pom.xml" -Dmaven.test.skip=true package - # Run from the repo root so the IR records the grammar by its repo-relative path. - (cd "$repo" && "$scratch/ubnfc/target/release/ubnfc" ir --grammar "$grammar_rel" \ + # Use the verified compatibility grammar, retaining its repo-relative path. + python3 "$repo/scripts/prepare-ubnfc-layout.py" "$repo/$grammar_rel" "$scratch/compat/$grammar_rel" >/dev/null + touch "$scratch/compat/.git" # logical source root only; no Git commands use this marker + (cd "$scratch/compat" && "$scratch/ubnfc/target/release/ubnfc" ir --grammar "$grammar_rel" \ --extern-first-chars "$scratch/ubnfc/$first_chars" --out "$scratch/out/grammar.ir.json" \ 2> "$scratch/front-warnings.txt") java -cp "$scratch/ubnfc/ubnfc-java/target/ubnfc-java-0.1.0-SNAPSHOT.jar" org.unlaxer.ubnfc.java.Main \ diff --git a/scripts/test_ubnfc_layout.py b/scripts/test_ubnfc_layout.py new file mode 100644 index 00000000..05db8574 --- /dev/null +++ b/scripts/test_ubnfc_layout.py @@ -0,0 +1,82 @@ +import importlib.util +import json +from pathlib import Path +import shutil +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location("layout", ROOT / "scripts/prepare-ubnfc-layout.py") +LAYOUT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LAYOUT) + + +class LayoutCompatibilityTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.base = Path(self.temporary.name) + shutil.copytree(ROOT / "tools/tinyexpression-p4-lsp-vscode/grammar", self.base / "grammar") + self.source = self.base / "grammar/tinyexpression-p4.ubnf" + self.destination = self.base / "derived/tinyexpression-p4.ubnf" + + def tearDown(self): + self.temporary.cleanup() + + def rejects(self): + with self.assertRaises((ValueError, OSError)): + LAYOUT.prepare(self.source, self.destination) + self.assertFalse(self.destination.exists(), "reject before publishing the derived grammar") + + def test_exact_snapshot_restores_the_pinned_old_grammar(self): + self.assertEqual("4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a", + LAYOUT.prepare(self.source, self.destination)) + self.assertNotIn("pkg:", self.destination.read_text()) + + def test_custom_mixed_and_missing_policies_are_rejected(self): + original = self.source.read_text() + for changed in [ + original.replace("layout.SPACES_AND_COMMENTS", "layout.SPACES"), + original.replace("@whitespace: layout.SPACES_AND_COMMENTS", "@whitespace: custom.GAP"), + original + "\n@whitespace: none\n", + original + "\n@whitespace(none)\n", + original + "\n@interleave(profile=commentsAndSpaces)\n", + original + "\n@comment: hidden\n", + original + "\n@import extra from 'pkg:team/layout'\n", + original.replace("@whitespace(layout.SPACES_AND_COMMENTS)", "", 1), + ]: + with self.subTest(changed=changed[-60:]): + self.source.write_text(changed) + self.rejects() + + def test_manifest_version_and_source_are_exact(self): + path = self.source.parent / "ubnf.json" + for dependency in [{"version": "1.0.1", "source": "builtin:std/layout@1.0.0"}, + {"version": "1.0.0", "source": "https://example.test/layout.json"}]: + path.write_text(json.dumps({"schemaVersion": 1, "dependencies": {"std/layout": dependency}})) + self.rejects() + + def test_identity_hash_graph_and_artifact_tampering_are_rejected(self): + lock = self.source.parent / "ubnf.lock.json" + original = json.loads(lock.read_text()) + for key, value in [("id", "team/layout"), ("version", "1.0.1"), ("sha256", "0" * 64), + ("dependencies", {"team/layout": "1.0.0"})]: + changed = json.loads(json.dumps(original)) + changed["packages"]["std/layout"][key] = value + lock.write_text(json.dumps(changed)) + self.rejects() + lock.write_text(json.dumps(original)) + cache = self.source.parent / ".ubnf-cache/packages" / (LAYOUT.STANDARD_HASH + ".json") + cache.write_bytes(cache.read_bytes() + b" ") + self.rejects() + + def test_non_integer_schema_is_rejected(self): + (self.source.parent / "ubnf.json").write_text('{"schemaVersion":true}') + self.rejects() + + def test_duplicate_json_keys_are_rejected(self): + (self.source.parent / "ubnf.json").write_text('{"schemaVersion":1,"schemaVersion":1}') + self.rejects() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/org/unlaxer/tinyexpression/p4/ubnfc/UBNFC_PIN b/src/main/java/org/unlaxer/tinyexpression/p4/ubnfc/UBNFC_PIN index 412e7805..ceb9cd37 100644 --- a/src/main/java/org/unlaxer/tinyexpression/p4/ubnfc/UBNFC_PIN +++ b/src/main/java/org/unlaxer/tinyexpression/p4/ubnfc/UBNFC_PIN @@ -4,13 +4,17 @@ ubnfc_repository=https://github.com/opaopa6969/ubnfc ubnfc_commit=f54d6b90466ffde14066d32f911ed642b1e49201 grammar=tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf -grammar_sha256=4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a +grammar_sha256=184cce54c70b0e3ebbdedaca5a0af0068ec56b2d7e33cda39d94f861c6433f46 +grammar_file 492cafad9f8f3278480d862d0f1fadeb5f61bf5c627b8072b32f6d90c91d3ff1 scripts/prepare-ubnfc-layout.py +grammar_file 2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67 tools/tinyexpression-p4-lsp-vscode/grammar/.ubnf-cache/packages/2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67.json grammar_file e33b8166d0a5b399150b583b0e466fbe2a4194325ef65c79c082297156796b76 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/characters.ubnf grammar_file 53ad774e87bd95a498ac744a721f22bdaf877721c0da13e8045aaeaea09b8179 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/fences.ubnf grammar_file 55f5f3b9289cb41b74d5c617e0b2ec1adbf24eb7fd8ca11aa02eda311b92bf2b tools/tinyexpression-p4-lsp-vscode/grammar/lexical/identifiers.ubnf grammar_file 1b1fcaa633f9477ad5414ecfed64693d1d50bf0aaa02775c5d67b39a18e8244f tools/tinyexpression-p4-lsp-vscode/grammar/lexical/numbers.ubnf grammar_file 08f209d29c84aa1addcb57eba24d7738c27bc8921cb13f1fbd5040dff6296a26 tools/tinyexpression-p4-lsp-vscode/grammar/lexical/strings.ubnf -grammar_file 4a02d31de4e646a0664fd78a4b77ff2e145e7af7dde83a209e244e38298e962a tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf +grammar_file 184cce54c70b0e3ebbdedaca5a0af0068ec56b2d7e33cda39d94f861c6433f46 tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf +grammar_file 2074b51b078eecd61c5a0bbd20399f423b20faada77d331b96f212692c27bdf7 tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.json +grammar_file 25745248036e459f3b69ad202f12b52d857b6f70abc02dac23caf4e7745f4db0 tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.lock.json extern_first_chars=scanners/first-chars.json ir_sha256=55d5981a9da79a887034778aa1853dffea92162cb5e9be18c840b072cafb13a0 java_package=org.unlaxer.tinyexpression.p4.ubnfc.generated diff --git a/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutGeneratedConformanceTest.java b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutGeneratedConformanceTest.java new file mode 100644 index 00000000..4e47d38f --- /dev/null +++ b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutGeneratedConformanceTest.java @@ -0,0 +1,256 @@ +package org.unlaxer.tinyexpression.p4; + +import org.unlaxer.dsl.codegen.*; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.unlaxer.context.ParseContext; +import org.unlaxer.dsl.bootstrap.UBNFAST.GrammarDecl; +import org.unlaxer.dsl.bootstrap.UBNFMapper; +import org.unlaxer.dsl.codegen.CodeGenerator.GeneratedSource; +import org.unlaxer.dsl.codegen.rust.RustBackend; +import org.unlaxer.parser.Parser; + +/** Java/generated-Rust/native-frontend contract for rule-scoped trivia policy. */ +public class P4LayoutGeneratedConformanceTest extends P4LayoutTestSupport { + private static final String PACKAGE = "org.example.ruletrivia"; + + private final Path repo = Path.of(System.getProperty("tinyexpression.unlaxer.source", "../unlaxer-parser")).toAbsolutePath().normalize(); + + @Test public void p4LayoutPoliciesPreserveCursorsAcceptanceAstAndSpans() throws Exception { + assumeTrue("enable with -Dtinyexpression.layout.conformance=true (requires rustc/cargo)", + Boolean.getBoolean("tinyexpression.layout.conformance")); + + Path runtime = temporary.getRoot().toPath().resolve("libunlaxer_runtime.rlib"); + success(run(List.of("rustc", "--edition=2021", "--crate-type=rlib", + "--crate-name=unlaxer_runtime", repo.resolve("rust/unlaxer-runtime/src/lib.rs").toString(), + "-o", runtime.toString()), "", false)); + + Path nativeTarget = temporary.getRoot().toPath().resolve("native-target"); + success(run(List.of("cargo", "build", "--locked", "--manifest-path", + repo.resolve("rust/Cargo.toml").toString(), "-p", "unlaxer-generator", "--target-dir", + nativeTarget.toString()), "", false)); + Path nativeGenerator = nativeTarget.resolve("debug/unlaxer").toAbsolutePath(); + + Path fixtures = Path.of("src/test/resources/p4-layout"); + JsonArray corpus = JsonParser.parseString( + Files.readString(fixtures.resolve("corpus.json"))).getAsJsonArray(); + var report = new ArrayList<>(List.of( + "fixture\tcase\tinput_json\texpected_prefix\tjava_prefix\texpected_ast\tjava_ast\trust")); + + int fixtureIndex = 0; + for (JsonElement fixtureElement : corpus) { + JsonObject fixture = fixtureElement.getAsJsonObject(); + String name = fixture.get("name").getAsString(); + String source = fixture.get("grammar").getAsString(); + if (fixture.has("modules")) for (var module : fixture.getAsJsonObject("modules").entrySet()) + assertEquals(name + " actual P4 module stays synchronized: " + module.getKey(), + Files.readString(Path.of("tools/tinyexpression-p4-lsp-vscode/grammar/lexical", module.getKey())), + module.getValue().getAsString()); + Path fixtureDir = temporary.getRoot().toPath().resolve("fixture-" + fixtureIndex++); + Files.createDirectories(fixtureDir); + Path ubnf = fixtureDir.resolve("root.ubnf"); + Files.writeString(ubnf, source); + if (fixture.has("modules")) for (var entry : fixture.getAsJsonObject("modules").entrySet()) + Files.writeString(fixtureDir.resolve(entry.getKey()), entry.getValue().getAsString()); + if (fixture.has("manifest")) { + Path manifest = fixtureDir.resolve("ubnf.json"); + Files.writeString(manifest, fixture.get("manifest").toString()); + org.unlaxer.dsl.bootstrap.UBNFPackageResolver.resolve(manifest); + String javaLock = Files.readString(fixtureDir.resolve("ubnf.lock.json")); + success(run(List.of(nativeGenerator.toString(), "deps", "resolve", "--manifest", manifest.toString()), "", true)); + assertEquals(name + " exact package identity/hash lock parity", javaLock, + Files.readString(fixtureDir.resolve("ubnf.lock.json"))); + } + GrammarDecl grammar = org.unlaxer.dsl.bootstrap.UBNFModuleLoader.load(ubnf).grammars().get(0); + GrammarValidator.validateOrThrow(grammar); + List javaFrontend = new RustBackend().generate(grammar); + assertEquals(name + " Java frontend Rust file count", 5, javaFrontend.size()); + + Path nativeGenerated = fixtureDir.resolve("native-generated"); + success(run(List.of(nativeGenerator.toString(), "generate", "--grammar", ubnf.toString(), + "--output", nativeGenerated.toString()), "", true)); + for (var file : javaFrontend) { + assertArrayEquals(name + " native frontend byte parity: " + file.relativePath(), + file.content().getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(nativeGenerated.resolve(file.relativePath()))); + } + + Path generated = Files.createDirectory(fixtureDir.resolve("generated")); + for (var file : javaFrontend) { + Files.writeString(generated.resolve(file.relativePath()), file.content()); + } + Files.writeString(fixtureDir.resolve("main.rs"), rustProbe()); + success(run(List.of("rustc", "--edition=2021", "--extern", + "unlaxer_runtime=" + runtime, fixtureDir.resolve("main.rs").toString(), "-o", + fixtureDir.resolve("probe").toString()), "", false)); + + JsonArray cases = fixture.getAsJsonArray("cases"); + String framed = String.join("\n", cases.asList().stream() + .map(row -> HexFormat.of().formatHex(row.getAsJsonObject().get("input").getAsString() + .getBytes(StandardCharsets.UTF_8))).toList()) + "\n"; + ProcessResult rustResult = run(List.of(fixtureDir.resolve("probe").toString()), framed, false); + success(rustResult); + List rustLines = rustResult.output().lines().toList(); + assertEquals(name + " Rust result count", cases.size(), rustLines.size()); + + try (URLClassLoader loader = compileJava(grammar)) { + Class parsers = loader.loadClass(PACKAGE + "." + grammar.name() + "Parsers"); + Parser parser = (Parser) parsers.getMethod("getRootParser").invoke(null); + Class mapper = loader.loadClass(PACKAGE + "." + grammar.name() + "Mapper"); + for (int i = 0; i < cases.size(); i++) { + JsonObject row = cases.get(i).getAsJsonObject(); + String input = row.get("input").getAsString(); + String context = name + "/" + row.get("id").getAsString(); + JsonObject rust = JsonParser.parseString(rustLines.get(i)).getAsJsonObject(); + + JsonArray javaPrefix = prefix(parser, input); + assertEquals(context + " independent prefix cursor oracle", row.get("prefix"), javaPrefix); + assertEquals(context + " Java/Rust prefix cursor parity", javaPrefix, rust.get("prefix")); + + boolean accepted = row.get("accepted").getAsBoolean(); + Optional diagnostic = + (Optional) mapper.getMethod("diagnose", String.class).invoke(null, input); + assertEquals(context + " Java full-input acceptance", accepted, diagnostic.isEmpty()); + assertEquals(context + " Rust full-input acceptance", accepted, + !rust.get("ast").isJsonNull()); + + if (row.has("diagnostic")) { + Object failure = diagnostic.orElseThrow(); + JsonArray javaDiagnostic = new JsonArray(); + javaDiagnostic.add((String) failure.getClass().getMethod("kind").invoke(failure)); + javaDiagnostic.add((Integer) failure.getClass().getMethod("offset").invoke(failure)); + assertEquals(context + " independent diagnostic category/location", row.get("diagnostic"), javaDiagnostic); + assertEquals(context + " Java/Rust diagnostic category/location", javaDiagnostic, rust.get("diagnostic")); + } + JsonElement expected = JsonNull.INSTANCE; + JsonElement javaAst = JsonNull.INSTANCE; + if (accepted) { + expected = row.get("ast"); + Object mapped = mapper.getMethod("parseWithSourceMap", String.class).invoke(null, input); + Object ast = mapped.getClass().getMethod("ast").invoke(mapped); + javaAst = canonical(ast, mapped); + assertEquals(context + " Java AST all fields/node spans", expected, javaAst); + assertEquals(context + " Rust AST all fields/node spans", expected, rust.get("ast")); + } + report.add(name + "\t" + row.get("id").getAsString() + "\t" + row.get("input") + + "\t" + row.get("prefix") + "\t" + javaPrefix + "\t" + expected + "\t" + + javaAst + "\t" + rust); + } + } + } + + Path target = Path.of("target"); + Files.createDirectories(target); + Files.write(target.resolve("p4-layout-generated.tsv"), report, StandardCharsets.UTF_8); + } + + private String rustProbe() { + return """ + mod generated; + use std::io::{self, BufRead}; + fn main() { + for line in io::stdin().lock().lines() { + let encoded = line.unwrap(); + let bytes = (0..encoded.len()).step_by(2) + .map(|i| u8::from_str_radix(&encoded[i..i + 2], 16).unwrap()).collect(); + let input = String::from_utf8(bytes).unwrap(); + let mut context = unlaxer_runtime::ParseContext::new(&input); + let prefix_ok = generated::parser::parse_context(&mut context).is_ok(); + let options = unlaxer_runtime::ParseOptions::with_memoization(unlaxer_runtime::Memoization::SafeFailures); + let mut memo_context = unlaxer_runtime::ParseContext::with_options(&input, options); + let memo_ok = generated::parser::parse_context(&mut memo_context).is_ok(); + assert_eq!((prefix_ok, context.position(), context.matched_position()), + (memo_ok, memo_context.position(), memo_context.matched_position())); + print!(r#"{{\"prefix\":[{},{},{}],\"ast\":"#, + prefix_ok, context.position(), context.matched_position()); + match generated::parser::parse_tree_detailed(&input) { + Ok(tree) => { + let ast = generated::mapper::map(&tree).unwrap(); + drop(tree); + let canonical = ast.canonical_json(); + let memo_tree = generated::parser::parse_tree_detailed_with_options(&input, options).unwrap(); + assert_eq!(canonical, generated::mapper::map(&memo_tree).unwrap().canonical_json()); + print!("{}", canonical); + } + Err(error) => { + assert_eq!(error, generated::parser::parse_tree_detailed_with_options(&input, options).unwrap_err()); + print!(r#"null,\"diagnostic\":[\"{}\",{}]"#, error.kind, error.offset); + } + } + println!("}}"); + } + } + """; + } + + private record ProcessResult(int code, String output) {} + + private ProcessResult run(List command, String input, boolean withoutJava) throws Exception { + Path log = temporary.newFile().toPath(); + var builder = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log.toFile()); + if (withoutJava) { + builder.environment().put("PATH", ""); + builder.environment().put("JAVA_HOME", "/nonexistent-unlaxer-java"); + } + Process process = builder.start(); + try { + try (var stdin = process.getOutputStream()) { + stdin.write(input.getBytes(StandardCharsets.UTF_8)); + } + if (!process.waitFor(180, TimeUnit.SECONDS)) { + terminateProcessTree(process); + fail("process timed out: " + command); + } + return new ProcessResult(process.exitValue(), Files.readString(log)); + } finally { + if (process.isAlive()) terminateProcessTree(process); + } + } + + private void terminateProcessTree(Process process) throws InterruptedException { + List descendants = process.descendants().toList(); + for (int i = descendants.size() - 1; i >= 0; i--) descendants.get(i).destroy(); + process.destroy(); + process.waitFor(2, TimeUnit.SECONDS); + for (int i = descendants.size() - 1; i >= 0; i--) { + ProcessHandle descendant = descendants.get(i); + if (descendant.isAlive()) descendant.destroyForcibly(); + } + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + } + } + + private void success(ProcessResult result) { + assertEquals(result.output(), 0, result.code()); + } +} diff --git a/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutMigrationTest.java b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutMigrationTest.java new file mode 100644 index 00000000..4faecaeb --- /dev/null +++ b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutMigrationTest.java @@ -0,0 +1,87 @@ +package org.unlaxer.tinyexpression.p4; + +import static org.junit.Assert.*; + +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.Test; +import org.unlaxer.dsl.bootstrap.UBNFModuleLoader; +import org.unlaxer.parser.Parser; +import org.unlaxer.tinyexpression.generated.p4.TinyExpressionP4Mapper; +import org.unlaxer.tinyexpression.generated.p4.TinyExpressionP4Parsers; +import org.unlaxer.tinyexpression.p4.ubnfc.generated.TinyExpressionP4Parser; +import org.unlaxer.tinyexpression.p4.ubnfc.generated.api.ParseOptions; + +/** Real P4 scopes and retained text compared with the old policy and production ubnfc. */ +public class P4LayoutMigrationTest extends P4LayoutTestSupport { + @Test public void authoringSnapshotShowsTheExactDefinitionAndPinnedOrigin() throws Exception { + var snapshot = org.unlaxer.dsl.tooling.VocabularyOrigins.inspect(Path.of( + "tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf")); + var layout = snapshot.getAsJsonArray("modules").asList().stream() + .map(value -> value.getAsJsonObject()).filter(value -> value.get("alias").getAsString().equals("layout")) + .findFirst().orElseThrow(); + var identity = layout.getAsJsonObject("identity"); + assertEquals("std/layout", identity.get("id").getAsString()); + assertEquals("1.0.0", identity.get("version").getAsString()); + assertEquals("2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67", + identity.get("sha256").getAsString()); + assertEquals("layout.ubnf", identity.get("file").getAsString()); + var definition = layout.getAsJsonArray("definitions").asList().stream() + .map(value -> value.getAsJsonObject()) + .filter(value -> value.get("name").getAsString().equals("SPACES_AND_COMMENTS")) + .findFirst().orElseThrow(); + String text = layout.get("text").getAsString(); + assertEquals("token SPACES_AND_COMMENTS ::= SPACES | LINE_COMMENT | BLOCK_COMMENT;", + text.substring(text.offsetByCodePoints(0, definition.get("start").getAsInt()), + text.offsetByCodePoints(0, definition.get("end").getAsInt()))); + for (var value : snapshot.getAsJsonArray("whitespace")) + assertEquals("layout.SPACES_AND_COMMENTS", value.getAsJsonObject().get("policy").getAsString()); + } + + @Test public void realP4KeepsPolicyScopesCursorsAstAndOwnedSourcePositions() throws Exception { + Path old = temporary.newFolder("old-layout").toPath().resolve("tinyexpression-p4.ubnf"); + Path source = Path.of("tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf"); + Process adapter = new ProcessBuilder("python3", "scripts/prepare-ubnfc-layout.py", + source.toString(), old.toString()).inheritIO().start(); + assertEquals(0, adapter.waitFor()); + Files.writeString(old, Files.readString(old).replace( + "@package: org.unlaxer.tinyexpression.generated.p4", "@package: org.example.ruletrivia")); + var grammar = UBNFModuleLoader.load(old).grammars().get(0); + var rows = JsonParser.parseString(Files.readString(Path.of( + "src/test/resources/p4-layout/actual-p4.json"))).getAsJsonArray(); + try (var loader = compileJava(grammar)) { + Class oldParsers = loader.loadClass("org.example.ruletrivia.TinyExpressionP4Parsers"); + Parser oldRoot = (Parser) oldParsers.getMethod("getRootParser").invoke(null); + Class oldMapper = loader.loadClass("org.example.ruletrivia.TinyExpressionP4Mapper"); + for (var element : rows) { + var row = element.getAsJsonObject(); + String id = row.get("id").getAsString(), input = row.get("input").getAsString(); + var oracle = row.get("prefix"); + assertEquals(id + " old independent cursor oracle", oracle, prefix(oldRoot, input)); + assertEquals(id + " new independent cursor oracle", oracle, + prefix(TinyExpressionP4Parsers.getRootParser(), input)); + var production = TinyExpressionP4Parser.parseEntry("TinyExpressionP4", "Formula", input, + new ParseOptions(true, false, false, true, Map.of())); + assertEquals(id + " production ubnfc acceptance", row.get("ok").getAsBoolean(), production.ok()); + assertEquals(id + " production consumed", oracle.getAsJsonArray().get(1).getAsInt(), production.consumedCp()); + assertEquals(id + " production matched", oracle.getAsJsonArray().get(2).getAsInt(), production.matchedCp()); + if (row.get("ok").getAsBoolean()) { + Object oldMapped = oldMapper.getMethod("parseWithSourceMap", String.class).invoke(null, input); + Object oldAst = oldMapped.getClass().getMethod("ast").invoke(oldMapped); + var mapped = TinyExpressionP4Mapper.parseWithSourceMap(input); + assertEquals(id + " every AST field and node span", canonical(oldAst, oldMapped), + canonical(mapped.ast(), mapped)); + var owned = P4PreferredAstMapper.parseByAstSimpleNamesDetailed(input, + java.util.List.of("FormulaExpr"), 0L); + assertEquals(id + " source-preserving compatibility mask", + org.unlaxer.tinyexpression.parser.TinyExpressionParserCapabilities.stripJavaStyleCommentsPreservingLayout(input), + owned.sourceText().text(owned.ast())); + assertArrayEquals(id + " source-preserving position", new int[]{0, input.codePointCount(0, input.length())}, + owned.sourceText().spanOf(owned.ast()).orElseThrow()); + } + } + } + } +} diff --git a/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutTestSupport.java b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutTestSupport.java new file mode 100644 index 00000000..71f5d8cf --- /dev/null +++ b/src/test/java/org/unlaxer/tinyexpression/p4/P4LayoutTestSupport.java @@ -0,0 +1,123 @@ +package org.unlaxer.tinyexpression.p4; + +import org.unlaxer.dsl.codegen.*; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.unlaxer.context.ParseContext; +import org.unlaxer.dsl.bootstrap.UBNFAST.GrammarDecl; +import org.unlaxer.dsl.bootstrap.UBNFMapper; +import org.unlaxer.dsl.codegen.CodeGenerator.GeneratedSource; +import org.unlaxer.dsl.codegen.rust.RustBackend; +import org.unlaxer.parser.Parser; + +abstract class P4LayoutTestSupport { + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + protected URLClassLoader compileJava(GrammarDecl grammar) throws Exception { + var sources = new ArrayList(); + for (CodeGenerator generator : List.of( + new ParserGenerator(), new ASTGenerator(), new MapperGenerator())) { + sources.add(generator.generate(grammar)); + } + var units = sources.stream().map(source -> new SimpleJavaFileObject( + URI.create("string:///" + source.packageName().replace('.', '/') + "/" + + source.className() + ".java"), JavaFileObject.Kind.SOURCE) { + @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return source.source(); + } + }).toList(); + var compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull("tests require a JDK, not a JRE", compiler); + var diagnostics = new DiagnosticCollector(); + Path output = temporary.newFolder().toPath(); + try (var manager = compiler.getStandardFileManager(diagnostics, Locale.ROOT, + StandardCharsets.UTF_8)) { + boolean compiled = compiler.getTask(null, manager, diagnostics, + List.of("--release", "17", "-sourcepath", "", "-implicit:none", "-classpath", + System.getProperty("java.class.path"), "-d", output.toString()), + null, units).call(); + assertTrue(diagnostics.getDiagnostics().toString(), compiled); + } + return new URLClassLoader(new URL[]{output.toUri().toURL()}, getClass().getClassLoader()); + } + + protected JsonArray prefix(Parser parser, String input) throws Exception { + JsonArray expected = null; + for (boolean memo : new boolean[]{false, true}) { + var result = new JsonArray(); + try (var context = new ParseContext(org.unlaxer.StringSource.createRootSource(input))) { + if (memo) context.enableMemoize(); + var parsed = parser.parse(context); + result.add(parsed.isSucceeded()); + if (parsed.isSucceeded()) { + int end = context.getConsumedPosition().value(); + assertEquals("retained CST source at consumed code-point position", + input.substring(0, input.offsetByCodePoints(0, end)), + parsed.getConsumed().source.sourceAsString()); + } + result.add(context.getConsumedPosition().value()); + result.add(context.getMatchedPosition().value()); + } + if (expected == null) expected = result; + else assertEquals("Java memo OFF/ON prefix parity: " + input, expected, result); + } + return expected; + } + + protected JsonObject canonical(Object ast, Object mapped) throws Exception { + var result = new JsonObject(); + result.addProperty("type", ast.getClass().getSimpleName()); + int[] span = (int[]) ((Optional) mapped.getClass().getMethod("sourceSpanOf", Object.class) + .invoke(mapped, ast)).orElseThrow(); + var position = new JsonArray(); + position.add(span[0]); + position.add(span[1]); + result.add("span", position); + var fields = new JsonObject(); + for (var component : ast.getClass().getRecordComponents()) { + fields.add(component.getName(), canonicalValue(component.getAccessor().invoke(ast), mapped)); + } + result.add("fields", fields); + return result; + } + + private JsonElement canonicalValue(Object value, Object mapped) throws Exception { + if (value == null) return JsonNull.INSTANCE; + if (value instanceof String text) return new JsonPrimitive(text); + if (value instanceof Optional optional) return canonicalValue(optional.orElse(null), mapped); + if (value instanceof List list) { + var result = new JsonArray(); + for (Object item : list) result.add(canonicalValue(item, mapped)); + return result; + } + return canonical(value, mapped); + } + +} diff --git a/src/test/resources/p4-layout/actual-p4.json b/src/test/resources/p4-layout/actual-p4.json new file mode 100644 index 00000000..58fa2b3a --- /dev/null +++ b/src/test/resources/p4-layout/actual-p4.json @@ -0,0 +1,156 @@ +[ + { + "id": "plain", + "input": "1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 1, + 1 + ] + }, + { + "id": "spaces", + "input": " 1 ", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 3, + 3 + ] + }, + { + "id": "tab", + "input": "\t1\t", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 3, + 3 + ] + }, + { + "id": "lf", + "input": "\n1\n", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 3, + 3 + ] + }, + { + "id": "crlf", + "input": "\r\n1\r\n", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 5, + 5 + ] + }, + { + "id": "line-lf", + "input": "//😀\n1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 5, + 5 + ] + }, + { + "id": "line-crlf", + "input": "//😀\r\n1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 6, + 6 + ] + }, + { + "id": "block", + "input": "/*😀*/1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 6, + 6 + ] + }, + { + "id": "string", + "input": "' /* // 😀 */ '", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 14, + 14 + ] + }, + { + "id": "code", + "input": "````java:X\n//😀\n/*x*/\n````\n1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 27, + 27 + ] + }, + { + "id": "import", + "input": "import a.B as b;\n1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 18, + 18 + ] + }, + { + "id": "declaration", + "input": "var $n as number;\n1", + "entry": "Formula", + "ok": true, + "prefix": [ + true, + 19, + 19 + ] + }, + { + "id": "unclosed-block", + "input": "/*unclosed", + "entry": "Formula", + "ok": false, + "prefix": [ + false, + 0, + 0 + ] + }, + { + "id": "unclosed-string", + "input": "'/*x", + "entry": "Formula", + "ok": false, + "prefix": [ + false, + 0, + 0 + ] + } +] diff --git a/src/test/resources/p4-layout/corpus.json b/src/test/resources/p4-layout/corpus.json new file mode 100644 index 00000000..ede2792a --- /dev/null +++ b/src/test/resources/p4-layout/corpus.json @@ -0,0 +1,615 @@ +[ + { + "name": "p4-standard-layout", + "grammar": "grammar LayoutProbe { @import layout from 'pkg:std/layout' @import strings from 'strings.ubnf' @import fences from 'fences.ubnf' @ubnf: v2 @package: org.example.ruletrivia @whitespace: layout.SPACES_AND_COMMENTS token VALUE ::= '1' | strings.STRING | fences.LONG_CODE_BLOCK; @root @mapping(Root,params=[value]) @whitespace(layout.SPACES_AND_COMMENTS) Root ::= VALUE @value; }", + "modules": { + "identifiers.ubnf": "grammar Identifiers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n token IDENTIFIER ::= chars.LETTER (chars.LETTER | chars.DIGIT)*;\n token CLASS_NAME ::= IDENTIFIER ('.' IDENTIFIER)*;\n}\n", + "numbers.ubnf": "grammar Numbers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n // Optional sign, integer/fraction, optional complete exponent. No implicit trivia.\n token NUMBER ::= ['+' | '-']\n (chars.DIGIT+ '.' chars.DIGIT* | '.' chars.DIGIT+ | chars.DIGIT+)\n [('e' | 'E') ['+' | '-'] chars.DIGIT+];\n}\n", + "fences.ubnf": "grammar Fences {\n @import chars from 'characters.ubnf'\n @import names from 'identifiers.ubnf'\n @ubnf: v2\n // The legacy three-backtick form ends at its first triple-backtick sequence.\n token CODE_START ::= BOL '```' names.IDENTIFIER ':' names.CLASS_NAME (chars.NEWLINE | EOF);\n token CODE_BODY ::= { NEGATIVE_LOOKAHEAD('```') ANY } LOOKAHEAD('```');\n token CODE_END ::= BOL '```' (chars.NEWLINE | EOF);\n // Four or more backticks: closing width must equal the captured opening width.\n // A shorter/longer fence or a non-standalone line remains opaque body text.\n token LONG_CODE_BLOCK ::= BOL CAPTURE(fence, '`'{4,}) names.IDENTIFIER ':' names.CLASS_NAME chars.NEWLINE\n { NEGATIVE_LOOKAHEAD(BOL SAME_AS(fence) EOL) ANY }\n BOL SAME_AS(fence) (chars.NEWLINE | EOF);\n}\n", + "strings.ubnf": "grammar Strings {\n @ubnf: v2\n // Quotes and escape pairs are raw source, including newlines and non-BMP scalars.\n // AST normalization belongs to the consumer, not this recognizer.\n token STRING ::= '\"' { '\\\\' ANY | NEGATION('\"\\\\') } '\"'\n | '\\'' { '\\\\' ANY | NEGATION('\\'\\\\') } '\\'';\n}\n", + "characters.ubnf": "grammar Characters {\n @ubnf: v2\n token DIGIT ::= CHAR_RANGE('0', '9');\n token LETTER ::= CHAR_RANGE('a', 'z') | CHAR_RANGE('A', 'Z') | '_';\n token NEWLINE ::= '\\r\\n' | '\\r' | '\\n';\n}\n" + }, + "manifest": { + "schemaVersion": 1, + "dependencies": { + "std/layout": { + "version": "1.0.0", + "source": "builtin:std/layout@1.0.0" + } + } + }, + "cases": [ + { + "id": "plain", + "input": "1", + "prefix": [ + true, + 1, + 1 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 1 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "spaces", + "input": " 1 ", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "tab", + "input": "\t1\t", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "lf", + "input": "\n1\n", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "crlf", + "input": "\r\n1\r\n", + "prefix": [ + true, + 5, + 5 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 5 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "line-lf", + "input": "//😀\n1", + "prefix": [ + true, + 5, + 5 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 5 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "line-crlf", + "input": "//😀\r\n1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "block", + "input": "/*😀*/1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "first-block-end", + "input": "/*x*/1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "string", + "input": "' /* // 😀 */ '", + "prefix": [ + true, + 14, + 14 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 14 + ], + "fields": { + "value": " /* // 😀 */ " + } + } + }, + { + "id": "long-code", + "input": "````java:X\n//😀\n/*x*/\n````", + "prefix": [ + true, + 25, + 25 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 25 + ], + "fields": { + "value": "````java:X\n//😀\n/*x*/\n````" + } + } + }, + { + "id": "unclosed-block", + "input": "1/*x", + "prefix": [ + true, + 1, + 1 + ], + "accepted": false, + "diagnostic": [ + "trailing_input", + 1 + ] + } + ] + }, + { + "name": "p4-java-style-baseline", + "grammar": "grammar LayoutProbe { @import strings from 'strings.ubnf' @import fences from 'fences.ubnf' @ubnf: v2 @package: org.example.ruletrivia @whitespace: javaStyle token VALUE ::= '1' | strings.STRING | fences.LONG_CODE_BLOCK; @root @mapping(Root,params=[value]) @whitespace(javaStyle) Root ::= VALUE @value; }", + "modules": { + "identifiers.ubnf": "grammar Identifiers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n token IDENTIFIER ::= chars.LETTER (chars.LETTER | chars.DIGIT)*;\n token CLASS_NAME ::= IDENTIFIER ('.' IDENTIFIER)*;\n}\n", + "numbers.ubnf": "grammar Numbers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n // Optional sign, integer/fraction, optional complete exponent. No implicit trivia.\n token NUMBER ::= ['+' | '-']\n (chars.DIGIT+ '.' chars.DIGIT* | '.' chars.DIGIT+ | chars.DIGIT+)\n [('e' | 'E') ['+' | '-'] chars.DIGIT+];\n}\n", + "fences.ubnf": "grammar Fences {\n @import chars from 'characters.ubnf'\n @import names from 'identifiers.ubnf'\n @ubnf: v2\n // The legacy three-backtick form ends at its first triple-backtick sequence.\n token CODE_START ::= BOL '```' names.IDENTIFIER ':' names.CLASS_NAME (chars.NEWLINE | EOF);\n token CODE_BODY ::= { NEGATIVE_LOOKAHEAD('```') ANY } LOOKAHEAD('```');\n token CODE_END ::= BOL '```' (chars.NEWLINE | EOF);\n // Four or more backticks: closing width must equal the captured opening width.\n // A shorter/longer fence or a non-standalone line remains opaque body text.\n token LONG_CODE_BLOCK ::= BOL CAPTURE(fence, '`'{4,}) names.IDENTIFIER ':' names.CLASS_NAME chars.NEWLINE\n { NEGATIVE_LOOKAHEAD(BOL SAME_AS(fence) EOL) ANY }\n BOL SAME_AS(fence) (chars.NEWLINE | EOF);\n}\n", + "strings.ubnf": "grammar Strings {\n @ubnf: v2\n // Quotes and escape pairs are raw source, including newlines and non-BMP scalars.\n // AST normalization belongs to the consumer, not this recognizer.\n token STRING ::= '\"' { '\\\\' ANY | NEGATION('\"\\\\') } '\"'\n | '\\'' { '\\\\' ANY | NEGATION('\\'\\\\') } '\\'';\n}\n", + "characters.ubnf": "grammar Characters {\n @ubnf: v2\n token DIGIT ::= CHAR_RANGE('0', '9');\n token LETTER ::= CHAR_RANGE('a', 'z') | CHAR_RANGE('A', 'Z') | '_';\n token NEWLINE ::= '\\r\\n' | '\\r' | '\\n';\n}\n" + }, + "cases": [ + { + "id": "plain", + "input": "1", + "prefix": [ + true, + 1, + 1 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 1 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "spaces", + "input": " 1 ", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "tab", + "input": "\t1\t", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "lf", + "input": "\n1\n", + "prefix": [ + true, + 3, + 3 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 3 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "crlf", + "input": "\r\n1\r\n", + "prefix": [ + true, + 5, + 5 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 5 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "line-lf", + "input": "//😀\n1", + "prefix": [ + true, + 5, + 5 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 5 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "line-crlf", + "input": "//😀\r\n1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "block", + "input": "/*😀*/1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "first-block-end", + "input": "/*x*/1", + "prefix": [ + true, + 6, + 6 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 6 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "string", + "input": "' /* // 😀 */ '", + "prefix": [ + true, + 14, + 14 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 14 + ], + "fields": { + "value": " /* // 😀 */ " + } + } + }, + { + "id": "long-code", + "input": "````java:X\n//😀\n/*x*/\n````", + "prefix": [ + true, + 25, + 25 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 25 + ], + "fields": { + "value": "````java:X\n//😀\n/*x*/\n````" + } + } + }, + { + "id": "unclosed-block", + "input": "1/*x", + "prefix": [ + true, + 1, + 1 + ], + "accepted": false, + "diagnostic": [ + "trailing_input", + 1 + ] + } + ] + }, + { + "name": "p4-explicit-none", + "grammar": "grammar LayoutProbe { @import layout from 'pkg:std/layout' @import strings from 'strings.ubnf' @import fences from 'fences.ubnf' @ubnf: v2 @package: org.example.ruletrivia @whitespace: layout.SPACES_AND_COMMENTS token VALUE ::= '1' | strings.STRING | fences.LONG_CODE_BLOCK; @root @mapping(Root,params=[value]) @whitespace(none) Root ::= VALUE @value; }", + "modules": { + "identifiers.ubnf": "grammar Identifiers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n token IDENTIFIER ::= chars.LETTER (chars.LETTER | chars.DIGIT)*;\n token CLASS_NAME ::= IDENTIFIER ('.' IDENTIFIER)*;\n}\n", + "numbers.ubnf": "grammar Numbers {\n @import chars from 'characters.ubnf'\n @ubnf: v2\n // Optional sign, integer/fraction, optional complete exponent. No implicit trivia.\n token NUMBER ::= ['+' | '-']\n (chars.DIGIT+ '.' chars.DIGIT* | '.' chars.DIGIT+ | chars.DIGIT+)\n [('e' | 'E') ['+' | '-'] chars.DIGIT+];\n}\n", + "fences.ubnf": "grammar Fences {\n @import chars from 'characters.ubnf'\n @import names from 'identifiers.ubnf'\n @ubnf: v2\n // The legacy three-backtick form ends at its first triple-backtick sequence.\n token CODE_START ::= BOL '```' names.IDENTIFIER ':' names.CLASS_NAME (chars.NEWLINE | EOF);\n token CODE_BODY ::= { NEGATIVE_LOOKAHEAD('```') ANY } LOOKAHEAD('```');\n token CODE_END ::= BOL '```' (chars.NEWLINE | EOF);\n // Four or more backticks: closing width must equal the captured opening width.\n // A shorter/longer fence or a non-standalone line remains opaque body text.\n token LONG_CODE_BLOCK ::= BOL CAPTURE(fence, '`'{4,}) names.IDENTIFIER ':' names.CLASS_NAME chars.NEWLINE\n { NEGATIVE_LOOKAHEAD(BOL SAME_AS(fence) EOL) ANY }\n BOL SAME_AS(fence) (chars.NEWLINE | EOF);\n}\n", + "strings.ubnf": "grammar Strings {\n @ubnf: v2\n // Quotes and escape pairs are raw source, including newlines and non-BMP scalars.\n // AST normalization belongs to the consumer, not this recognizer.\n token STRING ::= '\"' { '\\\\' ANY | NEGATION('\"\\\\') } '\"'\n | '\\'' { '\\\\' ANY | NEGATION('\\'\\\\') } '\\'';\n}\n", + "characters.ubnf": "grammar Characters {\n @ubnf: v2\n token DIGIT ::= CHAR_RANGE('0', '9');\n token LETTER ::= CHAR_RANGE('a', 'z') | CHAR_RANGE('A', 'Z') | '_';\n token NEWLINE ::= '\\r\\n' | '\\r' | '\\n';\n}\n" + }, + "manifest": { + "schemaVersion": 1, + "dependencies": { + "std/layout": { + "version": "1.0.0", + "source": "builtin:std/layout@1.0.0" + } + } + }, + "cases": [ + { + "id": "plain", + "input": "1", + "prefix": [ + true, + 1, + 1 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 1 + ], + "fields": { + "value": "1" + } + } + }, + { + "id": "string", + "input": "' /* // 😀 */ '", + "prefix": [ + true, + 14, + 14 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 14 + ], + "fields": { + "value": " /* // 😀 */ " + } + } + }, + { + "id": "long-code", + "input": "````java:X\n//😀\n/*x*/\n````", + "prefix": [ + true, + 25, + 25 + ], + "accepted": true, + "ast": { + "type": "Root", + "span": [ + 0, + 25 + ], + "fields": { + "value": "````java:X\n//😀\n/*x*/\n````" + } + } + }, + { + "id": "none-does-not-consume-space", + "input": "1 ", + "prefix": [ + true, + 1, + 1 + ], + "accepted": false, + "diagnostic": [ + "trailing_input", + 1 + ] + }, + { + "id": "none-does-not-consume-comment", + "input": "1/*x*/", + "prefix": [ + true, + 1, + 1 + ], + "accepted": false, + "diagnostic": [ + "trailing_input", + 1 + ] + } + ] + } +] diff --git a/tools/tinyexpression-p4-lsp-vscode/grammar/.ubnf-cache/packages/2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67.json b/tools/tinyexpression-p4-lsp-vscode/grammar/.ubnf-cache/packages/2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67.json new file mode 100644 index 00000000..1ea437af --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/grammar/.ubnf-cache/packages/2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "id": "std/layout", + "version": "1.0.0", + "entry": "layout.ubnf", + "files": { + "layout.ubnf": "grammar StandardLayout {\n @ubnf: v2\n token SPACES ::= (' ' | CHAR_RANGE('\\t', '\\r'))+;\n token LINE_COMMENT ::= '//' NEGATION('\\r\\n')*;\n token BLOCK_COMMENT ::= '/*' (NEGATIVE_LOOKAHEAD('*/') ANY)* '*/';\n token SPACES_AND_COMMENTS ::= SPACES | LINE_COMMENT | BLOCK_COMMENT;\n}\n" + }, + "dependencies": {} +} diff --git a/tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf b/tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf index 0aa89e9e..03515833 100644 --- a/tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf +++ b/tools/tinyexpression-p4-lsp-vscode/grammar/tinyexpression-p4.ubnf @@ -1,5 +1,7 @@ grammar TinyExpressionP4 { + @import layout from 'pkg:std/layout' + @import numbers from 'lexical/numbers.ubnf' @import names from 'lexical/identifiers.ubnf' @import strings from 'lexical/strings.ubnf' @@ -9,7 +11,7 @@ grammar TinyExpressionP4 { @ubnf: v2 @feature: declarativeTokensV1 @package: org.unlaxer.tinyexpression.generated.p4 - @whitespace: javaStyle + @whitespace: layout.SPACES_AND_COMMENTS // Preserve P4's existing literal matching while enabling declarative token syntax. @literalBoundary: none @@ -23,7 +25,7 @@ grammar TinyExpressionP4 { token EOF ::= EOF; @root - @interleave(profile=javaStyle) + @whitespace(layout.SPACES_AND_COMMENTS) @scopeTree(mode=lexical) @mapping(FormulaExpr, params=[imports, declarations, expression, methods, codeBlocks]) Formula ::= { CodeBlock @codeBlocks } { ImportDeclaration @imports } { VariableDeclaration @declarations } @@ -44,7 +46,7 @@ grammar TinyExpressionP4 { // import sample.v1.CheckAlphabets#check as checkAlphabets; (with method) // import sample.v1.CheckAlphabets as checkAlphabets; (bare) - @interleave(profile=javaStyle) + @whitespace(layout.SPACES_AND_COMMENTS) @mapping(ImportDeclarationExpr, params=[className, method, alias]) ImportDeclaration ::= 'import' ClassName @className [ '#' IDENTIFIER @method ] 'as' IDENTIFIER @alias ';' ; @@ -53,7 +55,7 @@ grammar TinyExpressionP4 { // ── Variable declarations ────────────────────────────────────────────────── - @interleave(profile=javaStyle) + @whitespace(layout.SPACES_AND_COMMENTS) VariableDeclaration ::= NumberVariableDeclaration | StringVariableDeclaration diff --git a/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.json b/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.json new file mode 100644 index 00000000..a2e13928 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "dependencies": { + "std/layout": { + "version": "1.0.0", + "source": "builtin:std/layout@1.0.0" + } + } +} diff --git a/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.lock.json b/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.lock.json new file mode 100644 index 00000000..0fba1954 --- /dev/null +++ b/tools/tinyexpression-p4-lsp-vscode/grammar/ubnf.lock.json @@ -0,0 +1 @@ +{"packages":{"std/layout":{"dependencies":{},"id":"std/layout","sha256":"2701f6884ea6c712d28c76ec3d340c3b27fca4575846239bd0854933205f4b67","version":"1.0.0"}},"roots":{"std/layout":"1.0.0"},"schemaVersion":1} diff --git a/tools/tinyexpression-p4-lsp-vscode/pom.xml b/tools/tinyexpression-p4-lsp-vscode/pom.xml index 34ab59aa..cb834ed5 100644 --- a/tools/tinyexpression-p4-lsp-vscode/pom.xml +++ b/tools/tinyexpression-p4-lsp-vscode/pom.xml @@ -15,7 +15,7 @@ UTF-8 0.23.1 - 3.2.0-SNAPSHOT + 3.3.0-SNAPSHOT ${project.build.directory}/generated-sources/tinyexpression-p4 ${project.basedir}/server-dist ${project.build.directory}/tinyexpression-p4-lsp-${project.version}.vsix