diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21535716..7b43ba58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,9 @@ name: CI on: push: - branches: [master] + # Feature branches included so work that cannot be compiled in the authoring environment + # gets a real build run before it reaches a pull request. + branches: [master, 'claude/**'] paths-ignore: - '**.md' - '.vscode/**' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..b6877a30 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,173 @@ +name: Direct XAML Compiler + +on: + push: + # Feature branches are included deliberately: this workflow exists to verify code that + # cannot be compiled in the authoring environment, so it has to run before the PR stage. + branches: [master, 'claude/**'] + paths: + - 'compiler/**' + - 'dotnet/build/DirectXaml.*' + - 'dotnet/src/Easydict.WinUI/Views/Controls/MinimalServiceResultItem.xaml' + - '.github/workflows/rust.yml' + pull_request: + branches: [master] + paths: + - 'compiler/**' + - 'dotnet/build/DirectXaml.*' + - 'dotnet/src/Easydict.WinUI/Views/Controls/MinimalServiceResultItem.xaml' + - '.github/workflows/rust.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + compiler: + name: fmt, clippy, test + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: compiler + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + run: rustup toolchain install stable --profile minimal --component rustfmt,clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + compiler/target + key: ${{ runner.os }}-cargo-${{ hashFiles('compiler/**/Cargo.toml') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all-targets --all-features + + - name: Test + run: cargo test --all-features + + # `cargo test` writes the golden on first run. Publishing it — together with the IR compiled + # from the shipping card — is how an environment that cannot build the compiler still obtains + # the artifacts it has to check in. + - name: Compile the shipping card + if: always() + run: | + cargo run -p dxaml-cli -- compile \ + --input ../dotnet/src/Easydict.WinUI/Views/Controls/MinimalServiceResultItem.xaml \ + --output ./out || true + + - name: Upload compiled IR + if: always() + uses: actions/upload-artifact@v4 + with: + name: direct-xaml-ir + path: | + compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json + compiler/out/MinimalServiceResultItem.dxir.json + compiler/out/MinimalServiceResultItem.bindings.g.cs + if-no-files-found: warn + + # Drift check. `cargo test` creates the golden when it is absent, so an untracked file only + # means it has not been committed yet — failing on that would deadlock anyone who cannot run + # the compiler locally, since CI is the only place the file can be produced. What must fail + # is a *tracked* golden that no longer matches what the compiler emits. + - name: Verify the IR golden is current + run: | + golden=crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json + status="$(git status --porcelain -- "$golden")" + case "$status" in + "") + echo "IR golden is committed and current." ;; + '??'*) + echo "::warning file=compiler/$golden::IR golden is not committed yet. Download it from the direct-xaml-ir artifact and commit it to enable regression checking." ;; + *) + echo "::error file=compiler/$golden::IR golden is stale. Run 'cargo test' locally and commit the result." + git --no-pager diff -- "$golden" + exit 1 ;; + esac + + - name: Compile the shipping card end to end + run: | + cargo run -p dxaml-cli -- compile \ + --input ../dotnet/src/Easydict.WinUI/Views/Controls/MinimalServiceResultItem.xaml \ + --output "$RUNNER_TEMP/dxir" + cat "$RUNNER_TEMP/dxir/MinimalServiceResultItem.dxir.json" + + # The full card is deliberately outside v0; if it ever compiles, the subset has drifted. + - name: Confirm the full card is still rejected + run: | + if cargo run -p dxaml-cli -- compile \ + --input ../dotnet/src/Easydict.WinUI/Views/Controls/ServiceResultItem.xaml \ + --check; then + echo "::error::ServiceResultItem.xaml compiled under Direct XAML v0, which the spec says it must not." + exit 1 + fi + + compiler-tools: + name: compiler tool (${{ matrix.rid }}) + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + rid: win-x64 + vs_arch: x64 + - target: aarch64-pc-windows-msvc + rid: win-arm64 + vs_arch: arm64 + defaults: + run: + working-directory: compiler + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust target + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup target add ${{ matrix.target }} + + - name: Build compiler + # The runner's ordinary PATH can resolve Git for Windows' GNU `link.exe`. + # Enter the matching MSVC developer shell so cargo sees the actual linker and + # architecture-specific Windows SDK / CRT libraries. + shell: cmd + run: | + set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + for /f "usebackq delims=" %%I in (`"%VSWHERE%" -latest -products * -property installationPath`) do set "VSINSTALL=%%I" + if not defined VSINSTALL exit /b 1 + call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -arch=${{ matrix.vs_arch }} -host_arch=x64 -no_logo + cargo build --release -p dxaml-cli --target ${{ matrix.target }} + + - name: Stage compiler + shell: pwsh + run: | + $destination = Join-Path $env:RUNNER_TEMP '${{ matrix.rid }}' + New-Item -ItemType Directory -Force $destination | Out-Null + Copy-Item 'target/${{ matrix.target }}/release/dxamlc.exe' $destination + + - name: Upload compiler + uses: actions/upload-artifact@v4 + with: + name: direct-xaml-compiler-${{ matrix.rid }} + path: ${{ runner.temp }}/${{ matrix.rid }}/dxamlc.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 857ca81b..f51f0261 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ tmp/ # Common build artifacts **/*.dll **/*.exe +!dotnet/build/tools/win-x64/dxamlc.exe +!dotnet/build/tools/win-arm64/dxamlc.exe **/*.pdb **/*.ilk **/*.obj @@ -50,6 +52,9 @@ project.lock.json worker/node_modules/ worker/.wrangler/ +# Rust (Direct XAML compiler) +compiler/target/ + # macOS .DS_Store diff --git a/compiler/.gitattributes b/compiler/.gitattributes new file mode 100644 index 00000000..67d3a816 --- /dev/null +++ b/compiler/.gitattributes @@ -0,0 +1,3 @@ +# Fixtures and goldens are hashed and compared byte-for-byte. Pin them to LF so a Windows +# checkout with core.autocrlf=true produces the same content hash as a Linux one. +crates/dxaml-cli/tests/fixtures/** text eol=lf diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock new file mode 100644 index 00000000..3c695af8 --- /dev/null +++ b/compiler/Cargo.lock @@ -0,0 +1,819 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dxaml-ast" +version = "0.1.0" +dependencies = [ + "dxaml-schema", + "dxaml-syntax", +] + +[[package]] +name = "dxaml-cli" +version = "0.1.0" +dependencies = [ + "dxaml-codegen-csharp", + "dxaml-hir", + "dxaml-ir", + "dxaml-lower", + "dxaml-syntax", + "jsonschema", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "dxaml-codegen-csharp" +version = "0.1.0" +dependencies = [ + "dxaml-ir", +] + +[[package]] +name = "dxaml-hir" +version = "0.1.0" +dependencies = [ + "dxaml-ast", + "dxaml-schema", + "dxaml-syntax", +] + +[[package]] +name = "dxaml-ir" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "dxaml-lower" +version = "0.1.0" +dependencies = [ + "dxaml-hir", + "dxaml-ir", + "dxaml-schema", +] + +[[package]] +name = "dxaml-schema" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "dxaml-syntax" +version = "0.1.0" +dependencies = [ + "quick-xml", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "iso8601" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1" +dependencies = [ + "nom", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" +dependencies = [ + "ahash", + "anyhow", + "base64", + "bytecount", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/compiler/Cargo.toml b/compiler/Cargo.toml new file mode 100644 index 00000000..076258e2 --- /dev/null +++ b/compiler/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +resolver = "2" +members = [ + "crates/dxaml-syntax", + "crates/dxaml-ast", + "crates/dxaml-schema", + "crates/dxaml-hir", + "crates/dxaml-lower", + "crates/dxaml-ir", + "crates/dxaml-codegen-csharp", + "crates/dxaml-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-only" +repository = "https://github.com/xiaocang/easydict_win32" +rust-version = "1.75" + +[workspace.dependencies] +dxaml-syntax = { path = "crates/dxaml-syntax" } +dxaml-ast = { path = "crates/dxaml-ast" } +dxaml-schema = { path = "crates/dxaml-schema" } +dxaml-hir = { path = "crates/dxaml-hir" } +dxaml-lower = { path = "crates/dxaml-lower" } +dxaml-ir = { path = "crates/dxaml-ir" } +dxaml-codegen-csharp = { path = "crates/dxaml-codegen-csharp" } + +# quick-xml is used ONLY by dxaml-syntax/src/lexer.rs. It breaks API across minor +# versions, so the blast radius of a version bump is deliberately one file. +quick-xml = "0.37" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/compiler/README.md b/compiler/README.md new file mode 100644 index 00000000..9b9470bb --- /dev/null +++ b/compiler/README.md @@ -0,0 +1,194 @@ +# Direct XAML compiler (`dxamlc`) + +Compiles a strict subset of WinUI 3 XAML into backend-neutral JSON IR plus typed C# slot +accessors. The app loads the embedded IR and paints `MinimalServiceResultItem` cards through one +virtualized Win2D `CanvasVirtualControl` per results host, with the stock XAML card retained as the fallback backend. + +## Layout + +| Path | Contents | +|---|---| +| `spec/direct-xaml-v0.md` | The frozen v0 language contract. Start here. | +| `spec/compatibility.md` | What survives a move to a direct renderer, and what breaks. | +| `schemas/dxir-v0.schema.json` | Normative JSON Schema for the emitted IR. | +| `schemas/direct-xaml-v0.subset.json` | Machine-readable mirror of the accepted input surface. | +| `crates/dxaml-syntax` | XML lexing, CST, spans, diagnostics. The only crate using `quick-xml`. | +| `crates/dxaml-ast` | Namespace resolution, directives, property elements, markup extensions. | +| `crates/dxaml-schema` | The authoritative v0 control / property / enum tables. | +| `crates/dxaml-hir` | Typed, schema-checked nodes and parsed property values. | +| `crates/dxaml-lower` | HIR → IR, resource interning, invalidation classification. | +| `crates/dxaml-ir` | IR types, serialization, structural validator. | +| `crates/dxaml-cli` | The `dxamlc` driver and the end-to-end tests. | +| `crates/dxaml-codegen-csharp` | Typed C# accessors for the emitted named-slot contract. | + +## Build and test + +```bash +cd compiler +cargo fmt --all --check +cargo clippy --all-targets -- -D warnings +cargo test +``` + +Compile the shipping card and generate both artifacts: + +```bash +cargo run -p dxaml-cli -- compile \ + --input ../dotnet/src/Easydict.WinUI/Views/Controls/MinimalServiceResultItem.xaml \ + --output ./out \ + --source-root ../dotnet/src/Easydict.WinUI +``` + +That writes `out/MinimalServiceResultItem.dxir.json` and +`out/MinimalServiceResultItem.bindings.g.cs`. Normal app builds run the same command through +`dotnet/build/DirectXaml.targets`; the packaged native compiler under +`dotnet/build/tools/win-x64/` keeps the .NET build independent of an installed Rust toolchain. + +The full rich card is expected to **fail**, which is the subset working as designed: + +```bash +cargo run -p dxaml-cli -- compile \ + --input ../dotnet/src/Easydict.WinUI/Views/Controls/ServiceResultItem.xaml --check +``` + +## Design notes + +**The compiler is total.** Every construct is either in the v0 subset or produces a diagnostic. +Nothing is silently ignored, and a document either yields complete IR or none at all. That is what +makes the IR trustworthy enough to render from. + +**The IR carries no geometry and no colours.** Layout depends on window size and DPI; colours +depend on the active theme. `{ThemeResource}` compiles to a runtime slot, never a folded value, so +Light/Dark/HighContrast switching keeps working. Resolution on the C# side will reuse the existing +`Services/ThemeResourceService.cs`. + +**Generated accessors and typed bindings.** The shipping card uses `x:Name` plus imperative +code-behind, so `x:Name` compiles to a *named slot* carrying the mutable properties and their +invalidation. `dxaml-codegen-csharp` turns that contract into typed methods such as +`SetResultTextText(string?)`. Typed `x:Bind` also lowers to a schema-validated binding table: +`OneTime` applies during context assignment, while `OneWay` subscribes to +`INotifyPropertyChanged`, filters unrelated properties before dispatch, and detaches during +context teardown. The generated glue keeps all UI writes on the configured dispatcher. + +**`quick-xml` is quarantined.** It changes API across minor versions, so every call lives in +`crates/dxaml-syntax/src/lexer.rs`, using only `from_str`, `read_event`, `buffer_position` and the +core events, with catch-all match arms for variants added later. Spans are computed here rather +than taken from the library, which does not expose per-attribute positions. + +## Goldens + +`crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json` is a byte-exact regression +golden. It is created on first `cargo test` — review it and commit it. To accept an intended +change afterwards: + +```bash +UPDATE_GOLDEN=1 cargo test +``` + +The fixture `MinimalServiceResultItem.xaml` is a verbatim copy of the shipping card. If that card +changes, update the copy deliberately; the test suite is meant to notice. + +## Implemented vertical slice + +- strict XML/CST/AST/HIR/IR compiler with source diagnostics and deterministic goldens +- JSON Schema, runtime capability/version validation, and typed C# accessor generation +- incremental MSBuild generation before XAML/C# compilation +- managed layout, display-list generation, theme-resource re-resolution, and Win2D execution +- one tile-virtualized results surface with per-card named slots, pointer hit testing, and Copy action routing +- cold `DirectRenderer` switch in Minimal theme, with automatic stock-XAML fallback +- resize, device-loss, theme handling, and UI automation visual coverage + +## MVP benchmark gate + +The original per-card `CanvasControl` vertical slice answered the plan's first performance question, +and the result was **not** a reason to replace the stock card. A deterministic Debug/x64 run on +2026-08-01 at 200% DPI produced: + +| Metric | Direct | stock XAML | Result | +|---|---:|---:|---:| +| hosted `FrameworkElement` count per card | 3 | 13 | 77% fewer | +| first visible result, median of 3 | 2,340 ms | 1,883 ms | Direct 24% slower | +| process Private Bytes, median of 3 | 143.8 MiB | 127.1 MiB | app-process private commit +16.7 MiB; GPU/DWM not sampled | +| CPU for 120 paced text updates, median | 2,953 ms | 1,172 ms | Direct 2.5x higher | +| 20-card first visible result, one run | 4,709 ms | 2,175 ms | Direct 2.2x slower | + +The outer results `ScrollViewer` preserved a non-zero position through a viewport resize and kept +the twentieth card reachable. That was a correctness pass, not a performance win. + +## Shared-surface follow-up + +The per-card canvas has been replaced by one `CanvasVirtualControl` per result host. Each card owns +only its compiled view, layout/display-list cache, pointer router, and two transparent automation +peers; the surface owns the Win2D device, text-format cache, region-invalidated drawing, and tile +culling. Reordering changes card offsets without rebuilding a XAML item subtree. +Incremental text snapshots already arrive through `StreamingTextCoalescer` at 16 ms; the surface +adds no second timer. It then invalidates from the earliest changed card through the stable surface +extent, avoiding a repaint above it. Issued invalidation generations remain pending until their own +card is drawn, so an older draw cannot discard a later update. An extent change uses a full +invalidation because WinUI must first apply the new virtual-surface height. + + +`DirectRendererTests` passes its four current-app UI automation scenarios: painted-card resize, +Copy pointer routing, stock-XAML fallback baseline, and a twenty-card scroll/resize path. + +The Light-theme whole-app hotspot and memory runs are retained only as scenario smoke data. They +are not a backend comparison: Direct paints the compiled Minimal card while the non-Minimal XAML +branch creates the rich `ServiceResultItem`. + +## Reproducible matched renderer comparison + +`dotnet/scripts/memory/Invoke-RendererComparison.ps1` preserves the existing +`Invoke-PrMemoryGate.ps1` assertions and pairs alternating Direct/XAML runs with isolated +settings, a deterministic DEBUG-only result hook, per-PID/LUID GPU Process Memory capture, and +bounded process-CPU samples. It writes the memory-gate output plus `environment.json`, raw +`gpu-process-memory.csv`, renderer marker artifacts, phase snapshots, and +`comparison-summary.json` beneath the requested output directory. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + C:\repo\easydict_win32\dotnet\scripts\memory\Invoke-RendererComparison.ps1 ` + -RunsPerBackend 3 -CardCount 1 -InitialIdleSeconds 5 -PostCloseIdleSeconds 5 +``` + +The earlier 2026-08-01 Debug/x64 20-card run on the Intel integrated adapter completed three +alternating runs per backend with all GPU samples available. At +`07-translation-submitted`, its app-PID medians were: + +| Metric | Direct | Minimal XAML | Direct − XAML | +|---|---:|---:|---:| +| Process Private Bytes | 159.44 MiB | 141.16 MiB | +18.28 MiB | +| GPU Process Memory `Total Committed` | 120.05 MiB | 87.33 MiB | +32.73 MiB | +| GPU Process Memory `Shared Usage` | 152.93 MiB | 111.36 MiB | +41.57 MiB | +| GPU Process Memory `Local Usage` | 119.61 MiB | 88.24 MiB | +31.37 MiB | +| GPU Process Memory `Dedicated Usage` | 0 MiB | 0 MiB | 0 MiB | + +`Total Committed` is the primary app-GPU comparison. Do **not** add Dedicated, Shared, Local, +Non Local, and Total Committed: they are overlapping counter views, not independent memory pools. +The zero Dedicated value is specific to this integrated-GPU run. DWM is recorded separately as +compositor context and must not be attributed to either backend. + +### Critical-path telemetry + +The same comparison now writes a result-submission marker immediately before UI refresh. Direct +completes it after the target Win2D card draw returns; XAML completes it on its next +`CompositionTarget.Rendering` callback. It then executes 120 controlled text updates at 50-ms +intervals and restricts `\Process(...)\% Processor Time` to the marker-bounded streaming window. + +The later 2026-08-01 Debug/x64, one-card, three-runs-per-backend run produced three usable +observations for each backend: + +| Metric | Direct | Minimal XAML | Direct − XAML | +|---|---:|---:|---:| +| First renderer completion, median | 56.44 ms | 30.93 ms | +25.51 ms | +| Streaming process CPU, median of per-run medians | 66.97% | 22.94% | +44.02 percentage points | +| Controlled streaming duration, median | 7.38 s | 7.32 s | same 120 × 50-ms workload | + +This is a renderer-callback measure, not a compositor-present timestamp. The CPU counter is raw +process `% Processor Time`, not normalized to logical cores; its one-second sampling excludes +system-wide cost and sub-second scheduler variation. Nevertheless, both matched measurements favor +Minimal XAML; they reinforce the existing decision not to enable Direct by default. Repeat across +comparable hardware/workloads and measure scroll-frame stability before reopening that decision. + +The intentionally deferred work remains rich +`ServiceResultItem.xaml`, character-level text selection, arbitrary control templates, +virtualized accessibility peers, compiler watch/IR hot reload, and a native Rust Direct2D runtime. diff --git a/compiler/crates/dxaml-ast/Cargo.toml b/compiler/crates/dxaml-ast/Cargo.toml new file mode 100644 index 00000000..c5cb20aa --- /dev/null +++ b/compiler/crates/dxaml-ast/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "dxaml-ast" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "XAML abstract syntax tree: namespaces, directives, property elements and markup extensions." + +[dependencies] +dxaml-syntax.workspace = true +dxaml-schema.workspace = true diff --git a/compiler/crates/dxaml-ast/src/lib.rs b/compiler/crates/dxaml-ast/src/lib.rs new file mode 100644 index 00000000..10406bb4 --- /dev/null +++ b/compiler/crates/dxaml-ast/src/lib.rs @@ -0,0 +1,456 @@ +//! Turns the untyped CST into a XAML abstract syntax tree. +//! +//! This layer resolves XML namespaces and classifies each attribute as a directive, a property, +//! an attached property or a namespace declaration. It performs no schema validation — it does +//! not know whether `Border` exists or whether `Padding` is legal on it. That is `dxaml-hir`'s +//! job. What this layer guarantees is that every surviving node is in the presentation namespace +//! and that ignorable markup has been dropped. + +pub mod markup; + +use std::collections::{HashMap, HashSet}; + +use dxaml_schema as schema; +use dxaml_syntax::{codes, DiagnosticBag, ElementId, Span, SyntaxTree}; + +pub use markup::{AttributeValue, MarkupExtension}; + +#[derive(Debug, Clone)] +pub struct XamlDocument { + pub root: Option, + /// Value of the root's `x:Class` directive, if present. + pub class_name: Option, +} + +#[derive(Debug, Clone)] +pub struct XamlElement { + /// Local type name; the namespace has already been checked. + pub name: String, + pub span: Span, + pub name_span: Span, + pub directives: Vec, + pub properties: Vec, + pub children: Vec, + pub text: String, + pub text_span: Option, + /// Namespace aliases in scope on this element. `x:DataType` uses these to turn + /// `prefix:Type` into a C# type name without making the XML parser understand CLR types. + pub namespace_aliases: HashMap, +} + +impl XamlElement { + pub fn directive(&self, name: &str) -> Option<&XamlDirective> { + self.directives.iter().find(|d| d.name == name) + } + + /// Child elements, skipping property elements. + pub fn element_children(&self) -> impl Iterator { + self.children.iter().filter_map(|child| match child { + XamlChild::Element(element) => Some(element), + XamlChild::PropertyElement(_) => None, + }) + } +} + +/// An `x:`-prefixed attribute, such as `x:Class` or `x:Name`. +#[derive(Debug, Clone)] +pub struct XamlDirective { + pub name: String, + pub value: String, + pub span: Span, + pub name_span: Span, + pub value_span: Span, +} + +#[derive(Debug, Clone)] +pub struct XamlProperty { + /// `Some("Grid")` for an attached property such as `Grid.Row`. + pub owner: Option, + pub name: String, + pub value: AttributeValue, + pub span: Span, + pub name_span: Span, + pub value_span: Span, +} + +impl XamlProperty { + /// The name as written, for diagnostics. + pub fn as_written(&self) -> String { + match &self.owner { + Some(owner) => format!("{owner}.{}", self.name), + None => self.name.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub enum XamlChild { + Element(XamlElement), + PropertyElement(XamlPropertyElement), +} + +/// An `Owner.Property` child element, such as ``. +#[derive(Debug, Clone)] +pub struct XamlPropertyElement { + pub owner: String, + pub name: String, + pub span: Span, + pub name_span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, Default)] +struct Namespaces { + default: Option, + by_prefix: HashMap, + ignorable: HashSet, +} + +impl Namespaces { + fn uri_for(&self, prefix: &str) -> Option<&str> { + if prefix.is_empty() { + self.default.as_deref() + } else { + self.by_prefix.get(prefix).map(String::as_str) + } + } + + fn is_ignorable_uri(uri: &str) -> bool { + uri == schema::NS_BLEND || uri == schema::NS_MARKUP_COMPAT + } +} + +pub fn build(tree: &SyntaxTree, diagnostics: &mut DiagnosticBag) -> XamlDocument { + let root_id = match tree.root { + Some(root_id) => root_id, + None => { + return XamlDocument { + root: None, + class_name: None, + } + } + }; + + let root = build_element(tree, root_id, &Namespaces::default(), diagnostics); + let class_name = root + .as_ref() + .and_then(|element| element.directive("Class")) + .map(|directive| directive.value.clone()); + + XamlDocument { root, class_name } +} + +/// Returns `None` when the element belongs to an ignorable namespace and should be dropped. +fn build_element( + tree: &SyntaxTree, + id: ElementId, + inherited: &Namespaces, + diagnostics: &mut DiagnosticBag, +) -> Option { + let source = tree.get(id); + let namespaces = extend_namespaces(tree, id, inherited); + + let prefix = source.name.prefix_str(); + if namespaces.ignorable.contains(prefix) { + return None; + } + match namespaces.uri_for(prefix) { + Some(uri) if uri == schema::NS_PRESENTATION => {} + Some(uri) if Namespaces::is_ignorable_uri(uri) => return None, + Some(uri) => { + diagnostics.error( + codes::UNKNOWN_NAMESPACE, + format!( + "element '{}' is in namespace '{uri}'; Direct XAML v0 only accepts the presentation namespace", + source.name.as_written() + ), + source.name_span, + ); + return None; + } + None => { + diagnostics.error( + codes::UNKNOWN_NAMESPACE, + format!("undeclared namespace prefix '{prefix}'"), + source.name_span, + ); + return None; + } + } + + let mut directives = Vec::new(); + let mut properties = Vec::new(); + + for attribute in &source.attributes { + let prefix = attribute.name.prefix_str(); + let local = attribute.name.local.as_str(); + + // Namespace declarations were consumed by `extend_namespaces`. + if prefix == "xmlns" || (prefix.is_empty() && local == "xmlns") { + continue; + } + + if !prefix.is_empty() { + if namespaces.ignorable.contains(prefix) { + continue; + } + match namespaces.uri_for(prefix) { + Some(uri) if uri == schema::NS_DIRECTIVES => { + directives.push(XamlDirective { + name: local.to_string(), + value: attribute.value.clone(), + span: attribute.span, + name_span: attribute.name_span, + value_span: attribute.value_span, + }); + } + Some(uri) if Namespaces::is_ignorable_uri(uri) => {} + Some(uri) => diagnostics.error( + codes::UNKNOWN_NAMESPACE, + format!( + "attribute '{}' is in namespace '{uri}', which Direct XAML v0 does not understand", + attribute.name.as_written() + ), + attribute.name_span, + ), + None => diagnostics.error( + codes::UNKNOWN_NAMESPACE, + format!("undeclared namespace prefix '{prefix}'"), + attribute.name_span, + ), + } + continue; + } + + let (owner, name) = match local.split_once('.') { + Some((owner, name)) => (Some(owner.to_string()), name.to_string()), + None => (None, local.to_string()), + }; + + properties.push(XamlProperty { + owner, + name, + value: AttributeValue::classify(&attribute.value), + span: attribute.span, + name_span: attribute.name_span, + value_span: attribute.value_span, + }); + } + + let mut children = Vec::new(); + for &child_id in &source.children { + let child_source = tree.get(child_id); + if child_source.name.local.contains('.') { + if let Some(property_element) = + build_property_element(tree, child_id, &namespaces, diagnostics) + { + children.push(XamlChild::PropertyElement(property_element)); + } + } else if let Some(element) = build_element(tree, child_id, &namespaces, diagnostics) { + children.push(XamlChild::Element(element)); + } + } + + Some(XamlElement { + name: source.name.local.clone(), + span: source.span, + name_span: source.name_span, + directives, + properties, + children, + text: source.text.clone(), + text_span: source.text_span, + namespace_aliases: namespaces.by_prefix.clone(), + }) +} + +fn build_property_element( + tree: &SyntaxTree, + id: ElementId, + inherited: &Namespaces, + diagnostics: &mut DiagnosticBag, +) -> Option { + let source = tree.get(id); + let namespaces = extend_namespaces(tree, id, inherited); + + let prefix = source.name.prefix_str(); + if namespaces.ignorable.contains(prefix) { + return None; + } + if let Some(uri) = namespaces.uri_for(prefix) { + if Namespaces::is_ignorable_uri(uri) { + return None; + } + } + + let (owner, name) = source.name.local.split_once('.')?; + + let mut children = Vec::new(); + for &child_id in &source.children { + if let Some(element) = build_element(tree, child_id, &namespaces, diagnostics) { + children.push(element); + } + } + + Some(XamlPropertyElement { + owner: owner.to_string(), + name: name.to_string(), + span: source.span, + name_span: source.name_span, + children, + }) +} + +fn extend_namespaces(tree: &SyntaxTree, id: ElementId, inherited: &Namespaces) -> Namespaces { + let source = tree.get(id); + let mut namespaces = inherited.clone(); + + for attribute in &source.attributes { + let prefix = attribute.name.prefix_str(); + let local = attribute.name.local.as_str(); + + if prefix == "xmlns" { + namespaces + .by_prefix + .insert(local.to_string(), attribute.value.clone()); + } else if prefix.is_empty() && local == "xmlns" { + namespaces.default = Some(attribute.value.clone()); + } + } + + // `mc:Ignorable` can only be read once its own prefix is bound, hence the second pass. + for attribute in &source.attributes { + if attribute.name.local != "Ignorable" { + continue; + } + let prefix = attribute.name.prefix_str(); + if namespaces.uri_for(prefix) == Some(schema::NS_MARKUP_COMPAT) { + for ignorable in attribute.value.split_whitespace() { + namespaces.ignorable.insert(ignorable.to_string()); + } + } + } + + namespaces +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(source: &str) -> (XamlDocument, DiagnosticBag) { + let (tree, mut diagnostics) = dxaml_syntax::parse(source); + let document = build(&tree, &mut diagnostics); + (document, diagnostics) + } + + const HEADER: &str = concat!( + r#"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" "#, + r#"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" "#, + r#"xmlns:d="http://schemas.microsoft.com/expression/blend/2008" "#, + r#"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" "#, + r#"mc:Ignorable="d""# + ); + + #[test] + fn separates_directives_from_properties() { + let source = format!( + r#""# + ); + let (document, diagnostics) = parse(&source); + assert!(!diagnostics.has_errors(), "{:?}", diagnostics.sorted()); + + let root = document.root.expect("root"); + assert_eq!(root.name, "UserControl"); + assert_eq!(document.class_name.as_deref(), Some("A.B")); + + let border = root.element_children().next().expect("border"); + assert_eq!( + border.directive("Name").map(|d| d.value.as_str()), + Some("Root") + ); + assert_eq!(border.properties.len(), 1); + assert_eq!(border.properties[0].name, "Padding"); + } + + #[test] + fn splits_attached_properties() { + let source = format!(r#""#); + let (document, diagnostics) = parse(&source); + assert!(!diagnostics.has_errors(), "{:?}", diagnostics.sorted()); + + let border = document + .root + .expect("root") + .element_children() + .next() + .cloned() + .expect("border"); + let property = &border.properties[0]; + assert_eq!(property.owner.as_deref(), Some("Grid")); + assert_eq!(property.name, "Row"); + assert_eq!(property.as_written(), "Grid.Row"); + } + + #[test] + fn recognises_property_elements() { + let source = format!( + r#""# + ); + let (document, diagnostics) = parse(&source); + assert!(!diagnostics.has_errors(), "{:?}", diagnostics.sorted()); + + let grid = document + .root + .expect("root") + .element_children() + .next() + .cloned() + .expect("grid"); + match &grid.children[0] { + XamlChild::PropertyElement(property_element) => { + assert_eq!(property_element.owner, "Grid"); + assert_eq!(property_element.name, "RowDefinitions"); + assert_eq!(property_element.children.len(), 1); + assert_eq!(property_element.children[0].name, "RowDefinition"); + } + other => panic!("expected a property element, got {other:?}"), + } + } + + #[test] + fn drops_ignorable_markup() { + let source = format!( + r#""# + ); + let (document, diagnostics) = parse(&source); + assert!(!diagnostics.has_errors(), "{:?}", diagnostics.sorted()); + + let root = document.root.expect("root"); + assert!(root.properties.is_empty(), "d: attributes must be dropped"); + assert_eq!(root.children.len(), 1, "d: elements must be dropped"); + } + + #[test] + fn rejects_undeclared_prefixes() { + let source = format!(r#""#); + let (_, diagnostics) = parse(&source); + assert!(diagnostics + .iter() + .any(|d| d.code == codes::UNKNOWN_NAMESPACE)); + } + + #[test] + fn keeps_text_content() { + let source = format!(r#"hello"#); + let (document, _) = parse(&source); + let text = document + .root + .expect("root") + .element_children() + .next() + .cloned() + .expect("text"); + assert_eq!(text.text, "hello"); + } +} diff --git a/compiler/crates/dxaml-ast/src/markup.rs b/compiler/crates/dxaml-ast/src/markup.rs new file mode 100644 index 00000000..ff1f606c --- /dev/null +++ b/compiler/crates/dxaml-ast/src/markup.rs @@ -0,0 +1,135 @@ +/// A markup extension, split into its name and comma-separated arguments. +/// +/// v0 does not support nested extensions. Only `{ThemeResource Key}` and `{StaticResource Key}` +/// reach lowering, and neither nests, so a flat split on commas is sufficient — anything that +/// would need a real recursive parser is rejected as unsupported before the arguments matter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarkupExtension { + pub name: String, + pub arguments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttributeValue { + Literal(String), + Markup(MarkupExtension), +} + +impl AttributeValue { + /// Classifies a raw attribute value, honouring the `{}` escape for literals that begin + /// with a brace. + pub fn classify(raw: &str) -> Self { + if let Some(literal) = raw.strip_prefix("{}") { + return Self::Literal(literal.to_string()); + } + + let trimmed = raw.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') && trimmed.len() >= 2 { + if let Some(extension) = parse_extension(trimmed) { + return Self::Markup(extension); + } + } + + Self::Literal(raw.to_string()) + } + + pub fn as_literal(&self) -> Option<&str> { + match self { + Self::Literal(value) => Some(value), + Self::Markup(_) => None, + } + } +} + +fn parse_extension(raw: &str) -> Option { + let inner = raw + .strip_prefix('{') + .and_then(|rest| rest.strip_suffix('}'))? + .trim(); + if inner.is_empty() { + return None; + } + + let (name, rest) = match inner.find(char::is_whitespace) { + Some(index) => (&inner[..index], inner[index..].trim()), + None => (inner, ""), + }; + if name.is_empty() { + return None; + } + + let arguments = if rest.is_empty() { + Vec::new() + } else { + rest.split(',') + .map(|argument| argument.trim().to_string()) + .filter(|argument| !argument.is_empty()) + .collect() + }; + + Some(MarkupExtension { + name: name.to_string(), + arguments, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_theme_resource() { + let value = AttributeValue::classify("{ThemeResource CardStrokeColorDefaultBrush}"); + assert_eq!( + value, + AttributeValue::Markup(MarkupExtension { + name: "ThemeResource".to_string(), + arguments: vec!["CardStrokeColorDefaultBrush".to_string()], + }) + ); + } + + #[test] + fn parses_an_extension_without_arguments() { + let value = AttributeValue::classify("{x:Null}"); + assert_eq!( + value, + AttributeValue::Markup(MarkupExtension { + name: "x:Null".to_string(), + arguments: Vec::new(), + }) + ); + } + + #[test] + fn splits_multiple_arguments() { + let value = AttributeValue::classify("{Binding Path=Foo, Mode=TwoWay}"); + match value { + AttributeValue::Markup(extension) => { + assert_eq!(extension.name, "Binding"); + assert_eq!(extension.arguments, vec!["Path=Foo", "Mode=TwoWay"]); + } + other => panic!("expected markup extension, got {other:?}"), + } + } + + #[test] + fn honours_the_literal_escape() { + assert_eq!( + AttributeValue::classify("{}{not an extension}"), + AttributeValue::Literal("{not an extension}".to_string()) + ); + } + + #[test] + fn plain_values_stay_literal() { + assert_eq!( + AttributeValue::classify("12,0,4,0"), + AttributeValue::Literal("12,0,4,0".to_string()) + ); + assert_eq!( + AttributeValue::classify("{unterminated"), + AttributeValue::Literal("{unterminated".to_string()) + ); + } +} diff --git a/compiler/crates/dxaml-cli/Cargo.toml b/compiler/crates/dxaml-cli/Cargo.toml new file mode 100644 index 00000000..4129166c --- /dev/null +++ b/compiler/crates/dxaml-cli/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "dxaml-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "dxamlc: the Direct XAML compiler driver." + +[lib] +name = "dxaml_cli" +path = "src/lib.rs" + +[[bin]] +name = "dxamlc" +path = "src/main.rs" + +[dependencies] +dxaml-codegen-csharp.workspace = true +dxaml-hir.workspace = true +dxaml-ir.workspace = true +dxaml-lower.workspace = true +dxaml-syntax.workspace = true + +[dev-dependencies] +# ponytail: jsonschema's broad URL/UUID ranges otherwise resolve current releases above the +# workspace MSRV, so keep its validation-only dependency tree Rust 1.75-compatible. +jsonschema = { version = "0.17", default-features = false, features = ["draft202012"] } +serde_json.workspace = true +time = "=0.3.36" +url = "=2.4.1" +uuid = "=1.6.1" diff --git a/compiler/crates/dxaml-cli/src/lib.rs b/compiler/crates/dxaml-cli/src/lib.rs new file mode 100644 index 00000000..db21756c --- /dev/null +++ b/compiler/crates/dxaml-cli/src/lib.rs @@ -0,0 +1,94 @@ +//! Compiler driver: source text in, IR plus rendered diagnostics out. +//! +//! Kept separate from `main.rs` so tests can drive a compile without spawning a process. + +use dxaml_ir::IrDocument; +use dxaml_syntax::{codes, Diagnostic, LineIndex, Span}; + +pub const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub struct CompileResult { + /// `None` when compilation failed; a partial document is never returned. + pub document: Option, + /// Diagnostics already rendered in MSBuild's format, in source order. + pub diagnostics: Vec, + pub failed: bool, +} + +/// Compiles one document. `display_path` appears in diagnostics and in the IR header. +pub fn compile_source(source: &str, display_path: &str) -> CompileResult { + compile_source_with_paths(source, display_path, display_path) +} + +/// Compiles one document while keeping diagnostic and reproducible IR paths separate. +pub fn compile_source_with_paths( + source: &str, + diagnostic_path: &str, + source_path: &str, +) -> CompileResult { + let index = LineIndex::new(source); + let (hir, bag) = dxaml_hir::analyze(source); + + let mut diagnostics: Vec = bag + .sorted() + .iter() + .map(|diagnostic| diagnostic.render(diagnostic_path, &index)) + .collect(); + + let hir = match hir { + Some(hir) if !bag.has_errors() => hir, + _ => { + // A document that produced no diagnostic but also no HIR would be a silent failure, + // which the contract forbids. + if !bag.has_errors() { + diagnostics.push( + Diagnostic::error( + codes::IR_VALIDATION, + "compilation produced no document and no diagnostic; this is a compiler bug", + Span::empty(0), + ) + .render(diagnostic_path, &index), + ); + } + return CompileResult { + document: None, + diagnostics, + failed: true, + }; + } + }; + + let document = dxaml_lower::lower(&hir, source, source_path, COMPILER_VERSION); + + let problems = dxaml_ir::validate(&document); + if !problems.is_empty() { + for problem in problems { + diagnostics.push( + Diagnostic::error(codes::IR_VALIDATION, problem, Span::empty(0)) + .render(diagnostic_path, &index), + ); + } + return CompileResult { + document: None, + diagnostics, + failed: true, + }; + } + + // Reaching here implies the bag held no errors, so anything left is advisory. + CompileResult { + document: Some(document), + diagnostics, + failed: false, + } +} + +/// The output file name for a given input stem: `Foo.xaml` becomes `Foo.dxir.json`. +pub fn output_file_name(stem: &str) -> String { + format!("{stem}.dxir.json") +} + +/// The generated binding source name for a given input stem. +pub fn bindings_output_file_name(stem: &str) -> String { + format!("{stem}.bindings.g.cs") +} diff --git a/compiler/crates/dxaml-cli/src/main.rs b/compiler/crates/dxaml-cli/src/main.rs new file mode 100644 index 00000000..684c99eb --- /dev/null +++ b/compiler/crates/dxaml-cli/src/main.rs @@ -0,0 +1,175 @@ +//! `dxamlc` — the Direct XAML compiler. +//! +//! ```text +//! dxamlc compile --input [--output ] [--check] +//! dxamlc --version +//! dxamlc --help +//! ``` + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use dxaml_cli::{ + bindings_output_file_name, compile_source_with_paths, output_file_name, COMPILER_VERSION, +}; + +const USAGE: &str = "\ +dxamlc — the Direct XAML compiler + +USAGE: + dxamlc compile --input [--output ] [--source-root ] [--check] + dxamlc --version + dxamlc --help + +OPTIONS: + --input XAML document to compile. Required. + --output Directory for generated IR and C# files. Defaults to the input directory. + --source-root Root removed from the source path recorded in IR, for reproducible builds. + --check Report diagnostics without writing anything. + +Diagnostics are written to stderr in MSBuild's format. The exit status is 0 only when the +document compiled with no errors."; + +fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + + match run(&arguments) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(message) => { + eprintln!("dxamlc: error: {message}"); + eprintln!(); + eprintln!("{USAGE}"); + ExitCode::FAILURE + } + } +} + +/// `Ok(true)` when the document compiled cleanly, `Ok(false)` when it produced errors, and +/// `Err` for a problem with the invocation itself. +fn run(arguments: &[String]) -> Result { + if arguments.is_empty() { + return Err("no command given".to_string()); + } + + match arguments[0].as_str() { + "--help" | "-h" | "help" => { + println!("{USAGE}"); + Ok(true) + } + "--version" | "-V" => { + println!("dxamlc {COMPILER_VERSION}"); + Ok(true) + } + "compile" => compile(&arguments[1..]), + other => Err(format!("unknown command '{other}'")), + } +} + +fn compile(arguments: &[String]) -> Result { + let mut input: Option = None; + let mut output: Option = None; + let mut source_root: Option = None; + let mut check_only = false; + + let mut index = 0usize; + while index < arguments.len() { + match arguments[index].as_str() { + "--input" => { + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| "--input needs a path".to_string())?; + input = Some(PathBuf::from(value)); + } + "--output" => { + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| "--output needs a path".to_string())?; + output = Some(PathBuf::from(value)); + } + "--source-root" => { + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| "--source-root needs a path".to_string())?; + source_root = Some(PathBuf::from(value)); + } + "--check" => check_only = true, + other => return Err(format!("unknown option '{other}'")), + } + index += 1; + } + + let input = input.ok_or_else(|| "--input is required".to_string())?; + + let source = std::fs::read_to_string(&input) + .map_err(|error| format!("cannot read {}: {error}", input.display()))?; + + let display_path = input.display().to_string(); + let source_path = reproducible_source_path(&input, source_root.as_deref()); + let result = compile_source_with_paths(&source, &display_path, &source_path); + + for diagnostic in &result.diagnostics { + eprintln!("{diagnostic}"); + } + + let document = match result.document { + Some(document) if !result.failed => document, + _ => return Ok(false), + }; + + if check_only { + return Ok(true); + } + + let stem = input + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| format!("{} has no usable file name", input.display()))?; + + let directory = output + .or_else(|| input.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")); + + std::fs::create_dir_all(&directory) + .map_err(|error| format!("cannot create {}: {error}", directory.display()))?; + + let ir_destination = directory.join(output_file_name(stem)); + let json = document + .to_json() + .map_err(|error| format!("cannot serialize IR: {error}"))?; + write_if_changed(&ir_destination, json.as_bytes())?; + + let bindings = match dxaml_codegen_csharp::generate(&document) { + Ok(bindings) => bindings, + Err(error) => { + eprintln!("{display_path}(1,1): error DX4001: {error}"); + return Ok(false); + } + }; + let bindings_destination = directory.join(bindings_output_file_name(stem)); + write_if_changed(&bindings_destination, bindings.as_bytes())?; + + println!("{}", ir_destination.display()); + println!("{}", bindings_destination.display()); + Ok(true) +} + +fn reproducible_source_path(input: &Path, source_root: Option<&Path>) -> String { + let relative = source_root + .and_then(|root| input.strip_prefix(root).ok()) + .or_else(|| input.file_name().map(Path::new)) + .unwrap_or(input); + relative.to_string_lossy().replace('\\', "/") +} + +fn write_if_changed(destination: &Path, content: &[u8]) -> Result<(), String> { + if std::fs::read(destination).is_ok_and(|existing| existing == content) { + return Ok(()); + } + + std::fs::write(destination, content) + .map_err(|error| format!("cannot write {}: {error}", destination.display())) +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/BindingsEmptyInvalidation.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/BindingsEmptyInvalidation.dxir.json new file mode 100644 index 00000000..6137bc69 --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/BindingsEmptyInvalidation.dxir.json @@ -0,0 +1,34 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "test", + "source": { + "path": "TypedBindingFixture.xaml", + "hash": "fnv1a64:0123456789abcdef" + }, + "class_name": "Tests.TypedBindingCard", + "binding_context_type": "Tests.TypedBindingContext", + "features": ["bindings"], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [], + "text": null + } + ], + "properties": [], + "named_slots": [], + "bindings": [ + { + "target_node": 0, + "target_property": "Text", + "source_path": ["ResultText"], + "mode": "oneTime", + "invalidation": [] + } + ], + "resources": [], + "actions": [], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidMode.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidMode.dxir.json new file mode 100644 index 00000000..2617d36a --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidMode.dxir.json @@ -0,0 +1,34 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "test", + "source": { + "path": "TypedBindingFixture.xaml", + "hash": "fnv1a64:0123456789abcdef" + }, + "class_name": "Tests.TypedBindingCard", + "binding_context_type": "Tests.TypedBindingContext", + "features": ["bindings"], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [], + "text": null + } + ], + "properties": [], + "named_slots": [], + "bindings": [ + { + "target_node": 0, + "target_property": "Text", + "source_path": ["ResultText"], + "mode": "twoWay", + "invalidation": ["measure", "paint"] + } + ], + "resources": [], + "actions": [], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidSourcePath.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidSourcePath.dxir.json new file mode 100644 index 00000000..8854bfbb --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/BindingsInvalidSourcePath.dxir.json @@ -0,0 +1,34 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "test", + "source": { + "path": "TypedBindingFixture.xaml", + "hash": "fnv1a64:0123456789abcdef" + }, + "class_name": "Tests.TypedBindingCard", + "binding_context_type": "Tests.TypedBindingContext", + "features": ["bindings"], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [], + "text": null + } + ], + "properties": [], + "named_slots": [], + "bindings": [ + { + "target_node": 0, + "target_property": "Text", + "source_path": ["Result", "Text"], + "mode": "oneWay", + "invalidation": ["measure", "paint"] + } + ], + "resources": [], + "actions": [], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/BindingsMissingContext.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/BindingsMissingContext.dxir.json new file mode 100644 index 00000000..9194bfc3 --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/BindingsMissingContext.dxir.json @@ -0,0 +1,33 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "test", + "source": { + "path": "TypedBindingFixture.xaml", + "hash": "fnv1a64:0123456789abcdef" + }, + "class_name": "Tests.TypedBindingCard", + "features": ["bindings"], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [], + "text": null + } + ], + "properties": [], + "named_slots": [], + "bindings": [ + { + "target_node": 0, + "target_property": "Text", + "source_path": ["ResultText"], + "mode": "oneTime", + "invalidation": ["measure", "paint"] + } + ], + "resources": [], + "actions": [], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/BindingsValid.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/BindingsValid.dxir.json new file mode 100644 index 00000000..e781687d --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/BindingsValid.dxir.json @@ -0,0 +1,55 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "test", + "source": { + "path": "TypedBindingFixture.xaml", + "hash": "fnv1a64:0123456789abcdef" + }, + "class_name": "Tests.TypedBindingCard", + "binding_context_type": "Tests.TypedBindingContext", + "features": ["bindings"], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [1, 2], + "text": null + }, + { + "id": 1, + "kind": "textBlock", + "parent": 0, + "children": [], + "text": null + }, + { + "id": 2, + "kind": "button", + "parent": 0, + "children": [], + "text": null + } + ], + "properties": [], + "named_slots": [], + "bindings": [ + { + "target_node": 1, + "target_property": "Text", + "source_path": ["ResultText"], + "mode": "oneTime", + "invalidation": ["measure", "paint"] + }, + { + "target_node": 2, + "target_property": "Content", + "source_path": ["Status"], + "mode": "oneWay", + "invalidation": ["measure", "paint", "semantics"] + } + ], + "resources": [], + "actions": [], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json b/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json new file mode 100644 index 00000000..e33d4896 --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.dxir.json @@ -0,0 +1,1049 @@ +{ + "ir_version": "0.2.0", + "compiler_version": "", + "source": { + "path": "MinimalServiceResultItem.xaml", + "hash": "fnv1a64:6018525132c6fcb6" + }, + "class_name": "Easydict.WinUI.Views.Controls.MinimalServiceResultItem", + "features": [ + "named-slots", + "theme-resources", + "actions" + ], + "nodes": [ + { + "id": 0, + "kind": "userControl", + "parent": null, + "children": [ + 1 + ], + "text": null + }, + { + "id": 1, + "kind": "border", + "parent": 0, + "children": [ + 2 + ], + "text": null + }, + { + "id": 2, + "kind": "grid", + "parent": 1, + "children": [ + 3, + 4, + 5, + 11 + ], + "text": null + }, + { + "id": 3, + "kind": "rowDefinition", + "parent": 2, + "children": [], + "text": null + }, + { + "id": 4, + "kind": "rowDefinition", + "parent": 2, + "children": [], + "text": null + }, + { + "id": 5, + "kind": "border", + "parent": 2, + "children": [ + 6 + ], + "text": null + }, + { + "id": 6, + "kind": "grid", + "parent": 5, + "children": [ + 7, + 8, + 9, + 10 + ], + "text": null + }, + { + "id": 7, + "kind": "columnDefinition", + "parent": 6, + "children": [], + "text": null + }, + { + "id": 8, + "kind": "columnDefinition", + "parent": 6, + "children": [], + "text": null + }, + { + "id": 9, + "kind": "textBlock", + "parent": 6, + "children": [], + "text": null + }, + { + "id": 10, + "kind": "textBlock", + "parent": 6, + "children": [], + "text": null + }, + { + "id": 11, + "kind": "border", + "parent": 2, + "children": [ + 12 + ], + "text": null + }, + { + "id": 12, + "kind": "stackPanel", + "parent": 11, + "children": [ + 13, + 14, + 15, + 16 + ], + "text": null + }, + { + "id": 13, + "kind": "textBlock", + "parent": 12, + "children": [], + "text": null + }, + { + "id": 14, + "kind": "textBlock", + "parent": 12, + "children": [], + "text": null + }, + { + "id": 15, + "kind": "textBlock", + "parent": 12, + "children": [], + "text": null + }, + { + "id": 16, + "kind": "button", + "parent": 12, + "children": [], + "text": null + } + ], + "properties": [ + { + "node": 1, + "name": "Background", + "value": { + "type": "resource", + "resource": 0 + } + }, + { + "node": 1, + "name": "BorderBrush", + "value": { + "type": "resource", + "resource": 1 + } + }, + { + "node": 1, + "name": "BorderThickness", + "value": { + "type": "resource", + "resource": 2 + } + }, + { + "node": 1, + "name": "CornerRadius", + "value": { + "type": "resource", + "resource": 3 + } + }, + { + "node": 1, + "name": "Margin", + "value": { + "type": "thickness", + "value": [ + 0.0, + 0.0, + 0.0, + 2.0 + ] + } + }, + { + "node": 3, + "name": "Height", + "value": { + "type": "gridLength", + "value": { + "kind": "auto" + } + } + }, + { + "node": 4, + "name": "Height", + "value": { + "type": "gridLength", + "value": { + "kind": "auto" + } + } + }, + { + "node": 5, + "name": "Grid.Row", + "value": { + "type": "int", + "value": 0 + } + }, + { + "node": 5, + "name": "Background", + "value": { + "type": "resource", + "resource": 4 + } + }, + { + "node": 5, + "name": "BorderBrush", + "value": { + "type": "resource", + "resource": 1 + } + }, + { + "node": 5, + "name": "BorderThickness", + "value": { + "type": "thickness", + "value": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + } + }, + { + "node": 5, + "name": "Padding", + "value": { + "type": "thickness", + "value": [ + 6.0, + 4.0, + 6.0, + 4.0 + ] + } + }, + { + "node": 7, + "name": "Width", + "value": { + "type": "gridLength", + "value": { + "kind": "star", + "value": 1.0 + } + } + }, + { + "node": 8, + "name": "Width", + "value": { + "type": "gridLength", + "value": { + "kind": "auto" + } + } + }, + { + "node": 9, + "name": "Grid.Column", + "value": { + "type": "int", + "value": 0 + } + }, + { + "node": 9, + "name": "FontSize", + "value": { + "type": "double", + "value": 12.0 + } + }, + { + "node": 9, + "name": "FontWeight", + "value": { + "type": "enum", + "enum": "FontWeight", + "value": "SemiBold" + } + }, + { + "node": 9, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 5 + } + }, + { + "node": 9, + "name": "TextTrimming", + "value": { + "type": "enum", + "enum": "TextTrimming", + "value": "CharacterEllipsis" + } + }, + { + "node": 9, + "name": "VerticalAlignment", + "value": { + "type": "enum", + "enum": "VerticalAlignment", + "value": "Center" + } + }, + { + "node": 10, + "name": "Grid.Column", + "value": { + "type": "int", + "value": 1 + } + }, + { + "node": 10, + "name": "FontSize", + "value": { + "type": "double", + "value": 10.0 + } + }, + { + "node": 10, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 6 + } + }, + { + "node": 10, + "name": "Margin", + "value": { + "type": "thickness", + "value": [ + 8.0, + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "node": 10, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + }, + { + "node": 10, + "name": "VerticalAlignment", + "value": { + "type": "enum", + "enum": "VerticalAlignment", + "value": "Center" + } + }, + { + "node": 11, + "name": "Grid.Row", + "value": { + "type": "int", + "value": 1 + } + }, + { + "node": 11, + "name": "Padding", + "value": { + "type": "thickness", + "value": [ + 8.0, + 6.0, + 8.0, + 8.0 + ] + } + }, + { + "node": 11, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + }, + { + "node": 12, + "name": "Spacing", + "value": { + "type": "double", + "value": 4.0 + } + }, + { + "node": 13, + "name": "FontSize", + "value": { + "type": "double", + "value": 12.0 + } + }, + { + "node": 13, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 7 + } + }, + { + "node": 13, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + }, + { + "node": 14, + "name": "TextWrapping", + "value": { + "type": "enum", + "enum": "TextWrapping", + "value": "Wrap" + } + }, + { + "node": 14, + "name": "IsTextSelectionEnabled", + "value": { + "type": "bool", + "value": true + } + }, + { + "node": 14, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 8 + } + }, + { + "node": 14, + "name": "FontSize", + "value": { + "type": "double", + "value": 13.0 + } + }, + { + "node": 14, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + }, + { + "node": 15, + "name": "TextWrapping", + "value": { + "type": "enum", + "enum": "TextWrapping", + "value": "Wrap" + } + }, + { + "node": 15, + "name": "IsTextSelectionEnabled", + "value": { + "type": "bool", + "value": true + } + }, + { + "node": 15, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 9 + } + }, + { + "node": 15, + "name": "FontSize", + "value": { + "type": "double", + "value": 12.0 + } + }, + { + "node": 15, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + }, + { + "node": 16, + "name": "Content", + "value": { + "type": "string", + "value": "Copy" + } + }, + { + "node": 16, + "name": "HorizontalAlignment", + "value": { + "type": "enum", + "enum": "HorizontalAlignment", + "value": "Right" + } + }, + { + "node": 16, + "name": "Padding", + "value": { + "type": "thickness", + "value": [ + 4.0, + 2.0, + 4.0, + 2.0 + ] + } + }, + { + "node": 16, + "name": "Background", + "value": { + "type": "resource", + "resource": 10 + } + }, + { + "node": 16, + "name": "BorderBrush", + "value": { + "type": "resource", + "resource": 11 + } + }, + { + "node": 16, + "name": "BorderThickness", + "value": { + "type": "thickness", + "value": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "node": 16, + "name": "CornerRadius", + "value": { + "type": "cornerRadius", + "value": [ + 3.0, + 3.0, + 3.0, + 3.0 + ] + } + }, + { + "node": 16, + "name": "Foreground", + "value": { + "type": "resource", + "resource": 8 + } + }, + { + "node": 16, + "name": "FontSize", + "value": { + "type": "double", + "value": 11.0 + } + }, + { + "node": 16, + "name": "Visibility", + "value": { + "type": "enum", + "enum": "Visibility", + "value": "Collapsed" + } + } + ], + "named_slots": [ + { + "name": "RootBorder", + "node": 1, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Background", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "HeaderBar", + "node": 5, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Background", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "ServiceNameText", + "node": 9, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Text", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "StatusText", + "node": 10, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Text", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "ContentArea", + "node": 11, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Background", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "PendingQueryText", + "node": 13, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Text", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "ResultText", + "node": 14, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Text", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "ErrorText", + "node": 15, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Text", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + }, + { + "name": "CopyButton", + "node": 16, + "mutable": [ + { + "property": "Opacity", + "invalidation": [ + "paint" + ] + }, + { + "property": "Visibility", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "Background", + "invalidation": [ + "paint" + ] + }, + { + "property": "Content", + "invalidation": [ + "measure", + "paint", + "semantics" + ] + }, + { + "property": "FontSize", + "invalidation": [ + "measure", + "paint" + ] + }, + { + "property": "Foreground", + "invalidation": [ + "paint" + ] + } + ] + } + ], + "bindings": [], + "resources": [ + { + "id": 0, + "kind": "themeResource", + "key": "ResultViewBackgroundBrush" + }, + { + "id": 1, + "kind": "themeResource", + "key": "CardStrokeColorDefaultBrush" + }, + { + "id": 2, + "kind": "themeResource", + "key": "EasydictCardBorderThickness" + }, + { + "id": 3, + "kind": "themeResource", + "key": "EasydictCardCornerRadius" + }, + { + "id": 4, + "kind": "themeResource", + "key": "ServiceResultHeaderBackgroundBrush" + }, + { + "id": 5, + "kind": "themeResource", + "key": "ServiceResultHeaderForegroundBrush" + }, + { + "id": 6, + "kind": "themeResource", + "key": "ServiceResultHeaderSecondaryForegroundBrush" + }, + { + "id": 7, + "kind": "themeResource", + "key": "TextFillColorTertiaryBrush" + }, + { + "id": 8, + "kind": "themeResource", + "key": "QueryTextBrush" + }, + { + "id": 9, + "kind": "themeResource", + "key": "SystemFillColorCriticalBrush" + }, + { + "id": 10, + "kind": "themeResource", + "key": "ControlFillColorDefaultBrush" + }, + { + "id": 11, + "kind": "themeResource", + "key": "ControlStrokeColorDefaultBrush" + } + ], + "actions": [ + { + "node": 5, + "event": "pointerPressed", + "handler": "OnHeaderPointerPressed" + }, + { + "node": 16, + "event": "click", + "handler": "CopyCommand" + } + ], + "semantics": [] +} diff --git a/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.xaml b/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.xaml new file mode 100644 index 00000000..d2e22435 --- /dev/null +++ b/compiler/crates/dxaml-cli/tests/fixtures/MinimalServiceResultItem.xaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +