From 783ce8d9d38d3d8418892d402e6b561db15dc870 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 10:04:53 +0200 Subject: [PATCH 01/30] try to build the static using github actions --- .Rbuildignore | 6 + .github/workflows/build-syntaxinterface.yml | 276 ++++++++++++++++++++ DESCRIPTION | 4 + configure | 133 ++++++++-- configure.win | 124 ++++++--- jaspModule.Rproj => jaspSyntax.Rproj | 0 src/Makevars.in | 4 +- 7 files changed, 488 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/build-syntaxinterface.yml rename jaspModule.Rproj => jaspSyntax.Rproj (100%) diff --git a/.Rbuildignore b/.Rbuildignore index 112ad26..9770cf0 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,3 +1,9 @@ +^renv$ +^renv\.lock$ +^\.Rprofile$ ^.*\.Rproj$ ^\.Rproj\.user$ ^\.travis\.yml$ +^\.github$ +^\.editorconfig$ +^demo\.R$ diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml new file mode 100644 index 0000000..19e59c1 --- /dev/null +++ b/.github/workflows/build-syntaxinterface.yml @@ -0,0 +1,276 @@ +# Build libSyntaxInterface for all platforms and upload to a GitHub Release. +# +# The configure script in jaspSyntax downloads these binaries during +# `install.packages()`. R-universe also uses this when building the package. +# +# Trigger: manual dispatch, or on a schedule (every 2 weeks on Monday 06:00 UTC). + +name: Build SyntaxInterface Libraries + +on: + workflow_dispatch: + inputs: + jasp_desktop_ref: + description: 'jasp-desktop branch/tag/SHA to build from' + required: false + default: 'development' + release_tag: + description: 'Release tag to upload assets to' + required: false + default: 'syntaxinterface-libs' + schedule: + - cron: '0 6 1,15 * *' # 1st and 15th of each month at 06:00 UTC + +# Cancel previous runs of this workflow on the same ref +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + JASP_DESKTOP_REF: ${{ github.event.inputs.jasp_desktop_ref || 'development' }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'syntaxinterface-libs' }} + QT_VERSION: '6.10.1' + QT_SUBMODULES: 'qtbase qtdeclarative qtshadertools' + +jobs: + + # --------------------------------------------------------------------------- + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + artifact: libSyntaxInterface-linux-x86_64.so + lib_file: libSyntaxInterface.so + - os: ubuntu-24.04-arm + arch: arm64 + artifact: libSyntaxInterface-linux-arm64.so + lib_file: libSyntaxInterface.so + - os: macos-15-intel + arch: x86_64 + artifact: libSyntaxInterface-darwin-x86_64.dylib + lib_file: libSyntaxInterface.dylib + - os: macos-15 # Apple Silicon (M1) + arch: arm64 + artifact: libSyntaxInterface-darwin-arm64.dylib + lib_file: libSyntaxInterface.dylib + - os: windows-latest + arch: x86_64 + artifact: SyntaxInterface-windows-x86_64.dll + lib_file: SyntaxInterface.dll + # the runners are based on https://docs.github.com/en/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories + # this may need to be updated over time + + runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} (${{ matrix.arch }}) + + steps: + - name: Checkout jaspSyntax (for workflow context) + uses: actions/checkout@v6 + + # ---- System dependencies ---- + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build \ + autoconf bison flex g++ gfortran \ + zlib1g-dev libssl-dev libgl1-mesa-dev \ + libsqlite3-dev libarchive-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libxcb-xkb-dev \ + libxcb-xinerama0 libxcb-cursor0 \ + libboost-dev libboost-filesystem-dev libboost-system-dev \ + libboost-date-time-dev libboost-timer-dev libboost-chrono-dev \ + librdata-dev libfreexl-dev libminizip-dev libglpk-dev \ + libjsoncpp-dev libnss3-dev libnspr4-dev \ + libxcomposite-dev libxdamage-dev libxrandr-dev libxtst-dev \ + libxi-dev libasound2-dev libxkbfile-dev \ + libxcb-icccm4-dev libxcb-shape0-dev libxcb-keysyms1-dev \ + patchelf pkg-config \ + r-base r-base-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install cmake ninja autoconf automake libtool pkg-config \ + boost jsoncpp libarchive openssl sqlite + + - name: Install system dependencies (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + choco install ninja -y + + # ---- Install readstat (Linux) ---- + + - name: Install ReadStat from source (Linux) + if: runner.os == 'Linux' + run: | + wget -q https://github.com/WizardMac/ReadStat/releases/download/v1.1.9/readstat-1.1.9.tar.gz + tar -xzf readstat-1.1.9.tar.gz + cd readstat-1.1.9 + ./configure --prefix=/usr/local + make -j$(nproc) CFLAGS='-Wno-error=use-after-free' + sudo make install + sudo ldconfig + + # ---- Setup R ---- + + - name: Setup R + uses: r-lib/actions/setup-r@v2 + with: + r-version: 'release' + + - name: Install Rcpp and RInside + run: | + Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' + + # ---- Install Qt from source and build static ---- + + - name: Cache static Qt build + id: cache-qt + uses: actions/cache@v5 + with: + path: ${{ github.workspace }}/qt-static + key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-${{ env.QT_SUBMODULES }} + + - name: Install aqtinstall + if: steps.cache-qt.outputs.cache-hit != 'true' + run: pip3 install aqtinstall + + - name: Download Qt source + if: steps.cache-qt.outputs.cache-hit != 'true' + shell: bash + run: | + aqt install-src --outputdir qt-src "${{ env.QT_VERSION }}" desktop + + - name: Build static Qt (Unix) + if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os != 'Windows' + shell: bash + run: | + cd qt-src/${{ env.QT_VERSION }}/Src + ./configure -release -static -opensource -confirm-license \ + -prefix ${{ github.workspace }}/qt-static \ + -submodules ${{ env.QT_SUBMODULES }} \ + -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON + cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) + cmake --install . + + - name: Build static Qt (Windows) + if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os == 'Windows' + shell: cmd + run: | + call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 + cd qt-src\${{ env.QT_VERSION }}\Src + configure.bat -release -static -opensource -confirm-license ^ + -prefix ${{ github.workspace }}\qt-static ^ + -submodules ${{ env.QT_SUBMODULES }} + cmake --build . --parallel + cmake --install . + + # ---- Clone and build jasp-desktop / SyntaxInterface ---- + + - name: Clone jasp-desktop + uses: actions/checkout@v6 + with: + repository: jasp-stats/jasp-desktop + ref: ${{ env.JASP_DESKTOP_REF }} + path: jasp-desktop + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure jasp-desktop (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + R_HOME=$(R RHOME) + cmake -G Ninja -S jasp-desktop -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH=${{ github.workspace }}/qt-static \ + -DCUSTOM_R_PATH="${R_HOME}" \ + -DINSTALL_R_MODULES=OFF \ + -DBUILD_TESTS=OFF + + - name: Configure jasp-desktop (Windows) + if: runner.os == 'Windows' + shell: cmd + run: | + call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 + for /f %%i in ('Rscript -e "cat(R.home())"') do set R_HOME=%%i + cmake -G Ninja -S jasp-desktop -B build ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_PREFIX_PATH=${{ github.workspace }}\qt-static ^ + -DINSTALL_R_MODULES=OFF ^ + -DBUILD_TESTS=OFF + + - name: Build SyntaxInterface + shell: bash + run: cmake --build build --target SyntaxInterface --parallel + + # ---- Collect and upload artifact ---- + + - name: Collect library (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + cp build/SyntaxInterface/${{ matrix.lib_file }} ${{ matrix.artifact }} + + - name: Collect library (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + # On Windows we also need libR-InterfaceNoRInside.dll + cp build/SyntaxInterface/${{ matrix.lib_file }} ${{ matrix.artifact }} + if [ -f build/R-Interface/libR-InterfaceNoRInside.dll ]; then + cp build/R-Interface/libR-InterfaceNoRInside.dll libR-InterfaceNoRInside-windows-x86_64.dll + fi + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: | + ${{ matrix.artifact }} + libR-InterfaceNoRInside-windows-*.dll + if-no-files-found: error + + # --------------------------------------------------------------------------- + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + merge-multiple: true + path: libs + + - name: Generate checksums + working-directory: libs + run: sha256sum * > SHA256SUMS + + - name: Show checksums + run: cat libs/SHA256SUMS + + - name: Create or update release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: "SyntaxInterface pre-built libraries" + body: | + Pre-built `libSyntaxInterface` binaries for use by `jaspSyntax`. + + Built from [jasp-desktop@${{ env.JASP_DESKTOP_REF }}](https://github.com/jasp-stats/jasp-desktop/tree/${{ env.JASP_DESKTOP_REF }}). + Qt version: ${{ env.QT_VERSION }} (static, submodules: ${{ env.QT_SUBMODULES }}). + + These are downloaded automatically by the `configure` script during `install.packages("jaspSyntax")`. + files: libs/* + draft: false + prerelease: false + make_latest: false diff --git a/DESCRIPTION b/DESCRIPTION index 359b588..4a16b5e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -8,5 +8,9 @@ Website: jasp-stats.org Maintainer: JASP Team Description: Set up the right options for the analysis by loading its QML Form, and set up the R environment so that it can run the analysis as if it was run by JASP License: GPL (>= 2) +Encoding: UTF-8 +URL: https://github.com/jasp-stats/jaspSyntax +BugReports: https://github.com/jasp-stats/jaspSyntax/issues +SystemRequirements: libcurl or wget (for downloading the SyntaxInterface library during installation) Imports: Rcpp (>= 1.0.5) LinkingTo: Rcpp diff --git a/configure b/configure index 35ca21f..b76b110 100755 --- a/configure +++ b/configure @@ -1,3 +1,4 @@ +#!/bin/bash # To manually specify a location for JASP_BUILD_DIR or JASP_SOURCE_DIR do # # options(configure.vars = c(jaspSyntax = "JASP_SOURCE_DIR=''")) @@ -5,30 +6,76 @@ set -e +# ---------- GitHub Release configuration ---------- +# Pre-built binaries are hosted as GitHub Release assets. +# The build-syntaxinterface.yml workflow produces these. +GITHUB_RELEASE_TAG="${JASPSYNTAX_RELEASE_TAG:-syntaxinterface-libs}" +GITHUB_RELEASE_URL="https://github.com/jasp-stats/jaspSyntax/releases/download/${GITHUB_RELEASE_TAG}" -function loadFile() { - DOWNLOAD_SUCCESS=1 - if curl --version 2>&1 >/dev/null; then - echo "Downloading $1/$2 with curl" - curl --silent --output "src/$2" "$1/$2" + +function downloadFile() { + # downloadFile + # Downloads a file using curl or wget. Exits on failure. + local URL="$1" + local OUTPUT="$2" + local DOWNLOAD_SUCCESS=1 + + if command -v curl >/dev/null 2>&1; then + echo "Downloading ${URL}" + curl --fail --silent --location --output "${OUTPUT}" "${URL}" DOWNLOAD_SUCCESS=$? fi if [ "${DOWNLOAD_SUCCESS}" -ne "0" ]; then - echo "seeing if wget is available" - if wget --version 2>&1 >/dev/null; then - wget --quiet -O "src/$2" "$1/$2" + if command -v wget >/dev/null 2>&1; then + echo "Trying wget for ${URL}" + wget --quiet -O "${OUTPUT}" "${URL}" DOWNLOAD_SUCCESS=$? fi fi + if [ "${DOWNLOAD_SUCCESS}" -ne "0" ]; then - printf "Installing jaspSyntax failed because the required file $2 is missing.\n\ + echo "Failed to download: ${URL}" + return 1 + fi + return 0 +} + +function loadFile() { + # loadFile + # Downloads / into src/. + if ! downloadFile "$1/$2" "src/$2"; then + printf "Installing jaspSyntax failed because the required file %s is missing.\n\ Normally this is downloaded automatically if either curl or wget is available, but apparently this failed.\n\ -Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.args: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))" +Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.args: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))\n" "$2" exit 1 fi } +# ---------- Detect platform and architecture ---------- +UNAME_S="$(uname -s)" +UNAME_M="$(uname -m)" +case "${UNAME_S}" in + Darwin*) DLL_EXT="dylib"; DLL_NAME="libSyntaxInterface.dylib" ;; + Linux*) DLL_EXT="so"; DLL_NAME="libSyntaxInterface.so" ;; + *) echo "Unsupported platform: ${UNAME_S}"; exit 1 ;; +esac + +# Map architecture names +case "${UNAME_M}" in + x86_64|amd64) ARCH="x86_64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) ARCH="${UNAME_M}" ;; +esac + +# Determine the GitHub Release asset name for this platform +case "${UNAME_S}" in + Darwin*) RELEASE_ASSET="libSyntaxInterface-darwin-${ARCH}.dylib" ;; + Linux*) RELEASE_ASSET="libSyntaxInterface-linux-${ARCH}.so" ;; +esac + +echo "Detected platform: ${UNAME_S} ${UNAME_M} (library: ${DLL_NAME}, asset: ${RELEASE_ASSET})" + if [ "${R_HOME}" ]; then echo "Found R_HOME: ${R_HOME}" else @@ -39,6 +86,7 @@ PKG_CXXFLAGS="" JASP_SOURCE_DIR="" JASP_BUILD_DIR="" +# ---------- Download header files if needed ---------- if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" @@ -59,32 +107,73 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then done fi - -DLL_NAME="libSyntaxInterface.dylib" +# ---------- Download pre-built library if needed ---------- if [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" - cp "${JASP_BUILD_DIR}/SyntaxInterface/$DLL_NAME" src/$DLL_NAME + cp "${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" "src/${DLL_NAME}" elif [ ! -f "src/${DLL_NAME}" ]; then - if [ "${JASP_FILE_SERVER}" = "" ]; then - JASP_FILE_SERVER="https://static.jasp-stats.org/jaspSyntax/0.96.1/" - fi - loadFile "${JASP_FILE_SERVER}" "${DLL_NAME}" - if [[ ! $(shasum -a 256 src/${DLL_NAME}) =~ "a6f88bd3bd1b4c5bdd687409ace043f97099ac2767737de32bc2fa318e9d691b" ]]; then - echo "Wrong checksum!" - rm src/${DLL_NAME} + echo "Downloading pre-built ${RELEASE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." + if ! downloadFile "${GITHUB_RELEASE_URL}/${RELEASE_ASSET}" "src/${DLL_NAME}"; then + echo "" + echo "ERROR: Could not download pre-built library for your platform." + echo " URL: ${GITHUB_RELEASE_URL}/${RELEASE_ASSET}" + echo "" + echo "You can build the library yourself and pass it via:" + echo " options(configure.vars = c(jaspSyntax = \"JASP_BUILD_DIR='/path/to/build'\"))" + echo "See https://github.com/jasp-stats/jaspSyntax#readme for instructions." exit 1 fi + + # Verify checksum from the SHA256SUMS file in the same release + if command -v shasum >/dev/null 2>&1; then + CHECKSUM_CMD="shasum -a 256" + elif command -v sha256sum >/dev/null 2>&1; then + CHECKSUM_CMD="sha256sum" + else + echo "Warning: no sha256 tool found, skipping checksum verification" + CHECKSUM_CMD="" + fi + + if [ -n "${CHECKSUM_CMD}" ]; then + echo "Verifying checksum..." + if downloadFile "${GITHUB_RELEASE_URL}/SHA256SUMS" "src/SHA256SUMS"; then + EXPECTED_CHECKSUM=$(grep "${RELEASE_ASSET}" src/SHA256SUMS | awk '{print $1}') + ACTUAL_CHECKSUM=$(${CHECKSUM_CMD} "src/${DLL_NAME}" | awk '{print $1}') + + if [ -z "${EXPECTED_CHECKSUM}" ]; then + echo "Warning: no checksum found for ${RELEASE_ASSET} in SHA256SUMS, skipping verification" + elif [ "${ACTUAL_CHECKSUM}" != "${EXPECTED_CHECKSUM}" ]; then + echo "Checksum verification FAILED for ${DLL_NAME}!" + echo " Expected: ${EXPECTED_CHECKSUM}" + echo " Got: ${ACTUAL_CHECKSUM}" + rm -f "src/${DLL_NAME}" + rm -f "src/SHA256SUMS" + exit 1 + else + echo "Checksum verified OK" + fi + rm -f "src/SHA256SUMS" + else + echo "Warning: could not download SHA256SUMS, skipping checksum verification" + fi + fi fi mkdir -p inst/libs -cp src/"$DLL_NAME" inst/libs/$DLL_NAME +cp "src/${DLL_NAME}" "inst/libs/${DLL_NAME}" PKG_LIBS=-lSyntaxInterface -sed -e "s|@cppflags@|${PKG_CXXFLAGS}|" -e "s|@libflags@|${PKG_LIBS}|" src/Makevars.in > src/Makevars +# Set platform-specific RPATH so jaspSyntax.so can find libSyntaxInterface at runtime +case "${UNAME_S}" in + Linux*) PKG_RPATHFLAGS="-Wl,-rpath,'\$\$ORIGIN'" ;; + *) PKG_RPATHFLAGS="" ;; +esac + +sed -e "s|@cppflags@|${PKG_CXXFLAGS}|" -e "s|@libflags@|${PKG_LIBS}|" -e "s|@rpathflags@|${PKG_RPATHFLAGS}|" src/Makevars.in > src/Makevars exit 0 diff --git a/configure.win b/configure.win index ed52a3f..b27a3c1 100644 --- a/configure.win +++ b/configure.win @@ -1,3 +1,4 @@ +#!/bin/bash # To manually specify a location for JASP_BUILD_DIR or JASP_SOURCE_DIR do # # options(configure.vars = c(jaspSyntax = "JASP_SOURCE_DIR=''")) @@ -5,29 +6,81 @@ set -e +# ---------- GitHub Release configuration ---------- +GITHUB_RELEASE_TAG="${JASPSYNTAX_RELEASE_TAG:-syntaxinterface-libs}" +GITHUB_RELEASE_URL="https://github.com/jasp-stats/jaspSyntax/releases/download/${GITHUB_RELEASE_TAG}" -function loadFile() { - DOWNLOAD_SUCCESS=1 - if curl --version 2>&1 >/dev/null; then - echo "Downloading $1/$2 with curl" - curl --silent --output "src/$2" "$1/$2" + +function downloadFile() { + local URL="$1" + local OUTPUT="$2" + local DOWNLOAD_SUCCESS=1 + + if command -v curl >/dev/null 2>&1; then + echo "Downloading ${URL}" + curl --fail --silent --location --output "${OUTPUT}" "${URL}" DOWNLOAD_SUCCESS=$? fi if [ "${DOWNLOAD_SUCCESS}" -ne "0" ]; then - echo "seeing if wget is available" - if wget --version 2>&1 >/dev/null; then - wget --quiet -O "src/$2" "$1/$2" + if command -v wget >/dev/null 2>&1; then + echo "Trying wget for ${URL}" + wget --quiet -O "${OUTPUT}" "${URL}" DOWNLOAD_SUCCESS=$? fi fi + if [ "${DOWNLOAD_SUCCESS}" -ne "0" ]; then - printf "Installing jaspSyntax failed because the required file $2 is missing.\n\ + echo "Failed to download: ${URL}" + return 1 + fi + return 0 +} + +function loadFile() { + if ! downloadFile "$1/$2" "src/$2"; then + printf "Installing jaspSyntax failed because the required file %s is missing.\n\ Normally this is downloaded automatically if either curl or wget is available, but apparently this failed.\n\ -Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.args: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))" +Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.args: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))\n" "$2" exit 1 - fi - printf "$2 loaded from $1" + fi +} + +function verifyChecksum() { + # verifyChecksum + # Verifies the checksum of against SHA256SUMS from the GitHub Release. + local FILE_PATH="$1" + local ASSET_NAME="$2" + + if command -v sha256sum >/dev/null 2>&1; then + CHECKSUM_CMD="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + CHECKSUM_CMD="shasum -a 256" + else + echo "Warning: no sha256 tool found, skipping checksum verification" + return 0 + fi + + echo "Verifying checksum for ${ASSET_NAME}..." + if downloadFile "${GITHUB_RELEASE_URL}/SHA256SUMS" "src/SHA256SUMS"; then + EXPECTED=$(grep "${ASSET_NAME}" src/SHA256SUMS | awk '{print $1}') + ACTUAL=$(${CHECKSUM_CMD} "${FILE_PATH}" | awk '{print $1}') + + if [ -z "${EXPECTED}" ]; then + echo "Warning: no checksum found for ${ASSET_NAME} in SHA256SUMS" + elif [ "${ACTUAL}" != "${EXPECTED}" ]; then + echo "Checksum verification FAILED for ${ASSET_NAME}!" + echo " Expected: ${EXPECTED}" + echo " Got: ${ACTUAL}" + rm -f "${FILE_PATH}" src/SHA256SUMS + exit 1 + else + echo "Checksum verified OK" + fi + rm -f src/SHA256SUMS + else + echo "Warning: could not download SHA256SUMS, skipping checksum verification" + fi } if [ "${R_HOME}" ]; then @@ -38,12 +91,14 @@ fi PKG_CXXFLAGS="" +# ---------- Download header files if needed ---------- + if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\"" elif [ ! -f "src/syntaxbridge_interface.h" ]; then if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then - GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/boutinb/jasp-desktop/refs/heads/useJASPModuleInRSyntax" + GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" fi loadFile "${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface" "syntaxbridge_interface.h" @@ -57,47 +112,46 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then done fi +# ---------- Download SyntaxInterface.dll ---------- + SYNTAXINTERFACE_DLL="SyntaxInterface.dll" +SYNTAXINTERFACE_ASSET="SyntaxInterface-windows-x86_64.dll" + if [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" - cp "${JASP_BUILD_DIR}/$SYNTAXINTERFACE_DLL" src/$SYNTAXINTERFACE_DLL + cp "${JASP_BUILD_DIR}/${SYNTAXINTERFACE_DLL}" "src/${SYNTAXINTERFACE_DLL}" elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then - if [ "${JASP_FILE_SERVER}" = "" ]; then - JASP_FILE_SERVER="https://static.jasp-stats.org/jaspSyntax/1.0/" - fi - - loadFile "${JASP_FILE_SERVER}" "${SYNTAXINTERFACE_DLL}" - - if [[ ! $(sha256sum src/${SYNTAXINTERFACE_DLL}) =~ "c9bcbd1e7a2f498e5934b1aa43919f4bc3cac5941992b259b3f338d6fadf8913" ]]; then - echo "Wrong checksum!" - rm src/${SYNTAXINTERFACE_DLL} + echo "Downloading pre-built ${SYNTAXINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." + if ! downloadFile "${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" "src/${SYNTAXINTERFACE_DLL}"; then + echo "ERROR: Could not download ${SYNTAXINTERFACE_ASSET}" + echo " URL: ${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" exit 1 fi + verifyChecksum "src/${SYNTAXINTERFACE_DLL}" "${SYNTAXINTERFACE_ASSET}" fi +# ---------- Download libR-InterfaceNoRInside.dll ---------- + RINTERFACE_DLL="libR-InterfaceNoRInside.dll" +RINTERFACE_ASSET="libR-InterfaceNoRInside-windows-x86_64.dll" + if [[ "${JASP_BUILD_DIR}" ]]; then - cp "${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" src/ + cp "${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" src/ elif [ ! -f "src/${RINTERFACE_DLL}" ]; then - if [ "${JASP_FILE_SERVER}" = "" ]; then - JASP_FILE_SERVER="https://static.jasp-stats.org/jaspSyntax/1.0/" - fi - - loadFile "${JASP_FILE_SERVER}" ${RINTERFACE_DLL} - if [[ ! $(sha256sum src/${RINTERFACE_DLL}) =~ "84917cd81836aaa894c6a98f70ab471eb98ff12c70173616a7ecc87f768ee341" ]]; then - echo "Wrong checksum!" - rm src/${RINTERFACE_DLL} + echo "Downloading pre-built ${RINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." + if ! downloadFile "${GITHUB_RELEASE_URL}/${RINTERFACE_ASSET}" "src/${RINTERFACE_DLL}"; then + echo "ERROR: Could not download ${RINTERFACE_ASSET}" + echo " URL: ${GITHUB_RELEASE_URL}/${RINTERFACE_ASSET}" exit 1 fi + verifyChecksum "src/${RINTERFACE_DLL}" "${RINTERFACE_ASSET}" fi -#PKG_LIBS=${PKG_LIBS}\ "${R_HOME}/bin/x64/R.dll" - PKG_CXXFLAGS=${PKG_CXXFLAGS}\ -DUNICODE\ -DWIN32\ -DWIN32_LEAN_AND_MEAN\ -DWIN64\ -D_ENABLE_EXTENDED_ALIGNED_STORAGE PKG_LIBS="-lSyntaxInterface -llibR-InterfaceNoRInside" -sed -e "s|@cppflags@|${PKG_CXXFLAGS}|" -e "s|@libflags@|${PKG_LIBS}|" src/Makevars.in > src/Makevars +sed -e "s|@cppflags@|${PKG_CXXFLAGS}|" -e "s|@libflags@|${PKG_LIBS}|" -e "s|@rpathflags@||" src/Makevars.in > src/Makevars exit 0 diff --git a/jaspModule.Rproj b/jaspSyntax.Rproj similarity index 100% rename from jaspModule.Rproj rename to jaspSyntax.Rproj diff --git a/src/Makevars.in b/src/Makevars.in index 853cbfc..c9bbbcd 100644 --- a/src/Makevars.in +++ b/src/Makevars.in @@ -1,6 +1,6 @@ PKG_CPPFLAGS = @cppflags@ CXX_STD = CXX20 -PKG_LIBS += -L. @libflags@ +PKG_LIBS += -L. @libflags@ @rpathflags@ all: $(SHLIB) - @if command -v install_name_tool; then install_name_tool -change @rpath/libSyntaxInterface.dylib @loader_path/libSyntaxInterface.dylib $(SHLIB); fi + @if command -v install_name_tool >/dev/null 2>&1; then install_name_tool -change @rpath/libSyntaxInterface.dylib @loader_path/libSyntaxInterface.dylib $(SHLIB); fi From 8544c21744a295586a81ee35f783ad42fded7f55 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 10:20:48 +0200 Subject: [PATCH 02/30] clone qt sources instead of using actinstall-qt --- .github/workflows/build-syntaxinterface.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 19e59c1..a89b64b 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -30,7 +30,7 @@ env: JASP_DESKTOP_REF: ${{ github.event.inputs.jasp_desktop_ref || 'development' }} RELEASE_TAG: ${{ github.event.inputs.release_tag || 'syntaxinterface-libs' }} QT_VERSION: '6.10.1' - QT_SUBMODULES: 'qtbase qtdeclarative qtshadertools' + QT_SUBMODULES: 'qtbase,qtdeclarative,qtshadertools' jobs: @@ -138,24 +138,23 @@ jobs: path: ${{ github.workspace }}/qt-static key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-${{ env.QT_SUBMODULES }} - - name: Install aqtinstall - if: steps.cache-qt.outputs.cache-hit != 'true' - run: pip3 install aqtinstall - - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' shell: bash run: | - aqt install-src --outputdir qt-src "${{ env.QT_VERSION }}" desktop + git clone --depth 1 --branch v${{ env.QT_VERSION }} https://code.qt.io/qt/qt5.git qt-src + cd qt-src + perl init-repository --module-subset=${{ env.QT_SUBMODULES }} - name: Build static Qt (Unix) if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash run: | - cd qt-src/${{ env.QT_VERSION }}/Src + SUBMODULES_SPACE="${QT_SUBMODULES//,/ }" + cd qt-src ./configure -release -static -opensource -confirm-license \ -prefix ${{ github.workspace }}/qt-static \ - -submodules ${{ env.QT_SUBMODULES }} \ + -submodules ${SUBMODULES_SPACE} \ -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . @@ -165,10 +164,11 @@ jobs: shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 - cd qt-src\${{ env.QT_VERSION }}\Src + cd qt-src + set "SUBMODULES_SPACE=%QT_SUBMODULES:,= %" configure.bat -release -static -opensource -confirm-license ^ -prefix ${{ github.workspace }}\qt-static ^ - -submodules ${{ env.QT_SUBMODULES }} + -submodules %SUBMODULES_SPACE% cmake --build . --parallel cmake --install . From 0c687e16657a1385517ec44e72faa3edb3f2503f Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 10:25:04 +0200 Subject: [PATCH 03/30] fix cache key --- .github/workflows/build-syntaxinterface.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index a89b64b..6fbccdd 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -31,6 +31,7 @@ env: RELEASE_TAG: ${{ github.event.inputs.release_tag || 'syntaxinterface-libs' }} QT_VERSION: '6.10.1' QT_SUBMODULES: 'qtbase,qtdeclarative,qtshadertools' + #NOTE: if you change QT_SUBMODULES, also update the cache key below in "Cache static Qt build". We cannot resuse this env var for the cache key because it contains commas. jobs: @@ -136,7 +137,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-${{ env.QT_SUBMODULES }} + key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' From 5be77b8df3b996aca6bf32e5371408264329b91f Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 10:35:57 +0200 Subject: [PATCH 04/30] try to fix qt build errors --- .github/workflows/build-syntaxinterface.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 6fbccdd..b222614 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -151,11 +151,10 @@ jobs: if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash run: | - SUBMODULES_SPACE="${QT_SUBMODULES//,/ }" cd qt-src ./configure -release -static -opensource -confirm-license \ -prefix ${{ github.workspace }}/qt-static \ - -submodules ${SUBMODULES_SPACE} \ + -submodules ${{ env.QT_SUBMODULES }} \ -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . @@ -166,10 +165,9 @@ jobs: run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 cd qt-src - set "SUBMODULES_SPACE=%QT_SUBMODULES:,= %" configure.bat -release -static -opensource -confirm-license ^ -prefix ${{ github.workspace }}\qt-static ^ - -submodules %SUBMODULES_SPACE% + -submodules ${{ env.QT_SUBMODULES }} cmake --build . --parallel cmake --install . @@ -204,6 +202,7 @@ jobs: cmake -G Ninja -S jasp-desktop -B build ^ -DCMAKE_BUILD_TYPE=Release ^ -DCMAKE_PREFIX_PATH=${{ github.workspace }}\qt-static ^ + -DVS_PATH="C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC" ^ -DINSTALL_R_MODULES=OFF ^ -DBUILD_TESTS=OFF From efd7b734359e1470c0552b8d9a32429c1cbee77a Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 11:20:31 +0200 Subject: [PATCH 05/30] slight adjustments to changes in jasp-desktop --- .github/workflows/build-syntaxinterface.yml | 39 ++++++++++++--------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index b222614..a75efeb 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -86,8 +86,7 @@ jobs: libxcb-xinerama0 libxcb-cursor0 \ libboost-dev libboost-filesystem-dev libboost-system-dev \ libboost-date-time-dev libboost-timer-dev libboost-chrono-dev \ - librdata-dev libfreexl-dev libminizip-dev libglpk-dev \ - libjsoncpp-dev libnss3-dev libnspr4-dev \ + libminizip-dev libnss3-dev libnspr4-dev \ libxcomposite-dev libxdamage-dev libxrandr-dev libxtst-dev \ libxi-dev libasound2-dev libxkbfile-dev \ libxcb-icccm4-dev libxcb-shape0-dev libxcb-keysyms1-dev \ @@ -106,18 +105,12 @@ jobs: run: | choco install ninja -y - # ---- Install readstat (Linux) ---- - - - name: Install ReadStat from source (Linux) - if: runner.os == 'Linux' + - name: Install C++ dependencies via vcpkg (Windows) + if: runner.os == 'Windows' + shell: cmd run: | - wget -q https://github.com/WizardMac/ReadStat/releases/download/v1.1.9/readstat-1.1.9.tar.gz - tar -xzf readstat-1.1.9.tar.gz - cd readstat-1.1.9 - ./configure --prefix=/usr/local - make -j$(nproc) CFLAGS='-Wno-error=use-after-free' - sudo make install - sudo ldconfig + call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 + vcpkg install boost-headers boost-filesystem boost-system boost-date-time boost-timer boost-chrono sqlite3 libarchive --triplet x64-windows-static # ---- Setup R ---- @@ -138,6 +131,7 @@ jobs: with: path: ${{ github.workspace }}/qt-static key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + save-always: true - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' @@ -155,6 +149,7 @@ jobs: ./configure -release -static -opensource -confirm-license \ -prefix ${{ github.workspace }}/qt-static \ -submodules ${{ env.QT_SUBMODULES }} \ + -nomake examples -nomake tests -nomake benchmarks \ -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . @@ -165,9 +160,10 @@ jobs: run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 cd qt-src - configure.bat -release -static -opensource -confirm-license ^ + call configure.bat -release -static -opensource -confirm-license ^ -prefix ${{ github.workspace }}\qt-static ^ - -submodules ${{ env.QT_SUBMODULES }} + -submodules ${{ env.QT_SUBMODULES }} ^ + -nomake examples -nomake tests -nomake benchmarks cmake --build . --parallel cmake --install . @@ -186,10 +182,17 @@ jobs: shell: bash run: | R_HOME=$(R RHOME) + # On macOS, add brew prefix paths for keg-only libraries + EXTRA_PREFIX="" + if [ "$(uname)" = "Darwin" ]; then + EXTRA_PREFIX="$(brew --prefix libarchive);$(brew --prefix sqlite);$(brew --prefix openssl)" + fi cmake -G Ninja -S jasp-desktop -B build \ -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_PREFIX_PATH=${{ github.workspace }}/qt-static \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/qt-static;${EXTRA_PREFIX}" \ -DCUSTOM_R_PATH="${R_HOME}" \ + -DREQUIRE_GITHUB_PAT=OFF \ + -DUSE_CONAN=OFF \ -DINSTALL_R_MODULES=OFF \ -DBUILD_TESTS=OFF @@ -202,7 +205,11 @@ jobs: cmake -G Ninja -S jasp-desktop -B build ^ -DCMAKE_BUILD_TYPE=Release ^ -DCMAKE_PREFIX_PATH=${{ github.workspace }}\qt-static ^ + -DCMAKE_TOOLCHAIN_FILE=%VCPKG_INSTALLATION_ROOT%\scripts\buildsystems\vcpkg.cmake ^ + -DVCPKG_TARGET_TRIPLET=x64-windows-static ^ -DVS_PATH="C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC" ^ + -DREQUIRE_GITHUB_PAT=OFF ^ + -DUSE_CONAN=OFF ^ -DINSTALL_R_MODULES=OFF ^ -DBUILD_TESTS=OFF From 75e0119fd3141924903cf8e8d20ac598aa8b9b7f Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 13:04:19 +0200 Subject: [PATCH 06/30] move setup R to after Qt build, also clone submodules of jasp-desktop --- .github/workflows/build-syntaxinterface.yml | 23 +++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index a75efeb..5e61e0f 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -112,17 +112,6 @@ jobs: call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 vcpkg install boost-headers boost-filesystem boost-system boost-date-time boost-timer boost-chrono sqlite3 libarchive --triplet x64-windows-static - # ---- Setup R ---- - - - name: Setup R - uses: r-lib/actions/setup-r@v2 - with: - r-version: 'release' - - - name: Install Rcpp and RInside - run: | - Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' - # ---- Install Qt from source and build static ---- - name: Cache static Qt build @@ -167,6 +156,17 @@ jobs: cmake --build . --parallel cmake --install . + # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- + + - name: Setup R + uses: r-lib/actions/setup-r@v2 + with: + r-version: 'release' + + - name: Install Rcpp and RInside + run: | + Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' + # ---- Clone and build jasp-desktop / SyntaxInterface ---- - name: Clone jasp-desktop @@ -175,6 +175,7 @@ jobs: repository: jasp-stats/jasp-desktop ref: ${{ env.JASP_DESKTOP_REF }} path: jasp-desktop + submodules: true token: ${{ secrets.GITHUB_TOKEN }} - name: Configure jasp-desktop (Unix) From e9b03a5947b82b7abd7e537928469530c96fa982 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 14:03:29 +0200 Subject: [PATCH 07/30] adjust cache steps for v5 --- .github/workflows/build-syntaxinterface.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 5e61e0f..db81235 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -114,13 +114,12 @@ jobs: # ---- Install Qt from source and build static ---- - - name: Cache static Qt build + - name: Restore Qt cache id: cache-qt - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/qt-static key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - save-always: true - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' @@ -156,6 +155,13 @@ jobs: cmake --build . --parallel cmake --install . + - name: Save Qt cache + if: steps.cache-qt.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ github.workspace }}/qt-static + key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- - name: Setup R From 98b52296ce25c79b08783f2c5bd68dcd08feebac Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 15:00:40 +0200 Subject: [PATCH 08/30] some tweaks to make the build leaner and to avoid running out of disk space --- .github/workflows/build-syntaxinterface.yml | 24 +++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index db81235..ae6f180 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -31,7 +31,8 @@ env: RELEASE_TAG: ${{ github.event.inputs.release_tag || 'syntaxinterface-libs' }} QT_VERSION: '6.10.1' QT_SUBMODULES: 'qtbase,qtdeclarative,qtshadertools' - #NOTE: if you change QT_SUBMODULES, also update the cache key below in "Cache static Qt build". We cannot resuse this env var for the cache key because it contains commas. + #NOTE: if you change QT_SUBMODULES or Qt configure flags, also update the cache key below in "Cache static Qt build". + # We cannot reuse the QT_SUBMODULES env var for the cache key because it contains commas. jobs: @@ -73,6 +74,15 @@ jobs: # ---- System dependencies ---- + - name: Free disk space (Linux) + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + df -h / + # Yes, we occasionally get "no space left on device" errors on ubuntu-latest, even though it should have ~14 GB free when clean. + # The above commands are a common workaround to free up some extra space, we should occasionally re-evaluate the necessity of this. + - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | @@ -119,7 +129,7 @@ jobs: uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v2-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' @@ -138,9 +148,12 @@ jobs: -prefix ${{ github.workspace }}/qt-static \ -submodules ${{ env.QT_SUBMODULES }} \ -nomake examples -nomake tests -nomake benchmarks \ + -no-feature-printsupport -no-feature-sql \ + -no-feature-dbus -no-feature-cups \ -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . + cd .. && rm -rf qt-src - name: Build static Qt (Windows) if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -151,16 +164,19 @@ jobs: call configure.bat -release -static -opensource -confirm-license ^ -prefix ${{ github.workspace }}\qt-static ^ -submodules ${{ env.QT_SUBMODULES }} ^ - -nomake examples -nomake tests -nomake benchmarks + -nomake examples -nomake tests -nomake benchmarks ^ + -no-feature-printsupport -no-feature-sql ^ + -no-feature-dbus -no-feature-cups cmake --build . --parallel cmake --install . + cd .. && rmdir /s /q qt-src - name: Save Qt cache if: steps.cache-qt.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v2-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- From f72988fc6ea693768de37d6ff1b28a61f67fa724 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 20:00:25 +0200 Subject: [PATCH 09/30] reduce Qt static build further + try to fix step 'Build SyntaxInterface' on windows --- .github/workflows/build-syntaxinterface.yml | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index ae6f180..f190b0e 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -129,7 +129,7 @@ jobs: uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-v2-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v3-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' @@ -148,9 +148,13 @@ jobs: -prefix ${{ github.workspace }}/qt-static \ -submodules ${{ env.QT_SUBMODULES }} \ -nomake examples -nomake tests -nomake benchmarks \ + -optimize-size \ -no-feature-printsupport -no-feature-sql \ -no-feature-dbus -no-feature-cups \ - -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON + -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DQT_FEATURE_qml_jit=OFF \ + -DQT_FEATURE_qml_debug=OFF \ + -DQT_FEATURE_qml_network=OFF cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . cd .. && rm -rf qt-src @@ -165,8 +169,12 @@ jobs: -prefix ${{ github.workspace }}\qt-static ^ -submodules ${{ env.QT_SUBMODULES }} ^ -nomake examples -nomake tests -nomake benchmarks ^ + -optimize-size ^ -no-feature-printsupport -no-feature-sql ^ - -no-feature-dbus -no-feature-cups + -no-feature-dbus -no-feature-cups ^ + -- -DQT_FEATURE_qml_jit=OFF ^ + -DQT_FEATURE_qml_debug=OFF ^ + -DQT_FEATURE_qml_network=OFF cmake --build . --parallel cmake --install . cd .. && rmdir /s /q qt-src @@ -176,7 +184,7 @@ jobs: uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-v2-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v3-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- @@ -236,10 +244,18 @@ jobs: -DINSTALL_R_MODULES=OFF ^ -DBUILD_TESTS=OFF - - name: Build SyntaxInterface + - name: Build SyntaxInterface (Unix) + if: runner.os != 'Windows' shell: bash run: cmake --build build --target SyntaxInterface --parallel + - name: Build SyntaxInterface (Windows) + if: runner.os == 'Windows' + shell: cmd + run: | + call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 + cmake --build build --target SyntaxInterface --parallel + # ---- Collect and upload artifact ---- - name: Collect library (Unix) From 98e0bfe983acafddfe64845093d19117233688c3 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 21:43:14 +0200 Subject: [PATCH 10/30] add missing boost header on windows --- .github/workflows/build-syntaxinterface.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index f190b0e..77c916d 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -120,7 +120,7 @@ jobs: shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 - vcpkg install boost-headers boost-filesystem boost-system boost-date-time boost-timer boost-chrono sqlite3 libarchive --triplet x64-windows-static + vcpkg install boost-headers boost-filesystem boost-system boost-date-time boost-timer boost-chrono boost-interprocess sqlite3 libarchive --triplet x64-windows-static # ---- Install Qt from source and build static ---- From 0eec6ea8b33dbbf4a19d963519b41decb6098d46 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 22:11:32 +0200 Subject: [PATCH 11/30] stop qt from dynamically linking ICU, and tweaks to configure file to more easily test an artefact --- .github/workflows/build-syntaxinterface.yml | 6 ++++-- configure | 12 +++++++++--- configure.win | 13 +++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 77c916d..0df6dcd 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -129,7 +129,7 @@ jobs: uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-v3-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v4-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' @@ -149,6 +149,7 @@ jobs: -submodules ${{ env.QT_SUBMODULES }} \ -nomake examples -nomake tests -nomake benchmarks \ -optimize-size \ + -no-icu \ -no-feature-printsupport -no-feature-sql \ -no-feature-dbus -no-feature-cups \ -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ @@ -170,6 +171,7 @@ jobs: -submodules ${{ env.QT_SUBMODULES }} ^ -nomake examples -nomake tests -nomake benchmarks ^ -optimize-size ^ + -no-icu ^ -no-feature-printsupport -no-feature-sql ^ -no-feature-dbus -no-feature-cups ^ -- -DQT_FEATURE_qml_jit=OFF ^ @@ -184,7 +186,7 @@ jobs: uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-v3-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-v4-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- diff --git a/configure b/configure index b76b110..76704f9 100755 --- a/configure +++ b/configure @@ -83,8 +83,11 @@ else fi PKG_CXXFLAGS="" -JASP_SOURCE_DIR="" -JASP_BUILD_DIR="" +JASP_SOURCE_DIR="${JASP_SOURCE_DIR:-}" +JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" +# JASPSYNTAX_LIB_PATH: direct path to a pre-built libSyntaxInterface (.so/.dylib) +# Overrides both JASP_BUILD_DIR and the GitHub Release download. +JASPSYNTAX_LIB_PATH="${JASPSYNTAX_LIB_PATH:-}" # ---------- Download header files if needed ---------- @@ -109,7 +112,10 @@ fi # ---------- Download pre-built library if needed ---------- -if [[ "${JASP_BUILD_DIR}" ]]; then +if [[ "${JASPSYNTAX_LIB_PATH}" ]]; then + echo "Using JASPSYNTAX_LIB_PATH: ${JASPSYNTAX_LIB_PATH}" + cp "${JASPSYNTAX_LIB_PATH}" "src/${DLL_NAME}" +elif [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" cp "${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" "src/${DLL_NAME}" elif [ ! -f "src/${DLL_NAME}" ]; then diff --git a/configure.win b/configure.win index b27a3c1..eb4cb02 100644 --- a/configure.win +++ b/configure.win @@ -90,6 +90,10 @@ else fi PKG_CXXFLAGS="" +JASP_SOURCE_DIR="${JASP_SOURCE_DIR:-}" +JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" +# JASPSYNTAX_LIB_DIR: directory containing pre-built SyntaxInterface.dll (and optionally libR-InterfaceNoRInside.dll) +JASPSYNTAX_LIB_DIR="${JASPSYNTAX_LIB_DIR:-}" # ---------- Download header files if needed ---------- @@ -117,7 +121,10 @@ fi SYNTAXINTERFACE_DLL="SyntaxInterface.dll" SYNTAXINTERFACE_ASSET="SyntaxInterface-windows-x86_64.dll" -if [[ "${JASP_BUILD_DIR}" ]]; then +if [[ "${JASPSYNTAX_LIB_DIR}" ]]; then + echo "Using JASPSYNTAX_LIB_DIR: ${JASPSYNTAX_LIB_DIR}" + cp "${JASPSYNTAX_LIB_DIR}/${SYNTAXINTERFACE_DLL}" "src/${SYNTAXINTERFACE_DLL}" +elif [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" cp "${JASP_BUILD_DIR}/${SYNTAXINTERFACE_DLL}" "src/${SYNTAXINTERFACE_DLL}" elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then @@ -135,7 +142,9 @@ fi RINTERFACE_DLL="libR-InterfaceNoRInside.dll" RINTERFACE_ASSET="libR-InterfaceNoRInside-windows-x86_64.dll" -if [[ "${JASP_BUILD_DIR}" ]]; then +if [[ "${JASPSYNTAX_LIB_DIR}" ]] && [ -f "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" ]; then + cp "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" src/ +elif [[ "${JASP_BUILD_DIR}" ]]; then cp "${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" src/ elif [ ! -f "src/${RINTERFACE_DLL}" ]; then echo "Downloading pre-built ${RINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." From b30e934ba2944bc70e3153d9f99eaac943f3cea2 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 22:24:58 +0200 Subject: [PATCH 12/30] install required mingw toolchain through rtools on windows --- .github/workflows/build-syntaxinterface.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 0df6dcd..2c35653 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -195,6 +195,15 @@ jobs: with: r-version: 'release' + - name: Install rtools45 MinGW toolchain (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + # R-Interface on Windows is cross-compiled with MinGW (R uses MinGW, not MSVC). + # setup-r installs rtools45 but the ucrt64 toolchain may not be present. + # See also: jasp-desktop/Docs/development/jasp-build-guide-windows.md + /c/rtools45/usr/bin/pacman.exe -Sy --noconfirm mingw-w64-ucrt-x86_64-toolchain make + - name: Install Rcpp and RInside run: | Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' From d77e58841f6baf951f4b27f5c6cc5d2f8a341180 Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Fri, 17 Apr 2026 23:56:04 +0200 Subject: [PATCH 13/30] try to find the DLLs on windows --- .github/workflows/build-syntaxinterface.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 2c35653..5b44f21 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -279,10 +279,23 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - # On Windows we also need libR-InterfaceNoRInside.dll - cp build/SyntaxInterface/${{ matrix.lib_file }} ${{ matrix.artifact }} - if [ -f build/R-Interface/libR-InterfaceNoRInside.dll ]; then - cp build/R-Interface/libR-InterfaceNoRInside.dll libR-InterfaceNoRInside-windows-x86_64.dll + # Ninja on Windows may place DLLs in build/bin/ or build/SyntaxInterface/ — search for them + echo "--- Searching for SyntaxInterface.dll ---" + find build -name 'SyntaxInterface.dll' -type f + SI_DLL=$(find build -name 'SyntaxInterface.dll' -type f | head -1) + if [ -z "$SI_DLL" ]; then + echo "ERROR: SyntaxInterface.dll not found anywhere under build/" + exit 1 + fi + echo "Found: $SI_DLL" + cp "$SI_DLL" ${{ matrix.artifact }} + + echo "--- Searching for libR-InterfaceNoRInside.dll ---" + find build -name 'libR-InterfaceNoRInside.dll' -type f + RI_DLL=$(find build -name 'libR-InterfaceNoRInside.dll' -type f | head -1) + if [ -n "$RI_DLL" ]; then + echo "Found: $RI_DLL" + cp "$RI_DLL" libR-InterfaceNoRInside-windows-x86_64.dll fi - name: Upload artifact From eb8ba39f31fd3f10ee837d1c9835945db852937c Mon Sep 17 00:00:00 2001 From: Don van den Bergh Date: Sat, 18 Apr 2026 00:48:21 +0200 Subject: [PATCH 14/30] cache vcpkg stuff on windows --- .github/workflows/build-syntaxinterface.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 5b44f21..5c133de 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -115,8 +115,16 @@ jobs: run: | choco install ninja -y - - name: Install C++ dependencies via vcpkg (Windows) + - name: Restore vcpkg cache (Windows) if: runner.os == 'Windows' + id: cache-vcpkg + uses: actions/cache@v5 + with: + path: C:/vcpkg/installed + key: vcpkg-v1-x64-windows-static-boost-sqlite3-libarchive + + - name: Install C++ dependencies via vcpkg (Windows) + if: runner.os == 'Windows' && steps.cache-vcpkg.outputs.cache-hit != 'true' shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 From 834daa1a3ef835bfc28142014465fff7dc5edb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Sun, 19 Apr 2026 08:05:24 +0200 Subject: [PATCH 15/30] Fix Windows source install on tryToBuild Compile the downloaded jsoncpp sources, install the built package and helper DLLs into the package library, and copy compatible MinGW runtime DLLs from a local JASP installation when available. Validated locally with a clean temp-library install and library load on Windows. --- configure.win | 34 +++++++++++++++++++++++++++++++--- src/Makevars.in | 7 ++++++- src/install.libs.R | 16 ++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 src/install.libs.R diff --git a/configure.win b/configure.win index eb4cb02..35276d6 100644 --- a/configure.win +++ b/configure.win @@ -41,7 +41,7 @@ function loadFile() { if ! downloadFile "$1/$2" "src/$2"; then printf "Installing jaspSyntax failed because the required file %s is missing.\n\ Normally this is downloaded automatically if either curl or wget is available, but apparently this failed.\n\ -Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.args: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))\n" "$2" +Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually and specify the path through configure.vars: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))\n" "$2" exit 1 fi } @@ -83,6 +83,21 @@ function verifyChecksum() { fi } +function copyOptionalFile() { + local FILE_NAME="$1" + shift + + for SOURCE_DIR in "$@"; do + if [ -n "${SOURCE_DIR}" ] && [ -f "${SOURCE_DIR}/${FILE_NAME}" ]; then + echo "Copying ${FILE_NAME} from ${SOURCE_DIR}" + cp "${SOURCE_DIR}/${FILE_NAME}" "src/${FILE_NAME}" + return 0 + fi + done + + return 1 +} + if [ "${R_HOME}" ]; then echo "Found R_HOME: ${R_HOME}" else @@ -94,12 +109,17 @@ JASP_SOURCE_DIR="${JASP_SOURCE_DIR:-}" JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" # JASPSYNTAX_LIB_DIR: directory containing pre-built SyntaxInterface.dll (and optionally libR-InterfaceNoRInside.dll) JASPSYNTAX_LIB_DIR="${JASPSYNTAX_LIB_DIR:-}" +JASPSYNTAX_RUNTIME_DIR="${JASPSYNTAX_RUNTIME_DIR:-}" + +if [ -z "${JASPSYNTAX_RUNTIME_DIR}" ] && [ -d "/c/Program Files/JASP" ]; then + JASPSYNTAX_RUNTIME_DIR="/c/Program Files/JASP" +fi # ---------- Download header files if needed ---------- if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" - PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\"" + PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\" -I\"${JASP_SOURCE_DIR}/Common\"" elif [ ! -f "src/syntaxbridge_interface.h" ]; then if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" @@ -156,10 +176,18 @@ elif [ ! -f "src/${RINTERFACE_DLL}" ]; then verifyChecksum "src/${RINTERFACE_DLL}" "${RINTERFACE_ASSET}" fi +RUNTIME_DLLS="libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll" + +for RUNTIME_DLL in ${RUNTIME_DLLS}; do + if [ ! -f "src/${RUNTIME_DLL}" ]; then + copyOptionalFile "${RUNTIME_DLL}" "${JASPSYNTAX_RUNTIME_DIR}" "${JASPSYNTAX_LIB_DIR}" "${JASP_BUILD_DIR}" + fi + done + PKG_CXXFLAGS=${PKG_CXXFLAGS}\ -DUNICODE\ -DWIN32\ -DWIN32_LEAN_AND_MEAN\ -DWIN64\ -D_ENABLE_EXTENDED_ALIGNED_STORAGE -PKG_LIBS="-lSyntaxInterface -llibR-InterfaceNoRInside" +PKG_LIBS="-l:${SYNTAXINTERFACE_DLL} -l:${RINTERFACE_DLL}" sed -e "s|@cppflags@|${PKG_CXXFLAGS}|" -e "s|@libflags@|${PKG_LIBS}|" -e "s|@rpathflags@||" src/Makevars.in > src/Makevars diff --git a/src/Makevars.in b/src/Makevars.in index c9bbbcd..921df0e 100644 --- a/src/Makevars.in +++ b/src/Makevars.in @@ -1,6 +1,11 @@ -PKG_CPPFLAGS = @cppflags@ +PKG_CPPFLAGS = -I. @cppflags@ CXX_STD = CXX20 +JSON_OBJECTS = json/json_reader.o json/json_value.o json/json_writer.o +OBJECTS = RcppExports.o dataframeimporter.o syntaxfunctions.o $(JSON_OBJECTS) PKG_LIBS += -L. @libflags@ @rpathflags@ +json/%.o: json/%.cpp + $(CXX) $(ALL_CPPFLAGS) $(ALL_CXXFLAGS) -c $< -o $@ + all: $(SHLIB) @if command -v install_name_tool >/dev/null 2>&1; then install_name_tool -change @rpath/libSyntaxInterface.dylib @loader_path/libSyntaxInterface.dylib $(SHLIB); fi diff --git a/src/install.libs.R b/src/install.libs.R new file mode 100644 index 0000000..58837ac --- /dev/null +++ b/src/install.libs.R @@ -0,0 +1,16 @@ +package_library <- paste0(R_PACKAGE_NAME, .Platform$dynlib.ext) + +if (!file.exists(package_library)) { + stop(sprintf("Required compiled library '%s' was not found.", package_library)) +} + +files <- unique(c(package_library, Sys.glob(c("*.dll", "*.so", "*.dylib")))) +files <- files[file.exists(files)] + +dest <- file.path(R_PACKAGE_DIR, paste0("libs", R_ARCH)) +dir.create(dest, recursive = TRUE, showWarnings = FALSE) + +ok <- file.copy(files, dest, overwrite = TRUE) +if (!all(ok)) { + stop("Failed to copy compiled libraries into the package libs directory.") +} \ No newline at end of file From 92cb6832e750b0fce02ba9053ab14784a8446213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Sun, 19 Apr 2026 08:31:21 +0200 Subject: [PATCH 16/30] Address PR review feedback on tryToBuild Treat missing runtime DLLs as optional under set -e and populate src/json from JASP_SOURCE_DIR so the review-identified configure paths build correctly. --- configure.win | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/configure.win b/configure.win index 35276d6..f936a94 100644 --- a/configure.win +++ b/configure.win @@ -117,9 +117,16 @@ fi # ---------- Download header files if needed ---------- +JSON_FILES="allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h" + if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\" -I\"${JASP_SOURCE_DIR}/Common\"" + mkdir -p 'src/json' + + for i in ${JSON_FILES}; do + cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" + done elif [ ! -f "src/syntaxbridge_interface.h" ]; then if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" @@ -129,8 +136,6 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then mkdir -p 'src/json' - JSON_FILES="allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h" - for i in ${JSON_FILES}; do loadFile "${GITHUB_JASP_DESKTOP_FILES}/Common" "json/${i}" done @@ -180,9 +185,11 @@ RUNTIME_DLLS="libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll" for RUNTIME_DLL in ${RUNTIME_DLLS}; do if [ ! -f "src/${RUNTIME_DLL}" ]; then - copyOptionalFile "${RUNTIME_DLL}" "${JASPSYNTAX_RUNTIME_DIR}" "${JASPSYNTAX_LIB_DIR}" "${JASP_BUILD_DIR}" + if ! copyOptionalFile "${RUNTIME_DLL}" "${JASPSYNTAX_RUNTIME_DIR}" "${JASPSYNTAX_LIB_DIR}" "${JASP_BUILD_DIR}"; then + echo "Warning: could not locate optional runtime DLL ${RUNTIME_DLL}" + fi fi - done +done PKG_CXXFLAGS=${PKG_CXXFLAGS}\ -DUNICODE\ -DWIN32\ -DWIN32_LEAN_AND_MEAN\ -DWIN64\ -D_ENABLE_EXTENDED_ALIGNED_STORAGE From b801b78358a115927af10eb4035d722a0cce7062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Sun, 19 Apr 2026 09:20:13 +0200 Subject: [PATCH 17/30] Handle additional PR review issues Populate json sources in the non-Windows JASP_SOURCE_DIR path and simplify install.libs.R so the copied library set is clearer without changing behavior. --- configure | 9 +++++++-- src/install.libs.R | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/configure b/configure index 76704f9..2b0d5b5 100755 --- a/configure +++ b/configure @@ -91,9 +91,16 @@ JASPSYNTAX_LIB_PATH="${JASPSYNTAX_LIB_PATH:-}" # ---------- Download header files if needed ---------- +JSON_FILES="allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h" + if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\" -I\"${JASP_SOURCE_DIR}/Common\"" + mkdir -p 'src/json' + + for i in ${JSON_FILES}; do + cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" + done elif [ ! -f "src/syntaxbridge_interface.h" ]; then if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" @@ -103,8 +110,6 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then mkdir -p 'src/json' - JSON_FILES="allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h" - for i in ${JSON_FILES}; do loadFile "${GITHUB_JASP_DESKTOP_FILES}/Common" "json/${i}" done diff --git a/src/install.libs.R b/src/install.libs.R index 58837ac..e52ce3b 100644 --- a/src/install.libs.R +++ b/src/install.libs.R @@ -4,7 +4,8 @@ if (!file.exists(package_library)) { stop(sprintf("Required compiled library '%s' was not found.", package_library)) } -files <- unique(c(package_library, Sys.glob(c("*.dll", "*.so", "*.dylib")))) +shared_libraries <- Sys.glob(c("*.dll", "*.so", "*.dylib")) +files <- unique(c(package_library, shared_libraries)) files <- files[file.exists(files)] dest <- file.path(R_PACKAGE_DIR, paste0("libs", R_ARCH)) From 0d2b368fd4ca717dc2616f4f2604f41612c186d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Tue, 21 Apr 2026 16:10:50 +0200 Subject: [PATCH 18/30] Use local Rtools runtime for Windows installs --- configure.win | 122 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/configure.win b/configure.win index f936a94..c657b22 100644 --- a/configure.win +++ b/configure.win @@ -98,6 +98,111 @@ function copyOptionalFile() { return 1 } +function addRuntimeSearchDir() { + local DIR_PATH="$1" + local KNOWN_DIR + + if [ -z "${DIR_PATH}" ] || [ ! -d "${DIR_PATH}" ]; then + return 0 + fi + + for KNOWN_DIR in "${RUNTIME_SEARCH_DIRS[@]}"; do + if [ "${KNOWN_DIR}" = "${DIR_PATH}" ]; then + return 0 + fi + done + + RUNTIME_SEARCH_DIRS+=("${DIR_PATH}") + return 0 +} + +function discoverLocalRtoolsRuntimeDirs() { + local GCC_PATH="" + local GCC_DIR="" + local RTOOLS_ROOT="" + local ENV_NAME + local ENV_VALUE + local PATH_DIR + local OLD_IFS + + if command -v g++ >/dev/null 2>&1; then + GCC_PATH=$(command -v g++) + GCC_DIR=$(dirname "${GCC_PATH}") + addRuntimeSearchDir "${GCC_DIR}" + + case "${GCC_DIR}" in + */x86_64-w64-mingw32.static.posix/bin) + RTOOLS_ROOT="${GCC_DIR%/x86_64-w64-mingw32.static.posix/bin}" + addRuntimeSearchDir "${RTOOLS_ROOT}/ucrt64/bin" + ;; + */ucrt64/bin) + addRuntimeSearchDir "${GCC_DIR}" + ;; + esac + fi + + for ENV_NAME in RTOOLS45_HOME RTOOLS44_HOME RTOOLS43_HOME RTOOLS42_HOME; do + ENV_VALUE=$(printenv "${ENV_NAME}" 2>/dev/null || true) + addRuntimeSearchDir "${ENV_VALUE}" + addRuntimeSearchDir "${ENV_VALUE}/bin" + addRuntimeSearchDir "${ENV_VALUE}/ucrt64/bin" + case "${ENV_VALUE}" in + */ucrt64) + addRuntimeSearchDir "${ENV_VALUE}/bin" + ;; + esac + done + + for RTOOLS_ROOT in /c/rtools45 /c/rtools44 /c/rtools43 /c/rtools42; do + addRuntimeSearchDir "${RTOOLS_ROOT}/ucrt64/bin" + done + + OLD_IFS="${IFS}" + IFS=':' + for PATH_DIR in ${PATH}; do + case "${PATH_DIR}" in + *rtools*/ucrt64/bin) + addRuntimeSearchDir "${PATH_DIR}" + ;; + *rtools*/x86_64-w64-mingw32.static.posix/bin) + RTOOLS_ROOT="${PATH_DIR%/x86_64-w64-mingw32.static.posix/bin}" + addRuntimeSearchDir "${RTOOLS_ROOT}/ucrt64/bin" + ;; + esac + done + IFS="${OLD_IFS}" +} + +function requireRuntimeFile() { + local FILE_NAME="$1" + shift + local SEARCH_DIR + + if [ -f "src/${FILE_NAME}" ]; then + return 0 + fi + + if copyOptionalFile "${FILE_NAME}" "$@"; then + return 0 + fi + + printf "Installing jaspSyntax failed because the required runtime DLL %s could not be located.\n\ +The downloaded Windows binaries require the matching local Rtools UCRT runtime.\n\ +Install the Rtools UCRT toolchain for your R version or set configure.vars, for example:\n\ +options(configure.vars = c(jaspSyntax = \"JASPSYNTAX_RUNTIME_DIR='/ucrt64/bin'\"))\n" "${FILE_NAME}" + + if [ "$#" -gt 0 ]; then + echo "Searched runtime directories:" + for SEARCH_DIR in "$@"; do + if [ -n "${SEARCH_DIR}" ]; then + echo " ${SEARCH_DIR}" + fi + done + fi + + exit 1 +} + if [ "${R_HOME}" ]; then echo "Found R_HOME: ${R_HOME}" else @@ -110,10 +215,7 @@ JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" # JASPSYNTAX_LIB_DIR: directory containing pre-built SyntaxInterface.dll (and optionally libR-InterfaceNoRInside.dll) JASPSYNTAX_LIB_DIR="${JASPSYNTAX_LIB_DIR:-}" JASPSYNTAX_RUNTIME_DIR="${JASPSYNTAX_RUNTIME_DIR:-}" - -if [ -z "${JASPSYNTAX_RUNTIME_DIR}" ] && [ -d "/c/Program Files/JASP" ]; then - JASPSYNTAX_RUNTIME_DIR="/c/Program Files/JASP" -fi +RUNTIME_SEARCH_DIRS=() # ---------- Download header files if needed ---------- @@ -183,12 +285,14 @@ fi RUNTIME_DLLS="libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll" +addRuntimeSearchDir "${JASPSYNTAX_RUNTIME_DIR}" +addRuntimeSearchDir "${JASPSYNTAX_LIB_DIR}" +addRuntimeSearchDir "${JASP_BUILD_DIR}" +addRuntimeSearchDir "${JASP_BUILD_DIR}/R-Interface" +discoverLocalRtoolsRuntimeDirs + for RUNTIME_DLL in ${RUNTIME_DLLS}; do - if [ ! -f "src/${RUNTIME_DLL}" ]; then - if ! copyOptionalFile "${RUNTIME_DLL}" "${JASPSYNTAX_RUNTIME_DIR}" "${JASPSYNTAX_LIB_DIR}" "${JASP_BUILD_DIR}"; then - echo "Warning: could not locate optional runtime DLL ${RUNTIME_DLL}" - fi - fi + requireRuntimeFile "${RUNTIME_DLL}" "${RUNTIME_SEARCH_DIRS[@]}" done From 40d7183435e56853ac8eb9ee017e8d81a70195fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 23 Apr 2026 14:01:57 +0200 Subject: [PATCH 19/30] Add a .jasp dataset reader API Add readDatasetFromJaspFile() as a public jaspSyntax entry point for materializing the first dataset in a saved .jasp file as an R data.frame. The implementation validates inputs, reuses the existing jaspSyntax bridge load/read path, decodes column names, and normalizes bridge-returned factor columns so numeric-like categorical data comes back as numeric/integer while text-labeled categorical data comes back as character. The public function runs the native dataset load/read path in a short-lived Rscript subprocess instead of in the caller session. This keeps the API stable on the current branch because repeated in-process reads were triggering native transaction unwind spam and stack-imbalance behavior after loading datasets from .jasp files. This commit also adds Rd documentation for the new function and documents the current limitation that only dataSetIndex = 1L is supported by the bridge-backed implementation. --- R/readDatasetFromJaspFile.R | 151 +++++++++++++++++++++++++++++++++ man/readDatasetFromJaspFile.Rd | 28 ++++++ 2 files changed, 179 insertions(+) create mode 100644 R/readDatasetFromJaspFile.R create mode 100644 man/readDatasetFromJaspFile.Rd diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R new file mode 100644 index 0000000..93959e1 --- /dev/null +++ b/R/readDatasetFromJaspFile.R @@ -0,0 +1,151 @@ +.validateReadDatasetFromJaspFileArgs <- function(jaspFilePath, dataSetIndex) { + if (!is.character(jaspFilePath) || length(jaspFilePath) != 1L || is.na(jaspFilePath)) { + stop("`jaspFilePath` must be a single string") + } + + if (!file.exists(jaspFilePath)) { + stop("File not found: ", jaspFilePath) + } + + if (!grepl("\\.jasp$", jaspFilePath, ignore.case = TRUE)) { + stop("File must have a .jasp extension") + } + + if (length(dataSetIndex) != 1L || is.na(dataSetIndex) || dataSetIndex != as.integer(dataSetIndex) || dataSetIndex < 1L) { + stop("`dataSetIndex` must be a single positive integer") + } + + dataSetIndex <- as.integer(dataSetIndex) + if (dataSetIndex != 1L) { + stop("Only `dataSetIndex = 1L` is currently supported by the jaspSyntax bridge") + } + + list( + jaspFilePath = jaspFilePath, + dataSetIndex = dataSetIndex + ) +} + +.readDatasetFromJaspFileInProcess <- function(jaspFilePath, dataSetIndex = 1L) { + args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) + jaspFilePath <- args$jaspFilePath + dataSetIndex <- args$dataSetIndex + + cleanUp() + on.exit(cleanUp(), add = TRUE) + + loadDataSetFromJaspFile(jaspFilePath) + + readFullDataSet <- get0(".readFullDatasetToEnd", envir = .GlobalEnv, inherits = FALSE) + if (!is.function(readFullDataSet)) { + stop("jaspSyntax bridge did not expose `.readFullDatasetToEnd`") + } + + dataset <- readFullDataSet() + if (!is.data.frame(dataset)) { + stop("jaspSyntax bridge returned an unexpected dataset object") + } + + if (ncol(dataset) == 0L) { + return(NULL) + } + + decodeName <- get0(".decodeColNamesStrict", envir = .GlobalEnv, inherits = FALSE) + if (is.function(decodeName)) { + names(dataset) <- vapply(names(dataset), function(columnName) { + as.character(decodeName(columnName)) + }, character(1L)) + } + + dataset[] <- lapply(dataset, .normalizeBridgeColumn) + + dataset +} + +.getRscriptBinary <- function() { + file.path(R.home("bin"), if (.Platform$OS.type == "windows") "Rscript.exe" else "Rscript") +} + +.normalizeBridgeColumn <- function(column) { + if (!is.factor(column)) { + return(column) + } + + levelValues <- levels(column) + numericLevels <- suppressWarnings(as.numeric(levelValues)) + + if (length(levelValues) > 0L && !any(is.na(numericLevels))) { + numericValues <- suppressWarnings(as.numeric(as.character(column))) + nonMissing <- !is.na(numericValues) + + if (all(numericValues[nonMissing] == as.integer(numericValues[nonMissing]))) { + return(as.integer(numericValues)) + } + + return(numericValues) + } + + as.character(column) +} + +.runReadDatasetSubprocess <- function(jaspFilePath, dataSetIndex) { + scriptPath <- tempfile("jaspSyntax_read_dataset_", fileext = ".R") + outputPath <- tempfile("jaspSyntax_read_dataset_", fileext = ".rds") + on.exit(unlink(c(scriptPath, outputPath)), add = TRUE) + + script <- c( + "args <- commandArgs(trailingOnly = TRUE)", + "jaspFilePath <- args[[1L]]", + "dataSetIndex <- as.integer(args[[2L]])", + "outputPath <- args[[3L]]", + "libPaths <- if (length(args) > 3L) args[4:length(args)] else character(0)", + "if (length(libPaths) > 0L) .libPaths(c(libPaths, .libPaths()))", + "result <- tryCatch(local({", + " suppressPackageStartupMessages(library(jaspSyntax))", + " getNamespace('jaspSyntax')[['.readDatasetFromJaspFileInProcess']](jaspFilePath, dataSetIndex)", + "}), error = function(e) structure(list(message = conditionMessage(e)), class = 'jaspSyntax_subprocess_error'))", + "saveRDS(result, outputPath)" + ) + + writeLines(script, scriptPath) + + output <- system2( + .getRscriptBinary(), + args = c("--vanilla", scriptPath, jaspFilePath, as.character(dataSetIndex), outputPath, .libPaths()), + stdout = TRUE, + stderr = TRUE + ) + + status <- attr(output, "status") + if (!file.exists(outputPath)) { + stop( + "readDatasetFromJaspFile failed before producing a result.", + if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" + ) + } + + result <- readRDS(outputPath) + if (inherits(result, "jaspSyntax_subprocess_error")) { + stop( + "readDatasetFromJaspFile failed: ", + result$message, + if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" + ) + } + + if (!is.null(status) && status != 0L) { + stop( + "readDatasetFromJaspFile failed with exit status ", + status, + ".", + if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" + ) + } + + result +} + +readDatasetFromJaspFile <- function(jaspFilePath, dataSetIndex = 1L) { + args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) + .runReadDatasetSubprocess(args$jaspFilePath, args$dataSetIndex) +} diff --git a/man/readDatasetFromJaspFile.Rd b/man/readDatasetFromJaspFile.Rd new file mode 100644 index 0000000..fad856b --- /dev/null +++ b/man/readDatasetFromJaspFile.Rd @@ -0,0 +1,28 @@ +\name{readDatasetFromJaspFile} +\alias{readDatasetFromJaspFile} +\title{Read a Dataset from a JASP File} +\usage{ +readDatasetFromJaspFile(jaspFilePath, dataSetIndex = 1L) +} +\arguments{ +\item{jaspFilePath}{Character scalar path to a \code{.jasp} file.} + +\item{dataSetIndex}{1-based dataset index inside the \code{.jasp} file. Currently only \code{1L} is supported.} +} +\value{ +Either a \code{data.frame} containing the dataset or \code{NULL} when the file does not contain tabular data. +} +\description{ +Reads the dataset stored inside a saved JASP file and materializes it as an R \code{data.frame}. +} +\details{ +This function isolates the native jaspSyntax dataset-loading path in a short-lived subprocess so repeated calls remain safe within the calling R session. + +Categorical columns are returned as character vectors. Numeric columns are returned using the bridge's native conversion. +} +\examples{ +\dontrun{ +dataset <- readDatasetFromJaspFile("path/to/analysis.jasp") +str(dataset) +} +} From 0739329d4054bee24bf02676d7e19b45b7935689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 23 Apr 2026 14:45:39 +0200 Subject: [PATCH 20/30] Harden bridge error handling in syntaxfunctions Wrap bridge-facing calls in syntaxfunctions.cpp so native exceptions and malformed bridge responses are turned into explicit R errors instead of being silently parsed or assumed valid. This adds a small helper for exception-safe bridge calls and a JSON parsing helper that rejects null pointers and invalid JSON. The affected entry points are parseDescription(), loadDataSetFromJaspFile(), analysisOptionsFromJaspFile(), and getVariableNames(). The change does not alter the public API shape. It makes failures in the current bridge-backed implementation easier to diagnose and safer to surface in R. --- src/syntaxfunctions.cpp | 64 +++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/syntaxfunctions.cpp b/src/syntaxfunctions.cpp index e942926..8e83e89 100644 --- a/src/syntaxfunctions.cpp +++ b/src/syntaxfunctions.cpp @@ -27,6 +27,36 @@ static bool global_param_dbInMemory = false; static bool global_param_orderLabelsByValue = true; static int global_param_threshold = 10; +template +auto callBridgeOrStop(const char * functionName, Func func) -> decltype(func()) +{ + try + { + return func(); + } + catch (const std::exception & exception) + { + Rcpp::stop("%s failed: %s", functionName, exception.what()); + } + catch (...) + { + Rcpp::stop("%s failed with an unknown exception.", functionName); + } +} + +Json::Value parseBridgeJsonOrStop(const char * rawJson, const char * functionName) +{ + if (rawJson == nullptr) + Rcpp::stop("%s returned a null pointer.", functionName); + + Json::Value parsedJson; + Json::Reader reader; + if (!reader.parse(rawJson, parsedJson)) + Rcpp::stop("%s returned invalid JSON.", functionName); + + return parsedJson; +} + // [[Rcpp::export]] void cleanUp() { @@ -92,10 +122,12 @@ Rcpp::List parseDescription(String modulePath) { std::string modulePathStr = modulePath.get_cstring(); - std::string rawDescription = syntaxBridgeParseDescription(modulePathStr.c_str()); - - Json::Value parsedDescription; - Json::Reader().parse(rawDescription, parsedDescription); + Json::Value parsedDescription = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeParseDescription", [&]() { + return syntaxBridgeParseDescription(modulePathStr.c_str()); + }), + "syntaxBridgeParseDescription" + ); Rcpp::List result; @@ -135,7 +167,10 @@ void loadDataSetFromJaspFile(String jaspFilePath) { std::string jaspFilePathStr = jaspFilePath.get_cstring(); - syntaxBridgeLoadDataSetFromJaspFile(jaspFilePathStr.c_str(), global_param_dbInMemory); + callBridgeOrStop("syntaxBridgeLoadDataSetFromJaspFile", [&]() { + syntaxBridgeLoadDataSetFromJaspFile(jaspFilePathStr.c_str(), global_param_dbInMemory); + return 0; + }); } Rcpp::List transformJsonObjectToRcppList(const Json::Value & json); @@ -194,10 +229,12 @@ Rcpp::List analysisOptionsFromJaspFile(String jaspFilePath, int analysisNr) { std::string jaspFilePathStr = jaspFilePath.get_cstring(); - std::string rawOptions = syntaxBridgeAnalysisOptionsFromJaspFile(jaspFilePathStr.c_str(), analysisNr); - - Json::Value parsedOptions; - Json::Reader().parse(rawOptions, parsedOptions); + Json::Value parsedOptions = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeAnalysisOptionsFromJaspFile", [&]() { + return syntaxBridgeAnalysisOptionsFromJaspFile(jaspFilePathStr.c_str(), analysisNr); + }), + "syntaxBridgeAnalysisOptionsFromJaspFile" + ); return transformJsonObjectToRcppList(parsedOptions); } @@ -214,9 +251,12 @@ String generateAnalysisWrapper(String modulePath, String analysisName) // [[Rcpp::export]] Rcpp::List getVariableNames() { - std::string rawNames = syntaxBridgeGetVariableNames(); - Json::Value parsedNames; - Json::Reader().parse(rawNames, parsedNames); + Json::Value parsedNames = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeGetVariableNames", [&]() { + return syntaxBridgeGetVariableNames(); + }), + "syntaxBridgeGetVariableNames" + ); Rcpp::List result; for (const Json::Value & parsedName : parsedNames) From fea5084332b0eb64bfc432db8c7f0f5c659b70cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 13:56:57 +0200 Subject: [PATCH 21/30] Add native JASP bridge helpers --- .gitattributes | 3 + .gitignore | 1 + DESCRIPTION | 14 +- NAMESPACE | 31 +- R/RcppExports.R | 12 + R/bridgeSubprocess.R | 139 +++ R/lifecycle.R | 81 ++ R/options.R | 842 ++++++++++++++++++ R/readDatasetFromJaspFile.R | 360 ++++++-- R/resultDecoding.R | 158 ++++ R/zzz.R | 84 ++ README.md | 27 +- configure | 59 +- configure.win | 459 +++++++++- man/anRpackage-package.Rd | 34 - man/clearQmlForms.Rd | 24 + man/datasetBridgeHelpers.Rd | 93 ++ man/decodeAnalysisResults.Rd | 26 + man/jaspSyntax-package.Rd | 54 ++ man/nativeBridge.Rd | 82 ++ man/nativeBridgeProvenance.Rd | 20 + man/parseQmlOptions.Rd | 50 ++ man/readAnalysisOptionsFromJaspFile.Rd | 32 + man/readAnalysisOptionsFromQml.Rd | 50 ++ man/readDatasetFromJaspFile.Rd | 5 +- man/readDefaultAnalysisOptions.Rd | 29 + man/readModuleDescription.Rd | 20 + man/resolveAnalysisQml.Rd | 17 + src/RcppExports.cpp | 30 + src/dataframeimporter.cpp | 28 +- src/dataframeimporter.h | 4 +- src/install.libs.R | 13 +- src/syntaxfunctions.cpp | 167 ++-- tests/testthat.R | 4 +- .../descriptivesReplayModule/DESCRIPTION | 8 + .../descriptivesReplayModule/NAMESPACE | 1 + .../inst/Description.qml | 18 + .../inst/qml/Descriptives.qml | 13 + .../jasp-files/descriptives-sleep.jasp | Bin 0 -> 137301 bytes .../fixtures/minimalModule/DESCRIPTION | 9 + .../testthat/fixtures/minimalModule/NAMESPACE | 1 + .../minimalModule/inst/Description.qml | 32 + .../minimalModule/inst/icons/.gitkeep | 1 + .../inst/qml/DefaultAnalysis.qml | 13 + .../inst/qml/MinimalAnalysis.qml | 39 + .../inst/qml/VariableAnalysis.qml | 20 + tests/testthat/test-dataset-helpers.R | 670 ++++++++++++++ tests/testthat/test-desktop-jasp-contract.R | 140 +++ tests/testthat/test-jasp-file-options.R | 630 +++++++++++++ tests/testthat/test-module-options.R | 158 ++++ tools/check-syntaxinterface-symbols.sh | 212 +++++ 51 files changed, 4823 insertions(+), 194 deletions(-) create mode 100644 .gitattributes create mode 100644 R/bridgeSubprocess.R create mode 100644 R/lifecycle.R create mode 100644 R/options.R create mode 100644 R/resultDecoding.R create mode 100644 R/zzz.R delete mode 100644 man/anRpackage-package.Rd create mode 100644 man/clearQmlForms.Rd create mode 100644 man/datasetBridgeHelpers.Rd create mode 100644 man/decodeAnalysisResults.Rd create mode 100644 man/jaspSyntax-package.Rd create mode 100644 man/nativeBridge.Rd create mode 100644 man/nativeBridgeProvenance.Rd create mode 100644 man/parseQmlOptions.Rd create mode 100644 man/readAnalysisOptionsFromJaspFile.Rd create mode 100644 man/readAnalysisOptionsFromQml.Rd create mode 100644 man/readDefaultAnalysisOptions.Rd create mode 100644 man/readModuleDescription.Rd create mode 100644 man/resolveAnalysisQml.Rd create mode 100644 tests/testthat/fixtures/descriptivesReplayModule/DESCRIPTION create mode 100644 tests/testthat/fixtures/descriptivesReplayModule/NAMESPACE create mode 100644 tests/testthat/fixtures/descriptivesReplayModule/inst/Description.qml create mode 100644 tests/testthat/fixtures/descriptivesReplayModule/inst/qml/Descriptives.qml create mode 100644 tests/testthat/fixtures/jasp-files/descriptives-sleep.jasp create mode 100644 tests/testthat/fixtures/minimalModule/DESCRIPTION create mode 100644 tests/testthat/fixtures/minimalModule/NAMESPACE create mode 100644 tests/testthat/fixtures/minimalModule/inst/Description.qml create mode 100644 tests/testthat/fixtures/minimalModule/inst/icons/.gitkeep create mode 100644 tests/testthat/fixtures/minimalModule/inst/qml/DefaultAnalysis.qml create mode 100644 tests/testthat/fixtures/minimalModule/inst/qml/MinimalAnalysis.qml create mode 100644 tests/testthat/fixtures/minimalModule/inst/qml/VariableAnalysis.qml create mode 100644 tests/testthat/test-dataset-helpers.R create mode 100644 tests/testthat/test-desktop-jasp-contract.R create mode 100644 tests/testthat/test-jasp-file-options.R create mode 100644 tests/testthat/test-module-options.R create mode 100644 tools/check-syntaxinterface-symbols.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6d7ea0d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +configure text eol=lf +configure.win text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index f631891..6568099 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ Thumbs.db src/Makevars src/Makevars.win src/syntaxbridge_interface.h +src/SyntaxInterface.provenance src/json/* diff --git a/DESCRIPTION b/DESCRIPTION index 4a16b5e..a2435c8 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,16 +1,22 @@ Package: jaspSyntax Type: Package Title: Makes JASP analyses available in R -Version: 1.2 -Date: 2020-12-01 +Version: 1.3.2 +Date: 2026-05-11 Author: JASP Team Website: jasp-stats.org Maintainer: JASP Team -Description: Set up the right options for the analysis by loading its QML Form, and set up the R environment so that it can run the analysis as if it was run by JASP +Description: Exposes the native JASP SyntaxInterface bridge to R so package code can parse module descriptions, replay QML option binding, load datasets, and read saved .jasp files using the same runtime preparation path as JASP Desktop. License: GPL (>= 2) Encoding: UTF-8 URL: https://github.com/jasp-stats/jaspSyntax BugReports: https://github.com/jasp-stats/jaspSyntax/issues SystemRequirements: libcurl or wget (for downloading the SyntaxInterface library during installation) -Imports: Rcpp (>= 1.0.5) +Imports: + jsonlite, + Rcpp (>= 1.0.5) +Suggests: + pkgload, + testthat LinkingTo: Rcpp +RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index b1a883f..98bd8ab 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,3 +1,32 @@ useDynLib(jaspSyntax, .registration=TRUE) -exportPattern("^[[:alpha:]]+") importFrom(Rcpp, evalCpp) +export(analysisOptionsFromJaspFile) +export(analysisOptionsFromQml) +export(clearDatasetState) +export(clearNativeState) +export(clearQmlForms) +export(cleanUp) +export(columnMapping) +export(decodeAnalysisResults) +export(decodeColumnNames) +export(generateAnalysisWrapper) +export(generateModuleWrappers) +export(getVariableNames) +export(loadAnalysisDataset) +export(loadDataSet) +export(loadDataSetFromJaspFile) +export(loadQmlAndParseOptions) +export(nativeBridgeProvenance) +export(parseDescription) +export(parseModuleDescription) +export(parseQmlOptions) +export(readAnalysisOptionsFromJaspFile) +export(readAnalysisOptionsFromQml) +export(readDatasetHeader) +export(readDatasetFromJaspFile) +export(readDefaultAnalysisOptions) +export(readLoadedDataset) +export(readModuleDescription) +export(readRequestedDataset) +export(resolveAnalysisQml) +export(setParameter) diff --git a/R/RcppExports.R b/R/RcppExports.R index 24b59c7..758a792 100644 --- a/R/RcppExports.R +++ b/R/RcppExports.R @@ -5,6 +5,18 @@ cleanUp <- function() { invisible(.Call(`_jaspSyntax_cleanUp`)) } +clearQmlFormsNative <- function() { + invisible(.Call(`_jaspSyntax_clearQmlFormsNative`)) +} + +clearDatasetStateNative <- function() { + invisible(.Call(`_jaspSyntax_clearDatasetStateNative`)) +} + +clearNativeStateNative <- function() { + invisible(.Call(`_jaspSyntax_clearNativeStateNative`)) +} + setParameter <- function(name, value) { .Call(`_jaspSyntax_setParameter`, name, value) } diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R new file mode 100644 index 0000000..ad1c2c7 --- /dev/null +++ b/R/bridgeSubprocess.R @@ -0,0 +1,139 @@ +.getRscriptBinary <- function() { + file.path(R.home("bin"), if (.Platform$OS.type == "windows") "Rscript.exe" else "Rscript") +} + +.isSourceCheckoutPath <- function(packagePath) { + packagePath <- normalizePath(packagePath, winslash = "/", mustWork = FALSE) + + file.exists(file.path(packagePath, "DESCRIPTION")) && + dir.exists(file.path(packagePath, "R")) && + file.exists(file.path(packagePath, "src", "syntaxfunctions.cpp")) +} + +.bridgeSubprocessPackageSpec <- function() { + packagePath <- normalizePath( + getNamespaceInfo(asNamespace("jaspSyntax"), "path"), + winslash = "/", + mustWork = FALSE + ) + + list( + packagePath = packagePath, + sourceCheckout = .isSourceCheckoutPath(packagePath), + libPaths = .libPaths() + ) +} + +.bridgeSubprocessPackageLoaderScript <- function() { + c( + "loadJaspSyntaxForSubprocess <- function(packageSpec) {", + " libPaths <- packageSpec$libPaths", + " if (length(libPaths) > 0L) .libPaths(c(libPaths, .libPaths()))", + " packagePath <- packageSpec$packagePath", + " if (isTRUE(packageSpec$sourceCheckout)) {", + " dllDirs <- c(file.path(packagePath, 'src'), file.path(packagePath, 'libs', R.version$arch), file.path(packagePath, 'libs'))", + " dllDirs <- dllDirs[dir.exists(dllDirs)]", + " if (.Platform$OS.type == 'windows' && length(dllDirs) > 0L) {", + " Sys.setenv(PATH = paste(c(dllDirs, Sys.getenv('PATH')), collapse = .Platform$path.sep))", + " }", + " if (!requireNamespace('pkgload', quietly = TRUE)) {", + " stop('pkgload is required to load source-checkout jaspSyntax in a subprocess')", + " }", + " suppressPackageStartupMessages(pkgload::load_all(packagePath, quiet = TRUE, recompile = FALSE))", + " } else {", + " suppressPackageStartupMessages(library(jaspSyntax))", + " }", + "}" + ) +} + +.readBridgeSubprocessOutput <- function(stdoutPath, stderrPath) { + c( + if (file.exists(stdoutPath)) readLines(stdoutPath, warn = FALSE) else character(0), + if (file.exists(stderrPath)) readLines(stderrPath, warn = FALSE) else character(0) + ) +} + +.bridgeSubprocessOutputSuffix <- function(output) { + if (length(output) > 0L) { + paste0("\n", paste(output, collapse = "\n")) + } else { + "" + } +} + +.runBridgeSubprocess <- function(task, target, input, failureLabel) { + scriptPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".R") + inputPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".rds") + outputPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".rds") + stdoutPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".out") + stderrPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".err") + on.exit(unlink(c(scriptPath, inputPath, outputPath, stdoutPath, stderrPath)), add = TRUE) + + saveRDS( + list( + input = input, + target = target, + packageSpec = .bridgeSubprocessPackageSpec() + ), + inputPath + ) + + script <- c( + "args <- commandArgs(trailingOnly = TRUE)", + "inputPath <- args[[1L]]", + "outputPath <- args[[2L]]", + .bridgeSubprocessPackageLoaderScript(), + "payload <- readRDS(inputPath)", + "input <- payload$input", + "target <- payload$target", + "packageSpec <- payload$packageSpec", + "result <- tryCatch(local({", + " loadJaspSyntaxForSubprocess(packageSpec)", + " do.call(getNamespace('jaspSyntax')[[target]], input)", + "}), error = function(e) structure(list(message = conditionMessage(e)), class = 'jaspSyntax_subprocess_error'))", + "saveRDS(result, outputPath)" + ) + + writeLines(script, scriptPath) + + status <- system2( + .getRscriptBinary(), + args = c("--vanilla", scriptPath, inputPath, outputPath), + stdout = stdoutPath, + stderr = stderrPath + ) + + output <- .readBridgeSubprocessOutput(stdoutPath, stderrPath) + outputSuffix <- .bridgeSubprocessOutputSuffix(output) + + if (!file.exists(outputPath)) { + stop( + failureLabel, " failed before producing a result.", + outputSuffix, + call. = FALSE + ) + } + + result <- readRDS(outputPath) + if (inherits(result, "jaspSyntax_subprocess_error")) { + stop( + failureLabel, " failed: ", + result$message, + outputSuffix, + call. = FALSE + ) + } + + if (!is.null(status) && status != 0L) { + stop( + failureLabel, " failed with exit status ", + status, + ".", + outputSuffix, + call. = FALSE + ) + } + + result +} diff --git a/R/lifecycle.R b/R/lifecycle.R new file mode 100644 index 0000000..41008e4 --- /dev/null +++ b/R/lifecycle.R @@ -0,0 +1,81 @@ +#' Native Bridge Lifecycle Helpers +#' +#' These helpers give downstream packages explicit names for the native state +#' they intend to clear. `clearQmlForms()` clears cached QML forms and the QML +#' component cache, `clearDatasetState()` clears bridge-owned dataset state, and +#' `clearNativeState()` clears both. +#' +#' @return Invisibly returns `NULL`. +#' +#' @export +clearQmlForms <- function() { + clearQmlFormsNative() + invisible(NULL) +} + +#' @rdname clearQmlForms +#' @export +clearDatasetState <- function() { + clearDatasetStateNative() + invisible(NULL) +} + +#' @rdname clearQmlForms +#' @export +clearNativeState <- function() { + clearNativeStateNative() + invisible(NULL) +} + +.nativeBridgeProvenancePaths <- function() { + namespacePath <- getNamespaceInfo("jaspSyntax", "path") + rArch <- sub("^/", "", .Platform$r_arch) + + unique(c( + file.path(namespacePath, "libs", rArch, "SyntaxInterface.provenance"), + file.path(namespacePath, "libs", "SyntaxInterface.provenance"), + file.path(namespacePath, "src", "SyntaxInterface.provenance"), + file.path(namespacePath, "inst", "libs", "SyntaxInterface.provenance") + )) +} + +.readNativeBridgeProvenance <- function(path) { + lines <- readLines(path, warn = FALSE) + lines <- trimws(lines) + lines <- lines[nzchar(lines) & !startsWith(lines, "#")] + + values <- strsplit(lines, "=", fixed = TRUE) + values <- values[lengths(values) >= 2L] + if (length(values) == 0L) { + return(structure(character(), path = normalizePath(path, winslash = "/", mustWork = FALSE))) + } + + keys <- vapply(values, `[[`, character(1L), 1L) + vals <- vapply(values, function(value) paste(value[-1L], collapse = "="), character(1L)) + vals <- stats::setNames(vals, keys) + structure(vals, path = normalizePath(path, winslash = "/", mustWork = FALSE)) +} + +#' Read Native Bridge Provenance +#' +#' Returns installation metadata for the bundled SyntaxInterface bridge, when +#' the package was installed by a configure script that recorded it. This is a +#' diagnostic helper for checking whether the header and native binary came from +#' the same Desktop/build source. Recent installs also record SHA-256 hashes for +#' the copied header and binary. +#' +#' @return A named character vector. The `path` attribute points to the +#' provenance file. An empty vector means the installed package did not record +#' provenance. +#' +#' @export +nativeBridgeProvenance <- function() { + paths <- .nativeBridgeProvenancePaths() + path <- paths[file.exists(paths)][1L] + + if (is.na(path)) { + return(structure(character(), path = NA_character_)) + } + + .readNativeBridgeProvenance(path) +} diff --git a/R/options.R b/R/options.R new file mode 100644 index 0000000..bd6eccc --- /dev/null +++ b/R/options.R @@ -0,0 +1,842 @@ +.validateScalarString <- function(x, name) { + if (!is.character(x) || length(x) != 1L || is.na(x) || !nzchar(x)) { + stop("`", name, "` must be a single non-empty string", call. = FALSE) + } + + x +} + +.validateModulePath <- function(modulePath) { + modulePath <- .validateScalarString(modulePath, "modulePath") + modulePath <- normalizePath(modulePath, winslash = "/", mustWork = FALSE) + + if (file.exists(modulePath) && !dir.exists(modulePath)) { + if (!identical(basename(modulePath), "Description.qml")) { + stop("`modulePath` must be a module directory or Description.qml file", call. = FALSE) + } + + moduleDir <- dirname(modulePath) + if (identical(basename(moduleDir), "inst")) { + modulePath <- dirname(moduleDir) + } else { + modulePath <- moduleDir + } + } + + if (!dir.exists(modulePath)) { + stop("Module path not found: ", modulePath, call. = FALSE) + } + + modulePath +} + +.validateJaspFilePath <- function(jaspFilePath) { + jaspFilePath <- .validateScalarString(jaspFilePath, "jaspFilePath") + jaspFilePath <- normalizePath(jaspFilePath, winslash = "/", mustWork = FALSE) + + if (!file.exists(jaspFilePath)) { + stop("File not found: ", jaspFilePath, call. = FALSE) + } + + if (!grepl("\\.jasp$", jaspFilePath, ignore.case = TRUE)) { + stop("File must have a .jasp extension", call. = FALSE) + } + + jaspFilePath +} + +.validateAnalysisName <- function(analysisName) { + .validateScalarString(analysisName, "analysisName") +} + +.validateQmlFile <- function(qmlFile) { + qmlFile <- .validateScalarString(qmlFile, "qmlFile") + qmlFile <- normalizePath(qmlFile, winslash = "/", mustWork = FALSE) + + if (!file.exists(qmlFile)) { + stop("QML file not found: ", qmlFile, call. = FALSE) + } + + qmlFile +} + +.toOptionsJson <- function(options) { + if (is.null(options)) { + return("{}") + } + + if (is.character(options) && length(options) == 1L) { + if (!jsonlite::validate(options)) { + stop("`options` must be a valid JSON string", call. = FALSE) + } + parsedOptions <- tryCatch( + jsonlite::fromJSON(options, simplifyVector = FALSE), + error = function(e) NULL + ) + if (!is.list(parsedOptions) || is.null(names(parsedOptions))) { + stop("`options` JSON string must contain a JSON object", call. = FALSE) + } + return(options) + } + + if (!is.list(options)) { + stop("`options` must be a named list or a JSON string", call. = FALSE) + } + + if (length(options) == 0L) { + return("{}") + } + + if (is.null(names(options)) || any(!nzchar(names(options)))) { + stop("`options` must be a named list", call. = FALSE) + } + + as.character(jsonlite::toJSON( + options, + auto_unbox = TRUE, + null = "null", + digits = NA + )) +} + +.fromJsonObject <- function(json, what) { + parsed <- tryCatch( + jsonlite::fromJSON(json, simplifyVector = FALSE), + error = function(e) { + stop(what, " returned invalid JSON: ", conditionMessage(e), call. = FALSE) + } + ) + + if (!is.list(parsed) || is.null(names(parsed))) { + stop(what, " must return a JSON object", call. = FALSE) + } + + parsed +} + +.moduleQmlPath <- function(modulePath, qmlFileName) { + qmlFileName <- .validateScalarString(qmlFileName, "qmlFileName") + candidates <- c( + file.path(modulePath, "inst", "qml", qmlFileName), + file.path(modulePath, "qml", qmlFileName) + ) + + qmlFile <- candidates[file.exists(candidates)][1L] + if (is.na(qmlFile)) { + stop( + "Could not locate QML file `", qmlFileName, "` under module path: ", + modulePath, + call. = FALSE + ) + } + + normalizePath(qmlFile, winslash = "/", mustWork = TRUE) +} + +.analysisValue <- function(analysis, name, default = NULL) { + value <- analysis[[name]] + if (is.null(value) || length(value) == 0L || is.na(value)) { + return(default) + } + + value +} + +.findAnalysis <- function(description, analysisName) { + analyses <- description[["analyses"]] + if (!is.list(analyses) || length(analyses) == 0L) { + stop("Module description does not contain analyses", call. = FALSE) + } + + analysisNames <- vapply( + analyses, + function(analysis) .analysisValue(analysis, "name", NA_character_), + character(1L) + ) + + matchIndex <- match(analysisName, analysisNames) + if (is.na(matchIndex)) { + stop( + "Could not locate analysis `", analysisName, "` in module `", + .analysisValue(description, "name", ""), "`", + call. = FALSE + ) + } + + analyses[[matchIndex]] +} + +.attachOptionAttributes <- function(options, description, analysis, qmlFile = NULL) { + attr(options, "analysisName") <- .analysisValue(analysis, "name") + attr(options, "analysisTitle") <- .analysisValue(analysis, "title") + attr(options, "moduleName") <- .analysisValue(description, "name") + attr(options, "moduleVersion") <- .analysisValue(description, "version") + attr(options, "preloadData") <- .analysisValue(analysis, "preloadData") + + if (!is.null(qmlFile)) { + attr(options, "qmlFile") <- qmlFile + } + + options +} + +.filterOptionMetadata <- function(options, includeMeta, includeTypeOptions) { + includeMeta <- .validateFlag(includeMeta, "includeMeta") + includeTypeOptions <- .validateFlag(includeTypeOptions, "includeTypeOptions") + + if (!includeMeta) { + options[[".meta"]] <- NULL + } + + if (!includeTypeOptions) { + options <- options[!grepl("\\.types$", names(options))] + options <- .dropNestedTypeOptions(options) + } + + options +} + +.dropNestedTypeOptions <- function(options) { + if (!is.list(options)) { + return(options) + } + + optionNames <- names(options) + if (!is.null(optionNames) && all(c("value", "types") %in% optionNames)) { + options[["types"]] <- NULL + optionNames <- names(options) + } + + options[] <- lapply(options, .dropNestedTypeOptions) + + options +} + +#' Read a JASP Module Description +#' +#' Reads a module's `Description.qml` through the native SyntaxInterface bridge. +#' +#' @param modulePath Path to a JASP module source directory or its +#' `inst/Description.qml` file. +#' @param byName Whether to name the returned `analyses` list by analysis name. +#' +#' @return A list with module metadata and an `analyses` list. +#' +#' @export +parseModuleDescription <- function(modulePath, byName = TRUE) { + modulePath <- .validateModulePath(modulePath) + description <- parseDescription(modulePath) + + if (!is.list(description) || is.null(names(description))) { + stop("jaspSyntax::parseDescription() returned an unexpected object", call. = FALSE) + } + + if (isTRUE(byName) && is.list(description[["analyses"]])) { + analysisNames <- vapply( + description[["analyses"]], + function(analysis) .analysisValue(analysis, "name", ""), + character(1L) + ) + names(description[["analyses"]]) <- analysisNames + } + + attr(description, "modulePath") <- modulePath + description +} + +#' @rdname parseModuleDescription +#' @export +readModuleDescription <- function(modulePath, byName = TRUE) { + parseModuleDescription(modulePath, byName = byName) +} + +#' Resolve an Analysis QML File +#' +#' Resolves an analysis name to the QML file and metadata provided by the native +#' module description parser. +#' +#' @inheritParams parseModuleDescription +#' @param analysisName Name of the analysis function. +#' +#' @return A list with module description, analysis metadata, QML file path, and +#' resolved preload flag. +#' +#' @export +resolveAnalysisQml <- function(modulePath, analysisName) { + modulePath <- .validateModulePath(modulePath) + analysisName <- .validateAnalysisName(analysisName) + + description <- parseModuleDescription(modulePath, byName = TRUE) + analysis <- .findAnalysis(description, analysisName) + qmlFileName <- .analysisValue(analysis, "qml") + + list( + modulePath = modulePath, + moduleName = .analysisValue(description, "name"), + version = .analysisValue(description, "version", ""), + description = description, + analysis = analysis, + analysisName = .analysisValue(analysis, "name"), + analysisTitle = .analysisValue(analysis, "title"), + qmlFileName = qmlFileName, + qmlFile = .moduleQmlPath(modulePath, qmlFileName), + preloadData = isTRUE(.analysisValue(analysis, "preloadData", TRUE)) + ) +} + +#' Parse QML Options +#' +#' Loads a QML form and parses supplied options through the native +#' SyntaxInterface bridge. The returned options are the same R-runtime JSON +#' shape prepared for analyses by JASP Desktop: QML controls are bound, +#' option metadata is applied, and column-name/type encoding is handled by the +#' native `ColumnEncoder`. +#' +#' @param qmlFile Path to an analysis QML file. +#' @param options Named list of options, a JSON object string, or `NULL` for +#' defaults. +#' @param moduleName Module name passed to the native bridge. +#' @param analysisName Analysis name passed to the native bridge. Defaults to +#' the QML file basename without extension. +#' @param version Module version passed to the native bridge. +#' @param preloadData Whether the analysis preloads data. +#' @param fresh Whether to clear cached QML/native state before parsing. This +#' should remain `TRUE` when reading defaults. +#' @param output Return parsed R `list` output or raw `json`. +#' @param includeMeta Whether to retain the `.meta` option in list output. +#' @param includeTypeOptions Whether to retain `*.types` options in list output. +#' +#' @return A named list of parsed options, or a JSON string when +#' `output = "json"`. +#' +#' @export +parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", + analysisName = NULL, version = "0", + preloadData = TRUE, fresh = TRUE, + output = c("list", "json"), + includeMeta = TRUE, + includeTypeOptions = TRUE) { + output <- match.arg(output) + qmlFile <- .validateQmlFile(qmlFile) + + if (is.null(analysisName)) { + analysisName <- tools::file_path_sans_ext(basename(qmlFile)) + } + + moduleName <- .validateScalarString(moduleName, "moduleName") + analysisName <- .validateAnalysisName(analysisName) + version <- .validateScalarString(version, "version") + + if (!is.logical(preloadData) || length(preloadData) != 1L || is.na(preloadData)) { + stop("`preloadData` must be a single TRUE/FALSE value", call. = FALSE) + } + + if (!is.logical(fresh) || length(fresh) != 1L || is.na(fresh)) { + stop("`fresh` must be a single TRUE/FALSE value", call. = FALSE) + } + + if (fresh) { + clearQmlForms() + } + + rawOptions <- loadQmlAndParseOptions( + moduleName = moduleName, + analysisName = analysisName, + qmlFile = qmlFile, + options = .toOptionsJson(options), + version = version, + preloadData = preloadData + ) + + if (!is.character(rawOptions) || length(rawOptions) != 1L || !nzchar(rawOptions)) { + stop( + "jaspSyntax::loadQmlAndParseOptions() failed for QML file `", + qmlFile, + "`", + call. = FALSE + ) + } + + if (identical(output, "json")) { + return(rawOptions) + } + + parsedOptions <- .fromJsonObject(rawOptions, "jaspSyntax::loadQmlAndParseOptions()") + .filterOptionMetadata(parsedOptions, includeMeta, includeTypeOptions) +} + +#' Read Analysis Options Through QML +#' +#' Resolves an analysis in a module, loads its QML form, and parses options +#' through the native SyntaxInterface path. +#' +#' @param modulePath Path to a JASP module source directory. +#' @param analysisName Name of the analysis function. +#' @param options Named list of options, a JSON object string, or `NULL` for +#' defaults. +#' @param version Optional module version override. Defaults to the version from +#' `Description.qml`/`DESCRIPTION`. +#' @param preloadData Optional preload flag override. Defaults to the analysis +#' value from the module description. +#' @param fresh Whether to clear cached QML/native state before parsing. +#' @param includeMeta Whether to retain the `.meta` option in list output. +#' @param includeTypeOptions Whether to retain `*.types` options in list output. +#' +#' @return A named list of parsed options. +#' +#' @export +readAnalysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, + version = NULL, preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE) { + resolved <- resolveAnalysisQml(modulePath, analysisName) + description <- resolved$description + analysis <- resolved$analysis + + if (is.null(version)) { + version <- resolved$version + } else { + version <- .validateScalarString(version, "version") + } + + if (is.null(preloadData)) { + preloadData <- resolved$preloadData + } else if (!is.logical(preloadData) || length(preloadData) != 1L || is.na(preloadData)) { + stop("`preloadData` must be a single TRUE/FALSE value", call. = FALSE) + } + + parsedOptions <- parseQmlOptions( + qmlFile = resolved$qmlFile, + options = options, + moduleName = resolved$moduleName, + analysisName = resolved$analysisName, + version = version, + preloadData = preloadData, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + + .attachOptionAttributes(parsedOptions, description, analysis, resolved$qmlFile) +} + +#' @rdname readAnalysisOptionsFromQml +#' @export +analysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, + version = NULL, preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE) { + readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + preloadData = preloadData, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) +} + +#' Read Default Analysis Options +#' +#' Loads an analysis QML form and returns the options produced by the native +#' SyntaxInterface defaults. +#' +#' @inheritParams readAnalysisOptionsFromQml +#' +#' @return A named list of default options. +#' +#' @export +readDefaultAnalysisOptions <- function(modulePath, analysisName, fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE) { + readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = NULL, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) +} + +.readJaspAnalysisMetadata <- function(jaspFilePath) { + jaspFilePath <- .validateJaspFilePath(jaspFilePath) + + tempDir <- tempfile("jaspSyntax_analyses_") + dir.create(tempDir) + on.exit(unlink(tempDir, recursive = TRUE), add = TRUE) + + utils::unzip(jaspFilePath, files = "analyses.json", exdir = tempDir) + analysesPath <- file.path(tempDir, "analyses.json") + if (!file.exists(analysesPath)) { + stop("Could not find `analyses.json` inside the JASP file", call. = FALSE) + } + + contents <- .fromJsonObject( + paste(readLines(analysesPath, warn = FALSE), collapse = "\n"), + "`analyses.json`" + ) + + analyses <- contents[["analyses"]] + if (!is.list(analyses) || length(analyses) == 0L) { + stop("No analyses found in the provided JASP file", call. = FALSE) + } + + analyses +} + +.analysisRecordFromJaspFile <- function(metadata, options) { + dynamicModule <- metadata[["dynamicModule"]] + if (!is.list(dynamicModule)) { + dynamicModule <- list() + } + + moduleName <- .analysisValue(dynamicModule, "moduleName") + if (is.null(moduleName)) { + moduleName <- .analysisValue(metadata, "moduleName") + } + if (is.null(moduleName)) { + moduleName <- .analysisValue(metadata, "module") + } + + moduleVersion <- .analysisValue(dynamicModule, "moduleVersion") + if (is.null(moduleVersion)) { + moduleVersion <- .analysisValue(metadata, "moduleVersion") + } + if (is.null(moduleVersion)) { + moduleVersion <- .analysisValue(metadata, "version") + } + + analysis <- list( + name = .analysisValue(metadata, "name"), + title = .analysisValue(metadata, "title"), + moduleName = moduleName, + moduleVersion = moduleVersion, + options = options + ) + + attr(analysis$options, "analysisName") <- analysis$name + attr(analysis$options, "analysisTitle") <- analysis$title + attr(analysis$options, "moduleName") <- analysis$moduleName + attr(analysis$options, "moduleVersion") <- analysis$moduleVersion + + analysis +} + +.validateFlag <- function(value, name) { + if (!is.logical(value) || length(value) != 1L || is.na(value)) { + stop("`", name, "` must be a single TRUE/FALSE value", call. = FALSE) + } + + value +} + +.hasUsableNames <- function(x) { + nms <- names(x) + !is.null(nms) && any(nzchar(nms)) +} + +.modulePathMismatchMessage <- function(record, modulePath) { + expected <- c(record$moduleName, record$name) + expected <- expected[!is.na(expected) & nzchar(expected)] + supplied <- names(modulePath) + supplied <- supplied[!is.na(supplied) & nzchar(supplied)] + + paste0( + "`modulePath` was named, but none of its names matched ", + if (length(expected) > 0L) { + paste0("module/analysis `", paste(expected, collapse = "` or `"), "`") + } else { + "the saved module or analysis" + }, + ". Supplied names: `", paste(supplied, collapse = "`, `"), "`. ", + "Installed-module fallback is only used when `modulePath = NULL`." + ) +} + +.installedModulePathForRecord <- function(record) { + if (is.null(record$moduleName) || !nzchar(record$moduleName)) { + stop( + "Cannot resolve a module path for analysis `", + .analysisValue(record, "name", ""), + "` because the JASP file does not record a module name", + call. = FALSE + ) + } + + found <- find.package(record$moduleName, quiet = TRUE) + if (length(found) == 0L) { + stop( + "Could not locate installed module `", record$moduleName, + "`. Supply `modulePath` to replay saved options through QML.", + call. = FALSE + ) + } + + .validateModulePath(found[[1L]]) +} + +.modulePathForRecord <- function(record, modulePath = NULL) { + if (!is.null(modulePath)) { + if (is.list(modulePath)) { + if (!is.null(record$moduleName) && record$moduleName %in% names(modulePath)) { + return(.validateModulePath(modulePath[[record$moduleName]])) + } + if (!is.null(record$name) && record$name %in% names(modulePath)) { + return(.validateModulePath(modulePath[[record$name]])) + } + if (length(modulePath) == 1L && !.hasUsableNames(modulePath)) { + return(.validateModulePath(modulePath[[1L]])) + } + } else { + if (!is.null(names(modulePath)) && !is.null(record$moduleName) && + record$moduleName %in% names(modulePath)) { + return(.validateModulePath(modulePath[[record$moduleName]])) + } + if (!is.null(names(modulePath)) && !is.null(record$name) && + record$name %in% names(modulePath)) { + return(.validateModulePath(modulePath[[record$name]])) + } + if (length(modulePath) == 1L && !.hasUsableNames(modulePath)) { + return(.validateModulePath(modulePath)) + } + } + + if (.hasUsableNames(modulePath)) { + stop(.modulePathMismatchMessage(record, modulePath), call. = FALSE) + } + + stop( + "`modulePath` must be a single module path or named by module/analysis ", + "when reading runtime options from a multi-module JASP file", + call. = FALSE + ) + } + + .installedModulePathForRecord(record) +} + +.runtimeOptionsForJaspRecord <- function(record, modulePath, + includeMeta, + includeTypeOptions) { + resolvedModulePath <- .modulePathForRecord(record, modulePath) + version <- record$moduleVersion + if (is.null(version) || !nzchar(version)) { + version <- NULL + } + + runtimeOptions <- readAnalysisOptionsFromQml( + modulePath = resolvedModulePath, + analysisName = record$name, + options = record$options, + version = version, + fresh = TRUE, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + + record$options <- runtimeOptions + record +} + +.readAnalysisOptionsFromJaspFileInProcess <- function(jaspFilePath, + modulePath = NULL, + runtime = FALSE, + includeMeta = TRUE, + includeTypeOptions = TRUE) { + jaspFilePath <- .validateJaspFilePath(jaspFilePath) + runtime <- .validateFlag(runtime, "runtime") + includeMeta <- .validateFlag(includeMeta, "includeMeta") + includeTypeOptions <- .validateFlag(includeTypeOptions, "includeTypeOptions") + analyses <- .readJaspAnalysisMetadata(jaspFilePath) + + clearNativeState() + on.exit(clearNativeState(), add = TRUE) + + if (runtime) { + loadDataSetFromJaspFile(jaspFilePath) + } + + records <- vector("list", length(analyses)) + for (i in seq_along(analyses)) { + options <- analysisOptionsFromJaspFile(jaspFilePath, i - 1L) + options <- .filterOptionMetadata(options, includeMeta = TRUE, includeTypeOptions = TRUE) + records[[i]] <- .analysisRecordFromJaspFile(analyses[[i]], options) + + if (runtime) { + records[[i]] <- .runtimeOptionsForJaspRecord( + records[[i]], + modulePath = modulePath, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + } else { + records[[i]]$options <- .filterOptionMetadata( + records[[i]]$options, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + } + } + + names(records) <- vapply( + records, + function(record) .analysisValue(record, "name", ""), + character(1L) + ) + + records +} + +.runReadAnalysisOptionsSubprocess <- function(jaspFilePath, modulePath, runtime, + includeMeta, includeTypeOptions) { + .runBridgeSubprocess( + task = "read_options", + target = ".readAnalysisOptionsFromJaspFileInProcess", + input = list( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + runtime = runtime, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ), + failureLabel = "readAnalysisOptionsFromJaspFile" + ) +} + +.runtimeOptionsForJaspRecordsInProcess <- function(records, dataset, + modulePath = NULL, + includeMeta = TRUE, + includeTypeOptions = TRUE) { + includeMeta <- .validateFlag(includeMeta, "includeMeta") + includeTypeOptions <- .validateFlag(includeTypeOptions, "includeTypeOptions") + + if (!is.list(records)) { + stop("`records` must be a list of saved JASP analysis records", call. = FALSE) + } + if (!is.null(dataset) && !is.data.frame(dataset)) { + stop("`dataset` must be a data frame or NULL", call. = FALSE) + } + + clearNativeState() + on.exit(clearNativeState(), add = TRUE) + + if (is.data.frame(dataset)) { + loadDataSet(dataset) + } + + recordNames <- names(records) + records <- lapply(records, function(record) { + .runtimeOptionsForJaspRecord( + record, + modulePath = modulePath, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + }) + names(records) <- recordNames + records +} + +.runReadAnalysisRuntimeOptionsSubprocess <- function(jaspFilePath, modulePath, + includeMeta, + includeTypeOptions) { + savedRecords <- .runReadAnalysisOptionsSubprocess( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + runtime = FALSE, + includeMeta = TRUE, + includeTypeOptions = TRUE + ) + dataset <- .runReadDatasetSubprocess( + jaspFilePath = jaspFilePath, + dataSetIndex = 1L, + decode = TRUE, + normalize = FALSE + ) + + .runBridgeSubprocess( + task = "read_runtime_options", + target = ".runtimeOptionsForJaspRecordsInProcess", + input = list( + records = savedRecords, + dataset = dataset, + modulePath = modulePath, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ), + failureLabel = "readAnalysisOptionsFromJaspFile(runtime = TRUE)" + ) +} + +#' Read Analysis Options From a JASP File +#' +#' Reads all saved analyses from a `.jasp` file and returns their metadata +#' together with their saved QML-bound options. With `runtime = TRUE`, saved +#' options are replayed through the resolved QML form and native Desktop +#' option encoder so the result matches the R-runtime options prepared by JASP +#' Desktop before calling the analysis. This helper reads the options stored in +#' the archive; it does not replace Desktop's full archive/module upgrade +#' workflow for older files. +#' +#' @param jaspFilePath Path to a `.jasp` file. +#' @param modulePath Optional module path, or a named list/vector of module +#' paths keyed by module name or analysis name. Required for +#' `runtime = TRUE` when the module is not installed. +#' @param runtime Whether to replay saved options through QML and the native +#' Desktop option encoder. The default `FALSE` returns the saved bound +#' options from `analyses.json`. +#' @param includeMeta Whether to retain the `.meta` option. +#' @param includeTypeOptions Whether to retain `*.types` options when present. +#' @param isolated Whether to run the native `.jasp` option extraction in a +#' separate R process. This is the default because the SyntaxInterface bridge +#' owns process-global native state. In-process reads also clear native state +#' before returning; use `readDatasetFromJaspFile()` for the saved dataset. +#' +#' @return A list of analysis records. Each record has `name`, `title`, +#' `moduleName`, `moduleVersion`, and `options`. +#' +#' @export +readAnalysisOptionsFromJaspFile <- function(jaspFilePath, + modulePath = NULL, + runtime = FALSE, + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = TRUE) { + jaspFilePath <- .validateJaspFilePath(jaspFilePath) + runtime <- .validateFlag(runtime, "runtime") + includeMeta <- .validateFlag(includeMeta, "includeMeta") + includeTypeOptions <- .validateFlag(includeTypeOptions, "includeTypeOptions") + isolated <- .validateFlag(isolated, "isolated") + + if (isolated) { + if (runtime) { + return(.runReadAnalysisRuntimeOptionsSubprocess( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + )) + } + + return(.runReadAnalysisOptionsSubprocess( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + runtime = runtime, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + )) + } + + .readAnalysisOptionsFromJaspFileInProcess( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + runtime = runtime, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) +} diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R index 93959e1..64de04c 100644 --- a/R/readDatasetFromJaspFile.R +++ b/R/readDatasetFromJaspFile.R @@ -26,126 +26,324 @@ ) } -.readDatasetFromJaspFileInProcess <- function(jaspFilePath, dataSetIndex = 1L) { +.readDatasetFromJaspFileInProcess <- function(jaspFilePath, dataSetIndex = 1L, + decode = TRUE, + normalize = TRUE) { args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) + decode <- .validateFlag(decode, "decode") + normalize <- .validateFlag(normalize, "normalize") jaspFilePath <- args$jaspFilePath dataSetIndex <- args$dataSetIndex - cleanUp() - on.exit(cleanUp(), add = TRUE) + clearNativeState() + on.exit(clearNativeState(), add = TRUE) loadDataSetFromJaspFile(jaspFilePath) - readFullDataSet <- get0(".readFullDatasetToEnd", envir = .GlobalEnv, inherits = FALSE) - if (!is.function(readFullDataSet)) { - stop("jaspSyntax bridge did not expose `.readFullDatasetToEnd`") - } - - dataset <- readFullDataSet() - if (!is.data.frame(dataset)) { - stop("jaspSyntax bridge returned an unexpected dataset object") - } + dataset <- readLoadedDataset(decode = decode, normalize = normalize) if (ncol(dataset) == 0L) { return(NULL) } - decodeName <- get0(".decodeColNamesStrict", envir = .GlobalEnv, inherits = FALSE) - if (is.function(decodeName)) { - names(dataset) <- vapply(names(dataset), function(columnName) { - as.character(decodeName(columnName)) - }, character(1L)) - } - - dataset[] <- lapply(dataset, .normalizeBridgeColumn) - dataset } -.getRscriptBinary <- function() { - file.path(R.home("bin"), if (.Platform$OS.type == "windows") "Rscript.exe" else "Rscript") -} - .normalizeBridgeColumn <- function(column) { if (!is.factor(column)) { return(column) } - levelValues <- levels(column) - numericLevels <- suppressWarnings(as.numeric(levelValues)) + as.character(column) +} - if (length(levelValues) > 0L && !any(is.na(numericLevels))) { - numericValues <- suppressWarnings(as.numeric(as.character(column))) - nonMissing <- !is.na(numericValues) +.bridgeCallback <- function(name, what) { + callback <- get0(name, envir = .GlobalEnv, inherits = FALSE) + if (!is.function(callback)) { + stop( + "jaspSyntax bridge did not expose `", name, "` for ", what, + call. = FALSE + ) + } - if (all(numericValues[nonMissing] == as.integer(numericValues[nonMissing]))) { - return(as.integer(numericValues)) - } + callback +} - return(numericValues) +.readBridgeDataset <- function(callbackName, what) { + dataset <- .bridgeCallback(callbackName, what)() + if (!is.data.frame(dataset)) { + stop( + "jaspSyntax bridge returned an unexpected ", what, " object", + call. = FALSE + ) } - as.character(column) + dataset } -.runReadDatasetSubprocess <- function(jaspFilePath, dataSetIndex) { - scriptPath <- tempfile("jaspSyntax_read_dataset_", fileext = ".R") - outputPath <- tempfile("jaspSyntax_read_dataset_", fileext = ".rds") - on.exit(unlink(c(scriptPath, outputPath)), add = TRUE) - - script <- c( - "args <- commandArgs(trailingOnly = TRUE)", - "jaspFilePath <- args[[1L]]", - "dataSetIndex <- as.integer(args[[2L]])", - "outputPath <- args[[3L]]", - "libPaths <- if (length(args) > 3L) args[4:length(args)] else character(0)", - "if (length(libPaths) > 0L) .libPaths(c(libPaths, .libPaths()))", - "result <- tryCatch(local({", - " suppressPackageStartupMessages(library(jaspSyntax))", - " getNamespace('jaspSyntax')[['.readDatasetFromJaspFileInProcess']](jaspFilePath, dataSetIndex)", - "}), error = function(e) structure(list(message = conditionMessage(e)), class = 'jaspSyntax_subprocess_error'))", - "saveRDS(result, outputPath)" - ) +.prepareBridgeDataset <- function(dataset, decode = TRUE, normalize = TRUE) { + decode <- .validateFlag(decode, "decode") + normalize <- .validateFlag(normalize, "normalize") - writeLines(script, scriptPath) + if (decode && ncol(dataset) > 0L) { + names(dataset) <- decodeColumnNames(names(dataset), strict = TRUE) + } - output <- system2( - .getRscriptBinary(), - args = c("--vanilla", scriptPath, jaspFilePath, as.character(dataSetIndex), outputPath, .libPaths()), - stdout = TRUE, - stderr = TRUE - ) + if (normalize && ncol(dataset) > 0L) { + dataset[] <- lapply(dataset, .normalizeBridgeColumn) + } - status <- attr(output, "status") - if (!file.exists(outputPath)) { - stop( - "readDatasetFromJaspFile failed before producing a result.", - if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" - ) + dataset +} + +#' Decode Native JASP Column Names +#' +#' Decodes column names using the native bridge decoder installed by +#' SyntaxInterface. When the bridge does not expose a decoder, the default is to +#' return names unchanged so callers can still operate on non-encoded inputs. +#' +#' @param columnNames Character vector of column names. +#' @param strict Whether to fail when the native decoder is unavailable or a +#' name cannot be decoded. +#' +#' @return A character vector with decoded names. +#' +#' @export +decodeColumnNames <- function(columnNames, strict = FALSE) { + if (!is.character(columnNames)) { + stop("`columnNames` must be a character vector", call. = FALSE) } - result <- readRDS(outputPath) - if (inherits(result, "jaspSyntax_subprocess_error")) { - stop( - "readDatasetFromJaspFile failed: ", - result$message, - if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" - ) + strict <- .validateFlag(strict, "strict") + decodeName <- get0(".decodeColNamesStrict", envir = .GlobalEnv, inherits = FALSE) + if (!is.function(decodeName)) { + if (strict) { + stop( + "jaspSyntax bridge did not expose `.decodeColNamesStrict`", + call. = FALSE + ) + } + return(columnNames) } - if (!is.null(status) && status != 0L) { - stop( - "readDatasetFromJaspFile failed with exit status ", - status, - ".", - if (length(output) > 0L) paste0("\n", paste(output, collapse = "\n")) else "" + vapply(columnNames, function(columnName) { + tryCatch( + { + decoded <- as.character(decodeName(columnName)) + if (length(decoded) != 1L || is.na(decoded)) { + stop("decoder returned an empty value") + } + decoded + }, + error = function(e) { + if (strict) { + stop( + "Could not decode column name `", columnName, "`: ", + conditionMessage(e), + call. = FALSE + ) + } + columnName + } ) + }, character(1L), USE.NAMES = FALSE) +} + +#' @rdname decodeColumnNames +#' @param encodedColumnNames Optional encoded column names. When omitted, the +#' current native dataset header is used. +#' +#' @return `columnMapping()` returns a named character vector mapping encoded +#' names to decoded names. +#' +#' @export +columnMapping <- function(encodedColumnNames = NULL, strict = FALSE) { + strict <- .validateFlag(strict, "strict") + + if (is.null(encodedColumnNames)) { + encodedColumnNames <- readDatasetHeader(decode = FALSE)$encodedName + } + + if (!is.character(encodedColumnNames)) { + stop("`encodedColumnNames` must be a character vector", call. = FALSE) + } + + stats::setNames( + decodeColumnNames(encodedColumnNames, strict = strict), + encodedColumnNames + ) +} + +#' Read the Loaded Native Dataset +#' +#' Reads the full dataset currently loaded into the native SyntaxInterface +#' bridge. This is the explicit high-level API for code that previously reached +#' into bridge callbacks in `.GlobalEnv`. +#' +#' @param decode Whether to decode native/encoded column names. +#' @param normalize Whether to normalize bridge-returned factor columns back to +#' plain character vectors while preserving numeric-looking factor labels. +#' +#' @return A data frame. +#' +#' @export +readLoadedDataset <- function(decode = TRUE, normalize = TRUE) { + dataset <- .readBridgeDataset(".readFullDatasetToEnd", "loaded dataset") + .prepareBridgeDataset(dataset, decode = decode, normalize = normalize) +} + +#' Read the Requested Native Dataset +#' +#' Reads the analysis-requested dataset after QML/runtime option preparation has +#' run through the native SyntaxInterface bridge. +#' +#' @inheritParams readLoadedDataset +#' +#' @return A data frame. +#' +#' @export +readRequestedDataset <- function(decode = TRUE, normalize = TRUE) { + dataset <- .readBridgeDataset(".readDataSetRequestedNative", "requested dataset") + .prepareBridgeDataset(dataset, decode = decode, normalize = normalize) +} + +#' Read the Native Dataset Header +#' +#' Reads the current native dataset header without materializing the full data +#' frame. The native bridge currently exposes names only; type-rich headers need +#' a future Desktop ABI. +#' +#' @param decode Whether to decode native/encoded column names. +#' +#' @return A data frame with `name` and `encodedName` columns. +#' +#' @export +readDatasetHeader <- function(decode = TRUE) { + decode <- .validateFlag(decode, "decode") + + encodedNames <- getVariableNames() + if (is.data.frame(encodedNames)) { + encodedNames <- names(encodedNames) + } else { + encodedNames <- unlist(encodedNames, use.names = FALSE) + } + encodedNames <- as.character(encodedNames) + + data.frame( + name = if (decode) decodeColumnNames(encodedNames, strict = TRUE) else encodedNames, + encodedName = encodedNames, + stringsAsFactors = FALSE + ) +} + +#' Load an Analysis Dataset Through the Native Bridge +#' +#' Loads a raw R data frame, replays saved/QML-bound analysis options through the +#' native QML preparation path, and returns the loaded and requested dataset +#' state owned by SyntaxInterface. +#' +#' @param dataset Raw data frame supplied by the caller. +#' @param modulePath Path to a JASP module source directory. +#' @param analysisName Name of the analysis function. +#' @param options Saved/QML-bound options as a named list, JSON object string, or +#' `NULL`. +#' @param includeMeta Whether to retain the `.meta` option in runtime options. +#' @param includeTypeOptions Whether to retain `*.types` options in runtime +#' options. +#' @inheritParams readLoadedDataset +#' +#' @return A list with `loadedDataset`, `requestedDataset`, +#' `resultDecodingDataset`, `runtimeOptions`, `columnMapping`, `modulePath`, +#' and `analysisName`. +#' +#' @export +loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NULL, + includeMeta = TRUE, + includeTypeOptions = TRUE, + decode = TRUE, + normalize = TRUE) { + if (!is.data.frame(dataset)) { + stop("`dataset` must be a data frame", call. = FALSE) } - result + includeMeta <- .validateFlag(includeMeta, "includeMeta") + includeTypeOptions <- .validateFlag(includeTypeOptions, "includeTypeOptions") + decode <- .validateFlag(decode, "decode") + normalize <- .validateFlag(normalize, "normalize") + modulePath <- .validateModulePath(modulePath) + analysisName <- .validateAnalysisName(analysisName) + + clearDatasetState() + loaded <- FALSE + on.exit({ + if (!loaded) { + clearNativeState() + } + }, add = TRUE) + + loadDataSet(dataset) + runtimeOptions <- readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = options, + fresh = TRUE, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + + loadedRaw <- .readBridgeDataset(".readFullDatasetToEnd", "loaded dataset") + requestedRaw <- .readBridgeDataset(".readDataSetRequestedNative", "requested dataset") + rawColumnNames <- unique(c(names(loadedRaw), names(requestedRaw))) + + state <- list( + loadedDataset = .prepareBridgeDataset( + loadedRaw, + decode = decode, + normalize = normalize + ), + requestedDataset = .prepareBridgeDataset( + requestedRaw, + decode = decode, + normalize = normalize + ), + resultDecodingDataset = .prepareBridgeDataset( + requestedRaw, + decode = decode, + normalize = FALSE + ), + runtimeOptions = runtimeOptions, + columnMapping = columnMapping(rawColumnNames, strict = decode), + modulePath = modulePath, + analysisName = analysisName + ) + class(state) <- c("jaspSyntax_analysis_dataset_state", class(state)) + + loaded <- TRUE + state +} + +.runReadDatasetSubprocess <- function(jaspFilePath, dataSetIndex, + decode = TRUE, + normalize = TRUE) { + .runBridgeSubprocess( + task = "read_dataset", + target = ".readDatasetFromJaspFileInProcess", + input = list( + jaspFilePath = jaspFilePath, + dataSetIndex = dataSetIndex, + decode = decode, + normalize = normalize + ), + failureLabel = "readDatasetFromJaspFile" + ) } readDatasetFromJaspFile <- function(jaspFilePath, dataSetIndex = 1L) { args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) - .runReadDatasetSubprocess(args$jaspFilePath, args$dataSetIndex) + .runReadDatasetSubprocess( + args$jaspFilePath, + args$dataSetIndex, + decode = TRUE, + normalize = TRUE + ) } diff --git a/R/resultDecoding.R b/R/resultDecoding.R new file mode 100644 index 0000000..9ac8623 --- /dev/null +++ b/R/resultDecoding.R @@ -0,0 +1,158 @@ +#' Decode JASP Analysis Result Payloads +#' +#' Decodes native column-name tokens and factor value tokens in analysis results +#' using the current SyntaxInterface dataset state. +#' +#' @param results A result payload list, typically decoded from jaspResults JSON. +#' @param requestedDataset Optional requested dataset to use as the factor-label +#' source. When omitted, the current native requested dataset is read from the +#' bridge if available. +#' @param columnMapping Optional named character vector mapping encoded native +#' column names to decoded user-facing column names. Supplying this avoids a +#' late native decoder call after analysis execution. +#' +#' @return The result payload with decoded column names and factor values. +#' +#' @export +decodeAnalysisResults <- function(results, requestedDataset = NULL, + columnMapping = NULL) { + if (!is.list(results)) { + return(results) + } + + decodeContext <- .analysisResultDecodeContext( + requestedDataset, + columnMapping = columnMapping + ) + .decodeAnalysisResultObject(results, decodeContext = decodeContext) +} + +.analysisResultDecodeContext <- function(requestedDataset = NULL, + columnMapping = NULL) { + columnMapping <- .validateAnalysisResultColumnMapping(columnMapping) + + if (is.null(requestedDataset)) { + requestedDataset <- tryCatch( + readRequestedDataset(decode = FALSE, normalize = FALSE), + error = function(e) NULL + ) + } + + if (!is.data.frame(requestedDataset)) { + return(list(factorValues = list(), columnMapping = columnMapping)) + } + + factorValues <- list() + for (columnName in names(requestedDataset)) { + column <- requestedDataset[[columnName]] + if (!is.factor(column)) { + next + } + + valueMap <- stats::setNames(levels(column), as.character(seq_along(levels(column)))) + decodedName <- tryCatch( + .decodeAnalysisResultColumnNames(columnName, columnMapping), + error = function(e) columnName + ) + columnKeys <- unique(c( + columnName, + decodedName, + .encodedAnalysisResultColumnNames(columnName, columnMapping) + )) + + for (columnKey in columnKeys) { + if (is.character(columnKey) && length(columnKey) == 1L && nzchar(columnKey)) { + factorValues[[columnKey]] <- valueMap + } + } + } + + list(factorValues = factorValues, columnMapping = columnMapping) +} + +.decodeAnalysisResultObject <- function(x, fieldName = NULL, decodeContext) { + if (is.list(x)) { + oldNames <- names(x) + for (i in seq_len(length(x))) { + childName <- if (!is.null(oldNames) && length(oldNames) >= i) oldNames[[i]] else NULL + child <- tryCatch(x[[i]], error = function(e) NULL) + x[i] <- list(.decodeAnalysisResultObject(child, fieldName = childName, decodeContext = decodeContext)) + } + + if (!is.null(oldNames)) { + names(x) <- .decodeAnalysisResultColumnNames( + oldNames, + decodeContext[["columnMapping"]] + ) + } + + return(x) + } + + x <- .decodeAnalysisResultFactorValues(x, fieldName, decodeContext) + + if (is.character(x)) { + x <- .decodeAnalysisResultColumnNames( + x, + decodeContext[["columnMapping"]] + ) + } + + x +} + +.decodeAnalysisResultFactorValues <- function(x, fieldName, decodeContext) { + if (is.null(fieldName) || is.null(decodeContext[["factorValues"]][[fieldName]])) { + return(x) + } + + valueMap <- decodeContext[["factorValues"]][[fieldName]] + key <- as.character(x) + matched <- key %in% names(valueMap) + if (!any(matched)) { + return(x) + } + + out <- as.character(x) + out[matched] <- unname(valueMap[key[matched]]) + out +} + +.validateAnalysisResultColumnMapping <- function(columnMapping = NULL) { + if (is.null(columnMapping)) { + return(NULL) + } + + if (!is.character(columnMapping) || is.null(names(columnMapping))) { + stop("`columnMapping` must be a named character vector", call. = FALSE) + } + + valid <- !is.na(columnMapping) & nzchar(columnMapping) & + !is.na(names(columnMapping)) & nzchar(names(columnMapping)) + columnMapping[valid] +} + +.decodeAnalysisResultColumnNames <- function(columnNames, columnMapping = NULL) { + if (!is.character(columnNames) || length(columnNames) == 0L) { + return(columnNames) + } + + if (length(columnMapping) > 0L) { + decoded <- unname(columnMapping[columnNames]) + matched <- !is.na(decoded) + columnNames[matched] <- decoded[matched] + return(columnNames) + } + + decodeColumnNames(columnNames, strict = FALSE) +} + +.encodedAnalysisResultColumnNames <- function(decodedColumnName, + columnMapping = NULL) { + if (!is.character(decodedColumnName) || length(decodedColumnName) != 1L || + length(columnMapping) == 0L) { + return(character(0)) + } + + names(columnMapping)[!is.na(columnMapping) & columnMapping == decodedColumnName] +} diff --git a/R/zzz.R b/R/zzz.R new file mode 100644 index 0000000..18a8424 --- /dev/null +++ b/R/zzz.R @@ -0,0 +1,84 @@ +.qtRootFromPath <- function(path) { + if (!nzchar(path)) { + return(character(0)) + } + + path <- normalizePath(path, winslash = "/", mustWork = FALSE) + if (basename(path) == "bin") { + path <- dirname(path) + } + if (dir.exists(file.path(path, "plugins")) || dir.exists(file.path(path, "qml"))) { + return(path) + } + character(0) +} + +.prioritizeQtRoots <- function(qtRoots, explicitRoots = character(0)) { + qtRoots <- unique(normalizePath(qtRoots[nzchar(qtRoots)], winslash = "/", mustWork = FALSE)) + explicitRoots <- unique(normalizePath(explicitRoots[nzchar(explicitRoots)], winslash = "/", mustWork = FALSE)) + discoveredRoots <- setdiff(qtRoots, explicitRoots) + + siblingRoots <- unlist(lapply(discoveredRoots, function(qtRoot) { + parent <- dirname(qtRoot) + c(Sys.glob(file.path(parent, "msvc*")), Sys.glob(file.path(parent, "mingw*"))) + }), use.names = FALSE) + siblingRoots <- normalizePath(siblingRoots[nzchar(siblingRoots)], winslash = "/", mustWork = FALSE) + siblingRoots <- siblingRoots[dir.exists(file.path(siblingRoots, "bin"))] + + unique(c(explicitRoots, siblingRoots, discoveredRoots)) +} + +.onLoad <- function(libname, pkgname) { + rArch <- sub("^/", "", .Platform$r_arch) + namespacePath <- getNamespaceInfo(pkgname, "path") + packageLibRoot <- file.path(libname, pkgname, "libs", rArch) + sourceLibRoot <- file.path(namespacePath, "src") + explicitQtRoots <- .qtRootFromPath(Sys.getenv("JASPSYNTAX_QT_DIR", unset = "")) + runtimeDirs <- c( + Sys.getenv("JASPSYNTAX_QT_DIR", unset = ""), + strsplit(Sys.getenv("PATH", unset = ""), .Platform$path.sep, fixed = TRUE)[[1L]] + ) + runtimeDirs <- normalizePath(runtimeDirs[nzchar(runtimeDirs)], winslash = "/", mustWork = FALSE) + qtRoots <- unique(c( + dirname(runtimeDirs[dir.exists(file.path(dirname(runtimeDirs), "plugins"))]), + runtimeDirs[dir.exists(file.path(runtimeDirs, "plugins"))] + )) + qtRoots <- .prioritizeQtRoots(qtRoots, explicitRoots = explicitQtRoots) + runtimePathDirs <- c(packageLibRoot, sourceLibRoot, file.path(qtRoots, "bin")) + runtimePathDirs <- runtimePathDirs[dir.exists(runtimePathDirs)] + if (length(runtimePathDirs) > 0L) { + oldPath <- strsplit(Sys.getenv("PATH", unset = ""), .Platform$path.sep, fixed = TRUE)[[1L]] + pathDirs <- c(runtimePathDirs, oldPath) + pathDirs <- pathDirs[nzchar(pathDirs)] + Sys.setenv(PATH = paste(unique(pathDirs), collapse = .Platform$path.sep)) + } + + qtPluginRoots <- c( + packageLibRoot, + sourceLibRoot, + file.path(qtRoots, "plugins") + ) + qtPluginRoots <- qtPluginRoots[dir.exists(file.path(qtPluginRoots, "platforms"))] + if (length(qtPluginRoots) > 0L) { + oldPluginPath <- strsplit(Sys.getenv("QT_PLUGIN_PATH", unset = ""), .Platform$path.sep, fixed = TRUE)[[1L]] + pluginPaths <- c(qtPluginRoots, oldPluginPath) + pluginPaths <- pluginPaths[nzchar(pluginPaths)] + Sys.setenv(QT_PLUGIN_PATH = paste(unique(pluginPaths), collapse = .Platform$path.sep)) + + oldQpaPath <- strsplit(Sys.getenv("QT_QPA_PLATFORM_PLUGIN_PATH", unset = ""), .Platform$path.sep, fixed = TRUE)[[1L]] + platformPaths <- c(file.path(qtPluginRoots, "platforms"), oldQpaPath) + platformPaths <- platformPaths[nzchar(platformPaths)] + Sys.setenv(QT_QPA_PLATFORM_PLUGIN_PATH = paste(unique(platformPaths), collapse = .Platform$path.sep)) + } + + qtQmlRoots <- file.path(qtRoots, "qml") + qtQmlRoots <- qtQmlRoots[dir.exists(qtQmlRoots)] + if (length(qtQmlRoots) > 0L) { + oldQmlPath <- strsplit(Sys.getenv("QML2_IMPORT_PATH", unset = ""), .Platform$path.sep, fixed = TRUE)[[1L]] + qmlPaths <- c(qtQmlRoots, oldQmlPath) + qmlPaths <- qmlPaths[nzchar(qmlPaths)] + qmlPaths <- paste(unique(qmlPaths), collapse = .Platform$path.sep) + Sys.setenv(QML2_IMPORT_PATH = qmlPaths) + Sys.setenv(QML_IMPORT_PATH = qmlPaths) + } +} diff --git a/README.md b/README.md index 636cc81..06b6390 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -This package will try to make [QML](https://en.wikipedia.org/wiki/QML) available in [R](https://www.r-project.org/). -It initializes QML, starts an engine, and allow the user to load a QML item. +jaspSyntax exposes the native JASP SyntaxInterface bridge to R. + +It is the lower-level runtime layer used by JASP tooling to: + +- parse module `Description.qml` metadata, +- resolve analysis QML files, +- replay QML option binding through the native Desktop option pipeline, +- load R data frames or saved `.jasp` datasets into native state, +- read saved `.jasp` analysis options as saved/QML-bound records or as + backend/runtime options. + +Saved `.jasp` options are read from the archive and then replayed through QML +when runtime options are requested. They are not a replacement for Desktop's +full archive/module upgrade workflow for old files. + +Prefer the high-level helpers such as `readModuleDescription()`, +`readAnalysisOptionsFromQml()`, `readDefaultAnalysisOptions()`, +`readAnalysisOptionsFromJaspFile()`, and `readDatasetFromJaspFile()` over the +raw native bridge calls. + +The lower-level helpers (`parseQmlOptions()`, lifecycle controls, dataset-state +readers, column mapping helpers, and `nativeBridgeProvenance()`) are exported +for bridge integration and diagnostics. Treat them as experimental/native-facing +APIs; ordinary callers should stay on the high-level readers above. The raw +native bridge calls follow the SyntaxInterface ABI rather than a stable R API. diff --git a/configure b/configure index 2b0d5b5..14d1a9d 100755 --- a/configure +++ b/configure @@ -88,6 +88,51 @@ JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" # JASPSYNTAX_LIB_PATH: direct path to a pre-built libSyntaxInterface (.so/.dylib) # Overrides both JASP_BUILD_DIR and the GitHub Release download. JASPSYNTAX_LIB_PATH="${JASPSYNTAX_LIB_PATH:-}" +SYNTAXINTERFACE_HEADER_PATH="src/syntaxbridge_interface.h" +SYNTAXINTERFACE_HEADER_ORIGIN="" +SYNTAXINTERFACE_BINARY_PATH="src/${DLL_NAME}" +SYNTAXINTERFACE_BINARY_ORIGIN="" + +function fileSha256() { + local FILE_PATH="$1" + + if [ ! -f "${FILE_PATH}" ]; then + echo "unavailable" + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "${FILE_PATH}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${FILE_PATH}" | awk '{print $1}' + else + echo "unavailable" + fi +} + +function writeSyntaxInterfaceProvenance() { + if [ -z "${SYNTAXINTERFACE_HEADER_ORIGIN}" ]; then + SYNTAXINTERFACE_HEADER_ORIGIN="local:${SYNTAXINTERFACE_HEADER_PATH}" + fi + if [ -z "${SYNTAXINTERFACE_BINARY_ORIGIN}" ]; then + SYNTAXINTERFACE_BINARY_ORIGIN="local:${SYNTAXINTERFACE_BINARY_PATH}" + fi + + cat > "src/SyntaxInterface.provenance" </dev/null || date) +platform=${UNAME_S} +architecture=${ARCH} +release_tag=${GITHUB_RELEASE_TAG} +header_path=${SYNTAXINTERFACE_HEADER_PATH} +header_origin=${SYNTAXINTERFACE_HEADER_ORIGIN} +binary_path=${SYNTAXINTERFACE_BINARY_PATH} +binary_origin=${SYNTAXINTERFACE_BINARY_ORIGIN} +header_sha256=$(fileSha256 "${SYNTAXINTERFACE_HEADER_PATH}") +binary_sha256=$(fileSha256 "${SYNTAXINTERFACE_BINARY_PATH}") +jasp_source_dir=${JASP_SOURCE_DIR} +jasp_build_dir=${JASP_BUILD_DIR} +jaspsyntax_lib_path=${JASPSYNTAX_LIB_PATH} +check_exports=${JASPSYNTAX_CHECK_EXPORTS:-auto} +EOF +} # ---------- Download header files if needed ---------- @@ -97,6 +142,8 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\" -I\"${JASP_SOURCE_DIR}/Common\"" mkdir -p 'src/json' + SYNTAXINTERFACE_HEADER_ORIGIN="${JASP_SOURCE_DIR}/SyntaxInterface/syntaxbridge_interface.h" + cp "${SYNTAXINTERFACE_HEADER_ORIGIN}" "${SYNTAXINTERFACE_HEADER_PATH}" for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" @@ -107,6 +154,7 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then fi loadFile "${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface" "syntaxbridge_interface.h" + SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface/syntaxbridge_interface.h" mkdir -p 'src/json' @@ -119,10 +167,12 @@ fi if [[ "${JASPSYNTAX_LIB_PATH}" ]]; then echo "Using JASPSYNTAX_LIB_PATH: ${JASPSYNTAX_LIB_PATH}" + SYNTAXINTERFACE_BINARY_ORIGIN="${JASPSYNTAX_LIB_PATH}" cp "${JASPSYNTAX_LIB_PATH}" "src/${DLL_NAME}" elif [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" - cp "${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" "src/${DLL_NAME}" + SYNTAXINTERFACE_BINARY_ORIGIN="${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" + cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${DLL_NAME}" elif [ ! -f "src/${DLL_NAME}" ]; then echo "Downloading pre-built ${RELEASE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." @@ -170,11 +220,18 @@ elif [ ! -f "src/${DLL_NAME}" ]; then echo "Warning: could not download SHA256SUMS, skipping checksum verification" fi fi + SYNTAXINTERFACE_BINARY_ORIGIN="${GITHUB_RELEASE_URL}/${RELEASE_ASSET}" fi +writeSyntaxInterfaceProvenance + +SYNTAXINTERFACE_HEADER_ORIGIN="${SYNTAXINTERFACE_HEADER_ORIGIN}" \ +SYNTAXINTERFACE_BINARY_ORIGIN="${SYNTAXINTERFACE_BINARY_ORIGIN}" \ + "${BASH:-bash}" tools/check-syntaxinterface-symbols.sh "${SYNTAXINTERFACE_HEADER_PATH}" "${SYNTAXINTERFACE_BINARY_PATH}" "src/syntaxfunctions.cpp" mkdir -p inst/libs cp "src/${DLL_NAME}" "inst/libs/${DLL_NAME}" +cp "src/SyntaxInterface.provenance" "inst/libs/SyntaxInterface.provenance" PKG_LIBS=-lSyntaxInterface diff --git a/configure.win b/configure.win index c657b22..7710ca8 100644 --- a/configure.win +++ b/configure.win @@ -83,14 +83,25 @@ function verifyChecksum() { fi } -function copyOptionalFile() { +function findRuntimeCandidate() { local FILE_NAME="$1" shift + local SOURCE_DIR + local CANDIDATE for SOURCE_DIR in "$@"; do - if [ -n "${SOURCE_DIR}" ] && [ -f "${SOURCE_DIR}/${FILE_NAME}" ]; then - echo "Copying ${FILE_NAME} from ${SOURCE_DIR}" - cp "${SOURCE_DIR}/${FILE_NAME}" "src/${FILE_NAME}" + if [ -z "${SOURCE_DIR}" ] || [ ! -d "${SOURCE_DIR}" ]; then + continue + fi + + if [ -f "${SOURCE_DIR}/${FILE_NAME}" ]; then + echo "${SOURCE_DIR}/${FILE_NAME}" + return 0 + fi + + CANDIDATE=$(find "${SOURCE_DIR}" -maxdepth 1 -type f -iname "${FILE_NAME}" 2>/dev/null | head -n 1) + if [ -n "${CANDIDATE}" ]; then + echo "${CANDIDATE}" return 0 fi done @@ -98,6 +109,24 @@ function copyOptionalFile() { return 1 } +function copyOptionalFile() { + local FILE_NAME="$1" + shift + local CANDIDATE + local DESTINATION="src/${FILE_NAME}" + + CANDIDATE=$(findRuntimeCandidate "${FILE_NAME}" "$@" || true) + if [ -n "${CANDIDATE}" ]; then + echo "Copying ${FILE_NAME} from ${CANDIDATE%/*}" + chmod u+w "${DESTINATION}" 2>/dev/null || true + cp -f "${CANDIDATE}" "${DESTINATION}" + chmod u+w "${DESTINATION}" 2>/dev/null || true + return 0 + fi + + return 1 +} + function addRuntimeSearchDir() { local DIR_PATH="$1" local KNOWN_DIR @@ -173,6 +202,254 @@ function discoverLocalRtoolsRuntimeDirs() { IFS="${OLD_IFS}" } +function discoverWindowsSystemRuntimeDirs() { + local SYSTEM_ROOT + local SYSTEM_DIR + + SYSTEM_ROOT=$(printenv SystemRoot 2>/dev/null || printenv SYSTEMROOT 2>/dev/null || true) + if [ -n "${SYSTEM_ROOT}" ]; then + SYSTEM_DIR="${SYSTEM_ROOT}/System32" + if command -v cygpath >/dev/null 2>&1; then + addRuntimeSearchDir "$(cygpath -u "${SYSTEM_DIR}")" + fi + addRuntimeSearchDir "${SYSTEM_DIR}" + fi + + addRuntimeSearchDir "/c/Windows/System32" +} + +function addQtRuntimeDirFromCMakeCache() { + local CACHE_PATH="$1" + local PREFIX_PATH="" + local QT_CORE_DIR="" + + if [ ! -f "${CACHE_PATH}" ]; then + return 0 + fi + + PREFIX_PATH=$(grep -E '^CMAKE_PREFIX_PATH:PATH=' "${CACHE_PATH}" | sed -E 's/^[^=]+=//' | head -n 1) + addRuntimeSearchDir "${PREFIX_PATH}/bin" + + QT_CORE_DIR=$(grep -E '^Qt6Core_DIR:PATH=' "${CACHE_PATH}" | sed -E 's/^[^=]+=//' | head -n 1) + case "${QT_CORE_DIR}" in + */lib/cmake/Qt6Core) + addRuntimeSearchDir "${QT_CORE_DIR%/lib/cmake/Qt6Core}/bin" + ;; + esac +} + +function discoverLocalQtRuntimeDirs() { + local PATH_DIR + local QT_DIR + local OLD_IFS + + addRuntimeSearchDir "${JASPSYNTAX_QT_DIR}" + addRuntimeSearchDir "${JASPSYNTAX_QT_DIR}/bin" + addQtRuntimeDirFromCMakeCache "${JASP_BUILD_DIR}/CMakeCache.txt" + + for QT_DIR in /c/Qt/*/msvc*/bin /c/Qt/*/mingw*/bin /c/Qt/*/*/bin; do + addRuntimeSearchDir "${QT_DIR}" + done + + OLD_IFS="${IFS}" + IFS=':' + for PATH_DIR in ${PATH}; do + case "${PATH_DIR}" in + *Qt*/bin) + addRuntimeSearchDir "${PATH_DIR}" + ;; + esac + done + IFS="${OLD_IFS}" +} + +function dllNameLower() { + echo "$1" | tr '[:upper:]' '[:lower:]' +} + +function findObjdumpTool() { + local SEARCH_DIR + local CANDIDATE + + if [ -n "${OBJDUMP_TOOL}" ] && [ -x "${OBJDUMP_TOOL}" ]; then + echo "${OBJDUMP_TOOL}" + return 0 + fi + + if command -v objdump >/dev/null 2>&1; then + OBJDUMP_TOOL=$(command -v objdump) + echo "${OBJDUMP_TOOL}" + return 0 + fi + + for SEARCH_DIR in "${RUNTIME_SEARCH_DIRS[@]}"; do + if [ -z "${SEARCH_DIR}" ] || [ ! -d "${SEARCH_DIR}" ]; then + continue + fi + + for CANDIDATE in "${SEARCH_DIR}/objdump.exe" "${SEARCH_DIR}/objdump"; do + if [ -x "${CANDIDATE}" ]; then + OBJDUMP_TOOL="${CANDIDATE}" + echo "${OBJDUMP_TOOL}" + return 0 + fi + done + done + + return 1 +} + +function importedDllsForFile() { + local BINARY_PATH="$1" + local OBJDUMP_PATH + + OBJDUMP_PATH=$(findObjdumpTool || true) + if [ -z "${OBJDUMP_PATH}" ]; then + return 1 + fi + + "${OBJDUMP_PATH}" -p "${BINARY_PATH}" 2>/dev/null | + sed -n -E 's/^[[:space:]]*DLL Name:[[:space:]]*//p' +} + +function syntaxInterfaceImportedDlls() { + if importedDllsForFile "${SYNTAXINTERFACE_BINARY_PATH}"; then + return 0 + fi + + if command -v strings >/dev/null 2>&1; then + strings "${SYNTAXINTERFACE_BINARY_PATH}" 2>/dev/null | + grep -E '^[A-Za-z0-9_.-]+\.dll$' || true + else + return 1 + fi +} + +function isPlatformRuntimeDll() { + local DLL_LOWER + DLL_LOWER=$(dllNameLower "$1") + + case "${DLL_LOWER}" in + api-ms-*.dll|ext-ms-*.dll|advapi32.dll|authz.dll|bcrypt.dll|cfgmgr32.dll|comctl32.dll|comdlg32.dll|crypt32.dll|cryptbase.dll|d3d*.dll|dnsapi.dll|dwmapi.dll|dwrite.dll|dxgi.dll|gdi32.dll|glu32.dll|imm32.dll|iphlpapi.dll|kernel32.dll|mpr.dll|msvcp140*.dll|msvcrt.dll|netapi32.dll|ntdll.dll|ole32.dll|oleacc.dll|oleaut32.dll|opengl32.dll|powrprof.dll|propsys.dll|psapi.dll|rpcrt4.dll|secur32.dll|setupapi.dll|shell32.dll|shlwapi.dll|user32.dll|userenv.dll|usp10.dll|ucrtbase.dll|uxtheme.dll|vcruntime140*.dll|version.dll|winhttp.dll|wininet.dll|winmm.dll|ws2_32.dll|wtsapi32.dll|xmllite.dll|r.dll|rgraphapp.dll|rblas.dll|rlapack.dll) + return 0 + ;; + esac + + return 1 +} + +function wasRuntimeBinaryProcessed() { + local BINARY_PATH="$1" + local PROCESSED + + for PROCESSED in "${PROCESSED_RUNTIME_BINARIES[@]}"; do + if [ "${PROCESSED}" = "${BINARY_PATH}" ]; then + return 0 + fi + done + + return 1 +} + +function markRuntimeBinaryProcessed() { + PROCESSED_RUNTIME_BINARIES+=("$1") +} + +function addMissingTransitiveRuntimeDll() { + local DLL_NAME="$1" + local IMPORTER="$2" + local ENTRY="${DLL_NAME} imported by ${IMPORTER}" + local KNOWN_ENTRY + + for KNOWN_ENTRY in "${MISSING_TRANSITIVE_RUNTIME_DLLS[@]}"; do + if [ "${KNOWN_ENTRY}" = "${ENTRY}" ]; then + return 0 + fi + done + + MISSING_TRANSITIVE_RUNTIME_DLLS+=("${ENTRY}") +} + +function collectBundledRuntimeBinaries() { + local CANDIDATE + + for CANDIDATE in src/*.dll src/platforms/*.dll; do + if [ -f "${CANDIDATE}" ]; then + echo "${CANDIDATE}" + fi + done +} + +function bundleTransitiveRuntimeDlls() { + local QUEUE=() + local INDEX=0 + local BINARY_PATH + local IMPORTED_DLL + local CANDIDATE + local ENTRY + local SEARCH_DIR + local OBJDUMP_PATH + + OBJDUMP_PATH=$(findObjdumpTool || true) + if [ -z "${OBJDUMP_PATH}" ]; then + printf "Warning: objdump is not available; jaspSyntax cannot inspect transitive Windows DLL dependencies automatically.\n\ +If package loading later fails with a DLL load error, reinstall with JASPSYNTAX_RUNTIME_DIR pointing to Rtools/ucrt64/bin and, for dynamic Qt builds, JASPSYNTAX_QT_DIR pointing to the Qt bin directory.\n" + return 0 + fi + + for BINARY_PATH in "$@"; do + if [ -f "${BINARY_PATH}" ]; then + QUEUE+=("${BINARY_PATH}") + fi + done + + while [ "${INDEX}" -lt "${#QUEUE[@]}" ]; do + BINARY_PATH="${QUEUE[$INDEX]}" + INDEX=$((INDEX + 1)) + + if wasRuntimeBinaryProcessed "${BINARY_PATH}"; then + continue + fi + markRuntimeBinaryProcessed "${BINARY_PATH}" + + while IFS= read -r IMPORTED_DLL; do + if [ -z "${IMPORTED_DLL}" ] || isPlatformRuntimeDll "${IMPORTED_DLL}"; then + continue + fi + + CANDIDATE="" + if copyOptionalFile "${IMPORTED_DLL}" "${RUNTIME_SEARCH_DIRS[@]}"; then + CANDIDATE="src/${IMPORTED_DLL}" + elif [ -f "src/${IMPORTED_DLL}" ]; then + CANDIDATE="src/${IMPORTED_DLL}" + elif [ -f "src/platforms/${IMPORTED_DLL}" ]; then + CANDIDATE="src/platforms/${IMPORTED_DLL}" + else + addMissingTransitiveRuntimeDll "${IMPORTED_DLL}" "${BINARY_PATH}" + continue + fi + + QUEUE+=("${CANDIDATE}") + done < <(importedDllsForFile "${BINARY_PATH}" | sort -u) + done + + if [ "${#MISSING_TRANSITIVE_RUNTIME_DLLS[@]}" -gt 0 ]; then + printf "Installing jaspSyntax failed because transitive Windows runtime DLL dependencies could not be bundled automatically.\n\ +Set JASPSYNTAX_RUNTIME_DIR to the Rtools/ucrt64/bin directory and, for dynamic Qt builds, set JASPSYNTAX_QT_DIR to the Qt bin directory. If you are using a local Desktop build, prefer JASPSYNTAX_LIB_DIR or JASP_BUILD_DIR from the same build tree.\n" + echo "Missing DLL dependencies:" + for ENTRY in "${MISSING_TRANSITIVE_RUNTIME_DLLS[@]}"; do + echo " ${ENTRY}" + done + echo "Searched runtime directories:" + for SEARCH_DIR in "${RUNTIME_SEARCH_DIRS[@]}"; do + if [ -n "${SEARCH_DIR}" ]; then + echo " ${SEARCH_DIR}" + fi + done + exit 1 + fi +} + function requireRuntimeFile() { local FILE_NAME="$1" shift @@ -187,9 +464,10 @@ function requireRuntimeFile() { fi printf "Installing jaspSyntax failed because the required runtime DLL %s could not be located.\n\ -The downloaded Windows binaries require the matching local Rtools UCRT runtime.\n\ -Install the Rtools UCRT toolchain for your R version or set configure.vars, for example:\n\ -options(configure.vars = c(jaspSyntax = \"JASPSYNTAX_RUNTIME_DIR='/ucrt64/bin'\"))\n" "${FILE_NAME}" +The selected SyntaxInterface binary requires matching runtime DLLs. For Rtools \ +DLLs set JASPSYNTAX_RUNTIME_DIR; for dynamic Qt builds set JASPSYNTAX_QT_DIR, \ +for example:\n\ +options(configure.vars = c(jaspSyntax = \"JASPSYNTAX_RUNTIME_DIR='/ucrt64/bin' JASPSYNTAX_QT_DIR='/bin'\"))\n" "${FILE_NAME}" if [ "$#" -gt 0 ]; then echo "Searched runtime directories:" @@ -203,6 +481,55 @@ options(configure.vars = c(jaspSyntax = \"JASPSYNTAX_RUNTIME_DIR='/dev/null 2>&1; then + sha256sum "${FILE_PATH}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${FILE_PATH}" | awk '{print $1}' + else + echo "unavailable" + fi +} + +function writeSyntaxInterfaceProvenance() { + if [ -z "${SYNTAXINTERFACE_HEADER_ORIGIN}" ]; then + SYNTAXINTERFACE_HEADER_ORIGIN="local:${SYNTAXINTERFACE_HEADER_PATH}" + fi + if [ -z "${SYNTAXINTERFACE_BINARY_ORIGIN}" ]; then + SYNTAXINTERFACE_BINARY_ORIGIN="local:${SYNTAXINTERFACE_BINARY_PATH}" + fi + + cat > "src/SyntaxInterface.provenance" </dev/null || date) +platform=Windows +architecture=x86_64 +release_tag=${GITHUB_RELEASE_TAG} +header_path=${SYNTAXINTERFACE_HEADER_PATH} +header_origin=${SYNTAXINTERFACE_HEADER_ORIGIN} +binary_path=${SYNTAXINTERFACE_BINARY_PATH} +binary_origin=${SYNTAXINTERFACE_BINARY_ORIGIN} +header_sha256=$(fileSha256 "${SYNTAXINTERFACE_HEADER_PATH}") +binary_sha256=$(fileSha256 "${SYNTAXINTERFACE_BINARY_PATH}") +jasp_source_dir=${JASP_SOURCE_DIR} +jasp_build_dir=${JASP_BUILD_DIR} +jaspsyntax_lib_dir=${JASPSYNTAX_LIB_DIR} +jaspsyntax_runtime_dir=${JASPSYNTAX_RUNTIME_DIR} +jaspsyntax_qt_dir=${JASPSYNTAX_QT_DIR} +check_exports=${JASPSYNTAX_CHECK_EXPORTS:-auto} +EOF +} # ---------- Download header files if needed ---------- @@ -225,6 +603,8 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then echo "JASP_SOURCE_DIR: ${JASP_SOURCE_DIR}" PKG_CXXFLAGS="-I\"${JASP_SOURCE_DIR}/SyntaxInterface\" -I\"${JASP_SOURCE_DIR}/Common\"" mkdir -p 'src/json' + SYNTAXINTERFACE_HEADER_ORIGIN="${JASP_SOURCE_DIR}/SyntaxInterface/syntaxbridge_interface.h" + cp "${SYNTAXINTERFACE_HEADER_ORIGIN}" "${SYNTAXINTERFACE_HEADER_PATH}" for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" @@ -235,6 +615,7 @@ elif [ ! -f "src/syntaxbridge_interface.h" ]; then fi loadFile "${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface" "syntaxbridge_interface.h" + SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface/syntaxbridge_interface.h" mkdir -p 'src/json' @@ -250,10 +631,28 @@ SYNTAXINTERFACE_ASSET="SyntaxInterface-windows-x86_64.dll" if [[ "${JASPSYNTAX_LIB_DIR}" ]]; then echo "Using JASPSYNTAX_LIB_DIR: ${JASPSYNTAX_LIB_DIR}" - cp "${JASPSYNTAX_LIB_DIR}/${SYNTAXINTERFACE_DLL}" "src/${SYNTAXINTERFACE_DLL}" + SYNTAXINTERFACE_BINARY_ORIGIN="${JASPSYNTAX_LIB_DIR}/${SYNTAXINTERFACE_DLL}" + cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${SYNTAXINTERFACE_DLL}" elif [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" - cp "${JASP_BUILD_DIR}/${SYNTAXINTERFACE_DLL}" "src/${SYNTAXINTERFACE_DLL}" + for CANDIDATE in \ + "${JASP_BUILD_DIR}/${SYNTAXINTERFACE_DLL}" \ + "${JASP_BUILD_DIR}/SyntaxInterface/${SYNTAXINTERFACE_DLL}" \ + "${JASP_BUILD_DIR}/bin/${SYNTAXINTERFACE_DLL}" + do + if [ -f "${CANDIDATE}" ]; then + SYNTAXINTERFACE_BINARY_ORIGIN="${CANDIDATE}" + break + fi + done + if [ -z "${SYNTAXINTERFACE_BINARY_ORIGIN}" ]; then + echo "ERROR: Could not locate ${SYNTAXINTERFACE_DLL} under JASP_BUILD_DIR=${JASP_BUILD_DIR}" + echo " Tried: ${JASP_BUILD_DIR}/${SYNTAXINTERFACE_DLL}" + echo " ${JASP_BUILD_DIR}/SyntaxInterface/${SYNTAXINTERFACE_DLL}" + echo " ${JASP_BUILD_DIR}/bin/${SYNTAXINTERFACE_DLL}" + exit 1 + fi + cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${SYNTAXINTERFACE_DLL}" elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then echo "Downloading pre-built ${SYNTAXINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." if ! downloadFile "${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" "src/${SYNTAXINTERFACE_DLL}"; then @@ -262,6 +661,7 @@ elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then exit 1 fi verifyChecksum "src/${SYNTAXINTERFACE_DLL}" "${SYNTAXINTERFACE_ASSET}" + SYNTAXINTERFACE_BINARY_ORIGIN="${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" fi # ---------- Download libR-InterfaceNoRInside.dll ---------- @@ -272,7 +672,25 @@ RINTERFACE_ASSET="libR-InterfaceNoRInside-windows-x86_64.dll" if [[ "${JASPSYNTAX_LIB_DIR}" ]] && [ -f "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" ]; then cp "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" src/ elif [[ "${JASP_BUILD_DIR}" ]]; then - cp "${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" src/ + RINTERFACE_ORIGIN="" + for CANDIDATE in \ + "${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" \ + "${JASP_BUILD_DIR}/${RINTERFACE_DLL}" \ + "${JASP_BUILD_DIR}/bin/${RINTERFACE_DLL}" + do + if [ -f "${CANDIDATE}" ]; then + RINTERFACE_ORIGIN="${CANDIDATE}" + break + fi + done + if [ -z "${RINTERFACE_ORIGIN}" ]; then + echo "ERROR: Could not locate ${RINTERFACE_DLL} under JASP_BUILD_DIR=${JASP_BUILD_DIR}" + echo " Tried: ${JASP_BUILD_DIR}/R-Interface/${RINTERFACE_DLL}" + echo " ${JASP_BUILD_DIR}/${RINTERFACE_DLL}" + echo " ${JASP_BUILD_DIR}/bin/${RINTERFACE_DLL}" + exit 1 + fi + cp "${RINTERFACE_ORIGIN}" src/ elif [ ! -f "src/${RINTERFACE_DLL}" ]; then echo "Downloading pre-built ${RINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." if ! downloadFile "${GITHUB_RELEASE_URL}/${RINTERFACE_ASSET}" "src/${RINTERFACE_DLL}"; then @@ -290,11 +708,32 @@ addRuntimeSearchDir "${JASPSYNTAX_LIB_DIR}" addRuntimeSearchDir "${JASP_BUILD_DIR}" addRuntimeSearchDir "${JASP_BUILD_DIR}/R-Interface" discoverLocalRtoolsRuntimeDirs +discoverWindowsSystemRuntimeDirs for RUNTIME_DLL in ${RUNTIME_DLLS}; do requireRuntimeFile "${RUNTIME_DLL}" "${RUNTIME_SEARCH_DIRS[@]}" done +discoverLocalQtRuntimeDirs +QT_RUNTIME_DLLS=$(syntaxInterfaceImportedDlls | grep -E '^Qt6.*\.dll$' | sort -u || true) +for QT_RUNTIME_DLL in ${QT_RUNTIME_DLLS}; do + requireRuntimeFile "${QT_RUNTIME_DLL}" "${RUNTIME_SEARCH_DIRS[@]}" +done +if [ -n "${QT_RUNTIME_DLLS}" ]; then + requireQtPlatformPlugin "qminimal.dll" +fi + +bundleTransitiveRuntimeDlls $(collectBundledRuntimeBinaries) +if compgen -G "src/Qt6*.dll" >/dev/null; then + requireQtPlatformPlugin "qminimal.dll" + bundleTransitiveRuntimeDlls $(collectBundledRuntimeBinaries) +fi + +writeSyntaxInterfaceProvenance + +SYNTAXINTERFACE_HEADER_ORIGIN="${SYNTAXINTERFACE_HEADER_ORIGIN}" \ +SYNTAXINTERFACE_BINARY_ORIGIN="${SYNTAXINTERFACE_BINARY_ORIGIN}" \ + "${BASH:-bash}" tools/check-syntaxinterface-symbols.sh "${SYNTAXINTERFACE_HEADER_PATH}" "${SYNTAXINTERFACE_BINARY_PATH}" "src/syntaxfunctions.cpp" PKG_CXXFLAGS=${PKG_CXXFLAGS}\ -DUNICODE\ -DWIN32\ -DWIN32_LEAN_AND_MEAN\ -DWIN64\ -D_ENABLE_EXTENDED_ALIGNED_STORAGE diff --git a/man/anRpackage-package.Rd b/man/anRpackage-package.Rd deleted file mode 100644 index a23ac20..0000000 --- a/man/anRpackage-package.Rd +++ /dev/null @@ -1,34 +0,0 @@ -\name{anRpackage-package} -\alias{anRpackage-package} -\alias{anRpackage} -\docType{package} -\title{ - A short title line describing what the package does -} -\description{ - A more detailed description of what the package does. A length - of about one to five lines is recommended. -} -\details{ - This section should provide a more detailed overview of how to use the - package, including the most important functions. -} -\author{ -Your Name, email optional. - -Maintainer: Your Name -} -\references{ - This optional section can contain literature or other references for - background information. -} -\keyword{ package } -\seealso{ - Optional links to other man pages -} -\examples{ - \dontrun{ - ## Optional simple examples of the most important functions - ## These can be in \dontrun{} and \donttest{} blocks. - } -} diff --git a/man/clearQmlForms.Rd b/man/clearQmlForms.Rd new file mode 100644 index 0000000..5bdc5cf --- /dev/null +++ b/man/clearQmlForms.Rd @@ -0,0 +1,24 @@ +\name{clearQmlForms} +\alias{clearQmlForms} +\alias{clearDatasetState} +\alias{clearNativeState} +\title{Native Bridge Lifecycle Helpers} +\usage{ +clearQmlForms() + +clearDatasetState() + +clearNativeState() +} +\value{ +Invisibly returns \code{NULL}. +} +\description{ +These helpers give downstream packages explicit names for the native state +they intend to clear. \code{clearQmlForms()} clears cached QML forms and the QML +component cache, \code{clearDatasetState()} clears bridge-owned dataset state, and +\code{clearNativeState()} clears both. +} +\seealso{ +\code{\link{nativeBridge}} +} diff --git a/man/datasetBridgeHelpers.Rd b/man/datasetBridgeHelpers.Rd new file mode 100644 index 0000000..06bde3e --- /dev/null +++ b/man/datasetBridgeHelpers.Rd @@ -0,0 +1,93 @@ +\name{loadAnalysisDataset} +\alias{loadAnalysisDataset} +\alias{readLoadedDataset} +\alias{readRequestedDataset} +\alias{readDatasetHeader} +\alias{decodeColumnNames} +\alias{columnMapping} +\title{High-Level Native Dataset Helpers} +\usage{ +loadAnalysisDataset( + dataset, + modulePath, + analysisName, + options = NULL, + includeMeta = TRUE, + includeTypeOptions = TRUE, + decode = TRUE, + normalize = TRUE +) + +readLoadedDataset(decode = TRUE, normalize = TRUE) + +readRequestedDataset(decode = TRUE, normalize = TRUE) + +readDatasetHeader(decode = TRUE) + +decodeColumnNames(columnNames, strict = FALSE) + +columnMapping(encodedColumnNames = NULL, strict = FALSE) +} +\arguments{ +\item{dataset}{Raw data frame supplied by the caller.} + +\item{modulePath}{Path to a JASP module source directory.} + +\item{analysisName}{Name of the analysis function.} + +\item{options}{Saved/QML-bound options as a named list, JSON object string, or \code{NULL}.} + +\item{includeMeta}{Whether to retain the \code{.meta} option in runtime options.} + +\item{includeTypeOptions}{Whether to retain \code{*.types} options in runtime options.} + +\item{decode}{Whether to decode native/encoded column names.} + +\item{normalize}{Whether to normalize bridge-returned factor columns back to plain character vectors while preserving numeric-looking factor labels.} + +\item{columnNames}{Character vector of column names.} + +\item{strict}{Whether to fail when the native decoder is unavailable or a name cannot be decoded.} + +\item{encodedColumnNames}{Optional encoded column names. When omitted, the current native dataset header is used.} +} +\value{ +\code{loadAnalysisDataset()} returns a list with \code{loadedDataset}, +\code{requestedDataset}, \code{resultDecodingDataset}, +\code{runtimeOptions}, \code{columnMapping}, \code{modulePath}, and +\code{analysisName}. + +\code{readLoadedDataset()} and \code{readRequestedDataset()} return data +frames. \code{readDatasetHeader()} returns a data frame with \code{name} and +\code{encodedName} columns. \code{decodeColumnNames()} returns a character +vector. \code{columnMapping()} returns a named character vector mapping encoded +names to decoded names. +} +\description{ +These helpers keep native dataset preload, requested-data state, and column-name +decoding behind the jaspSyntax API. They are the preferred interface for +downstream packages that need loaded/requested dataset state after QML/runtime +option preparation. +} +\details{ +\code{loadAnalysisDataset()} is the supported high-level entry point for +jaspTools-style replay. The direct state readers and column mapping helpers are +exported for bridge diagnostics and should be treated as native-facing +integration APIs. + +\code{loadAnalysisDataset()} loads the raw data frame into SyntaxInterface, +replays saved/QML-bound options through \code{readAnalysisOptionsFromQml()} with +\code{fresh = TRUE}, then reads both the loaded and requested native dataset +state. This keeps QML option \code{value}/\code{types} interpretation in the +native-backed jaspSyntax path. + +The current native bridge exposes requested and decoded dataset state through +R callbacks installed in \code{.GlobalEnv}. These helpers centralize that +callback use. A future Desktop ABI can replace the callback implementation +without changing downstream caller code. +} +\seealso{ +\code{\link{readAnalysisOptionsFromQml}}, +\code{\link{readDatasetFromJaspFile}}, +\code{\link{nativeBridge}} +} diff --git a/man/decodeAnalysisResults.Rd b/man/decodeAnalysisResults.Rd new file mode 100644 index 0000000..64f3811 --- /dev/null +++ b/man/decodeAnalysisResults.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/resultDecoding.R +\name{decodeAnalysisResults} +\alias{decodeAnalysisResults} +\title{Decode JASP Analysis Result Payloads} +\usage{ +decodeAnalysisResults(results, requestedDataset = NULL, columnMapping = NULL) +} +\arguments{ +\item{results}{A result payload list, typically decoded from jaspResults JSON.} + +\item{requestedDataset}{Optional requested dataset to use as the factor-label +source. When omitted, the current native requested dataset is read from the +bridge if available.} + +\item{columnMapping}{Optional named character vector mapping encoded native +column names to decoded user-facing column names. Supplying this avoids a +late native decoder call after analysis execution.} +} +\value{ +The result payload with decoded column names and factor values. +} +\description{ +Decodes native column-name tokens and factor value tokens in analysis results +using the current SyntaxInterface dataset state. +} diff --git a/man/jaspSyntax-package.Rd b/man/jaspSyntax-package.Rd new file mode 100644 index 0000000..a405f0e --- /dev/null +++ b/man/jaspSyntax-package.Rd @@ -0,0 +1,54 @@ +\name{jaspSyntax-package} +\alias{jaspSyntax-package} +\alias{jaspSyntax} +\docType{package} +\title{ +Native JASP SyntaxInterface bridge for R +} +\description{ +jaspSyntax exposes the native JASP SyntaxInterface bridge to R. It parses +module descriptions, resolves analysis QML files, replays QML option binding, +loads datasets into native state, and reads saved \code{.jasp} files using the +same runtime preparation path as JASP Desktop. +} +\details{ +Saved \code{.jasp} options are read from the archive and then replayed through +QML when runtime options are requested. They are not a replacement for Desktop's +full archive/module upgrade workflow for old files. + +Use \code{readModuleDescription()} and \code{resolveAnalysisQml()} for module +metadata, \code{readAnalysisOptionsFromQml()} or +\code{readDefaultAnalysisOptions()} for QML/runtime options, and +\code{loadAnalysisDataset()}, \code{readLoadedDataset()}, and +\code{readRequestedDataset()} for native dataset state. Use +\code{readAnalysisOptionsFromJaspFile()} plus \code{readDatasetFromJaspFile()} +for saved \code{.jasp} files. The low-level native bridge calls remain exported +for compatibility, but package code should prefer the higher-level helpers. +Helpers such as \code{parseQmlOptions()}, lifecycle controls, dataset-state +readers, column mapping, and \code{nativeBridgeProvenance()} are +native-facing/experimental integration APIs. +} +\author{ +JASP Team + +Maintainer: JASP Team +} +\references{ + This optional section can contain literature or other references for + background information. +} +\keyword{ package } +\seealso{ +\code{\link{readAnalysisOptionsFromJaspFile}}, +\code{\link{readAnalysisOptionsFromQml}}, +\code{\link{loadAnalysisDataset}}, +\code{\link{nativeBridgeProvenance}}, +\code{\link{readDatasetFromJaspFile}}, +\code{\link{readModuleDescription}} +} +\examples{ + \dontrun{ + records <- readAnalysisOptionsFromJaspFile("analysis.jasp") + dataset <- readDatasetFromJaspFile("analysis.jasp") + } +} diff --git a/man/nativeBridge.Rd b/man/nativeBridge.Rd new file mode 100644 index 0000000..71261cd --- /dev/null +++ b/man/nativeBridge.Rd @@ -0,0 +1,82 @@ +\name{nativeBridge} +\alias{analysisOptionsFromJaspFile} +\alias{cleanUp} +\alias{generateAnalysisWrapper} +\alias{generateModuleWrappers} +\alias{getVariableNames} +\alias{loadDataSet} +\alias{loadDataSetFromJaspFile} +\alias{loadQmlAndParseOptions} +\alias{parseDescription} +\alias{setParameter} +\title{Low-Level SyntaxInterface Bridge} +\usage{ +analysisOptionsFromJaspFile(jaspFilePath, analysisNr) + +cleanUp() + +generateAnalysisWrapper(modulePath, analysisName) + +generateModuleWrappers(modulePath) + +getVariableNames() + +loadDataSet(data) + +loadDataSetFromJaspFile(jaspFilePath) + +loadQmlAndParseOptions( + moduleName, + analysisName, + qmlFile, + options, + version, + preloadData +) + +parseDescription(modulePath) + +setParameter(name, value) +} +\arguments{ +\item{jaspFilePath}{Path to a \code{.jasp} file.} + +\item{analysisNr}{Zero-based analysis index.} + +\item{modulePath}{Path to a JASP module.} + +\item{data}{A data frame-like object to load into the native bridge.} + +\item{moduleName}{Module name passed to SyntaxInterface.} + +\item{analysisName}{Analysis name passed to SyntaxInterface.} + +\item{qmlFile}{Path to an analysis QML file.} + +\item{options}{JSON object string with analysis options.} + +\item{version}{Module version passed to SyntaxInterface.} + +\item{preloadData}{Whether the analysis preloads data.} + +\item{name}{Native bridge parameter name.} + +\item{value}{Native bridge parameter value.} +} +\description{ +These functions expose the low-level native SyntaxInterface bridge. They are +exported for compatibility and native integration work. They follow the native +SyntaxInterface ABI and are not the stable package-level contract; ordinary +package code should prefer the higher-level helpers such as +\code{readModuleDescription()}, +\code{readAnalysisOptionsFromQml()}, \code{readDefaultAnalysisOptions()}, +\code{loadAnalysisDataset()}, \code{readLoadedDataset()}, +\code{readRequestedDataset()}, \code{readAnalysisOptionsFromJaspFile()}, and +\code{readDatasetFromJaspFile()}. + +\code{cleanUp()} clears the native state and then runs the legacy cleanup hook. +Use \code{\link{clearQmlForms}}, \code{\link{clearDatasetState}}, or +\code{\link{clearNativeState}} when the intended lifecycle scope matters. +Use \code{\link{nativeBridgeProvenance}} to inspect the recorded +SyntaxInterface header/binary source for ABI diagnostics. +} diff --git a/man/nativeBridgeProvenance.Rd b/man/nativeBridgeProvenance.Rd new file mode 100644 index 0000000..b96e053 --- /dev/null +++ b/man/nativeBridgeProvenance.Rd @@ -0,0 +1,20 @@ +\name{nativeBridgeProvenance} +\alias{nativeBridgeProvenance} +\title{Read Native Bridge Provenance} +\usage{ +nativeBridgeProvenance() +} +\value{ +A named character vector. The \code{path} attribute points to the provenance +file. An empty vector means the installed package did not record provenance. +} +\description{ +Returns installation metadata for the bundled SyntaxInterface bridge, when the +package was installed by a configure script that recorded it. This is a +diagnostic helper for checking whether the header and native binary came from +the same Desktop/build source. Recent installs also record SHA-256 hashes for +the copied header and binary. +} +\seealso{ +\code{\link{nativeBridge}} +} diff --git a/man/parseQmlOptions.Rd b/man/parseQmlOptions.Rd new file mode 100644 index 0000000..110d4d5 --- /dev/null +++ b/man/parseQmlOptions.Rd @@ -0,0 +1,50 @@ +\name{parseQmlOptions} +\alias{parseQmlOptions} +\title{Parse QML Options} +\usage{ +parseQmlOptions( + qmlFile, + options = NULL, + moduleName = "jaspModule", + analysisName = NULL, + version = "0", + preloadData = TRUE, + fresh = TRUE, + output = c("list", "json"), + includeMeta = TRUE, + includeTypeOptions = TRUE +) +} +\arguments{ +\item{qmlFile}{Path to an analysis QML file.} + +\item{options}{Named list of options, a JSON object string, or \code{NULL} for defaults.} + +\item{moduleName}{Module name passed to the native bridge.} + +\item{analysisName}{Analysis name passed to the native bridge. Defaults to the QML file basename without extension.} + +\item{version}{Module version passed to the native bridge.} + +\item{preloadData}{Whether the analysis preloads data.} + +\item{fresh}{Whether to clear cached QML/native state before parsing. This should remain \code{TRUE} when reading defaults.} + +\item{output}{Return parsed R \code{list} output or raw \code{json}.} + +\item{includeMeta}{Whether to retain the \code{.meta} option in list output.} + +\item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} +} +\value{ +A named list of parsed options, or a JSON string when \code{output = "json"}. +} +\description{ +Loads a QML form and parses supplied options through the native SyntaxInterface bridge. The native bridge binds the QML controls, applies option metadata, and runs the same column-name/type encoding used before JASP Desktop calls the R backend. +} +\details{ +This is a low-level/native-facing helper for diagnostics and bridge integration. +Most callers should use \code{\link{readAnalysisOptionsFromQml}} so QML file +names, module versions, and \code{preloadData} come from the parsed module +description. +} diff --git a/man/readAnalysisOptionsFromJaspFile.Rd b/man/readAnalysisOptionsFromJaspFile.Rd new file mode 100644 index 0000000..c846c0c --- /dev/null +++ b/man/readAnalysisOptionsFromJaspFile.Rd @@ -0,0 +1,32 @@ +\name{readAnalysisOptionsFromJaspFile} +\alias{readAnalysisOptionsFromJaspFile} +\title{Read Analysis Options From a JASP File} +\usage{ +readAnalysisOptionsFromJaspFile( + jaspFilePath, + modulePath = NULL, + runtime = FALSE, + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = TRUE +) +} +\arguments{ +\item{jaspFilePath}{Path to a \code{.jasp} file.} + +\item{modulePath}{Optional module path, or a named list/vector of module paths keyed by module name or analysis name. Required for \code{runtime = TRUE} when the module is not installed.} + +\item{runtime}{Whether to replay saved options through QML and the native Desktop option encoder. The default \code{FALSE} returns the saved bound options from \code{analyses.json}.} + +\item{includeMeta}{Whether to retain the \code{.meta} option.} + +\item{includeTypeOptions}{Whether to retain \code{*.types} options when present.} + +\item{isolated}{Whether to run the native \code{.jasp} option extraction in a separate R process. This is the default because the SyntaxInterface bridge owns process-global native state. In-process reads also clear native state before returning; use \code{readDatasetFromJaspFile()} for the saved dataset.} +} +\value{ +A list of analysis records. Each record has \code{name}, \code{title}, \code{moduleName}, \code{moduleVersion}, and \code{options}. +} +\description{ +Reads all saved analyses from a \code{.jasp} file and returns their metadata together with their saved QML-bound options. With \code{runtime = TRUE}, saved options are replayed through the resolved QML form and native Desktop option encoder so the result matches the R-runtime options prepared by JASP Desktop before calling the analysis. This helper reads the options stored in the archive; it does not replace Desktop's full archive/module upgrade workflow for older files. +} diff --git a/man/readAnalysisOptionsFromQml.Rd b/man/readAnalysisOptionsFromQml.Rd new file mode 100644 index 0000000..2f8fc33 --- /dev/null +++ b/man/readAnalysisOptionsFromQml.Rd @@ -0,0 +1,50 @@ +\name{readAnalysisOptionsFromQml} +\alias{analysisOptionsFromQml} +\alias{readAnalysisOptionsFromQml} +\title{Read Analysis Options Through QML} +\usage{ +readAnalysisOptionsFromQml( + modulePath, + analysisName, + options = NULL, + version = NULL, + preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE +) + +analysisOptionsFromQml( + modulePath, + analysisName, + options = NULL, + version = NULL, + preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE +) +} +\arguments{ +\item{modulePath}{Path to a JASP module source directory.} + +\item{analysisName}{Name of the analysis function.} + +\item{options}{Named list of options, a JSON object string, or \code{NULL} for defaults.} + +\item{version}{Optional module version override. Defaults to the version from the module description.} + +\item{preloadData}{Optional preload flag override. Defaults to the analysis value from the module description.} + +\item{fresh}{Whether to clear cached QML/native state before parsing.} + +\item{includeMeta}{Whether to retain the \code{.meta} option in list output.} + +\item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} +} +\value{ +A named list of parsed options. +} +\description{ +Resolves an analysis in a module, loads its QML form, and parses options through the native SyntaxInterface path. The returned options are the same R-runtime JSON shape prepared for analyses by JASP Desktop: QML controls are bound, option metadata is applied, and column-name/type encoding is handled by the native \code{ColumnEncoder}. +} diff --git a/man/readDatasetFromJaspFile.Rd b/man/readDatasetFromJaspFile.Rd index fad856b..9672640 100644 --- a/man/readDatasetFromJaspFile.Rd +++ b/man/readDatasetFromJaspFile.Rd @@ -18,7 +18,10 @@ Reads the dataset stored inside a saved JASP file and materializes it as an R \c \details{ This function isolates the native jaspSyntax dataset-loading path in a short-lived subprocess so repeated calls remain safe within the calling R session. -Categorical columns are returned as character vectors. Numeric columns are returned using the bridge's native conversion. +Dataset materialization is delegated to \code{\link{readLoadedDataset}} so +column-name decoding and bridge factor normalization stay behind the jaspSyntax +dataset API. Categorical columns are returned as character vectors. Numeric +columns are returned using the bridge's native conversion. } \examples{ \dontrun{ diff --git a/man/readDefaultAnalysisOptions.Rd b/man/readDefaultAnalysisOptions.Rd new file mode 100644 index 0000000..925e2d6 --- /dev/null +++ b/man/readDefaultAnalysisOptions.Rd @@ -0,0 +1,29 @@ +\name{readDefaultAnalysisOptions} +\alias{readDefaultAnalysisOptions} +\title{Read Default Analysis Options} +\usage{ +readDefaultAnalysisOptions( + modulePath, + analysisName, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE +) +} +\arguments{ +\item{modulePath}{Path to a JASP module source directory.} + +\item{analysisName}{Name of the analysis function.} + +\item{fresh}{Whether to clear cached QML/native state before parsing.} + +\item{includeMeta}{Whether to retain the \code{.meta} option in list output.} + +\item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} +} +\value{ +A named list of default options. +} +\description{ +Loads an analysis QML form and returns the options produced by the native SyntaxInterface defaults. +} diff --git a/man/readModuleDescription.Rd b/man/readModuleDescription.Rd new file mode 100644 index 0000000..dea6852 --- /dev/null +++ b/man/readModuleDescription.Rd @@ -0,0 +1,20 @@ +\name{readModuleDescription} +\alias{parseModuleDescription} +\alias{readModuleDescription} +\title{Read a JASP Module Description} +\usage{ +parseModuleDescription(modulePath, byName = TRUE) + +readModuleDescription(modulePath, byName = TRUE) +} +\arguments{ +\item{modulePath}{Path to a JASP module source directory or its \code{inst/Description.qml} file.} + +\item{byName}{Whether to name the returned \code{analyses} list by analysis name.} +} +\value{ +A list with module metadata and an \code{analyses} list. +} +\description{ +Reads a module's \code{Description.qml} through the native SyntaxInterface bridge. +} diff --git a/man/resolveAnalysisQml.Rd b/man/resolveAnalysisQml.Rd new file mode 100644 index 0000000..1b02fe9 --- /dev/null +++ b/man/resolveAnalysisQml.Rd @@ -0,0 +1,17 @@ +\name{resolveAnalysisQml} +\alias{resolveAnalysisQml} +\title{Resolve an Analysis QML File} +\usage{ +resolveAnalysisQml(modulePath, analysisName) +} +\arguments{ +\item{modulePath}{Path to a JASP module source directory or its \code{inst/Description.qml} file.} + +\item{analysisName}{Name of the analysis function.} +} +\value{ +A list with module description, analysis metadata, QML file path, and resolved preload flag. +} +\description{ +Resolves an analysis name to the QML file and metadata provided by the native module description parser. +} diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index 3b6dd3c..77b68c3 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -19,6 +19,33 @@ BEGIN_RCPP return R_NilValue; END_RCPP } +// clearQmlFormsNative +void clearQmlFormsNative(); +RcppExport SEXP _jaspSyntax_clearQmlFormsNative() { +BEGIN_RCPP + Rcpp::RNGScope rcpp_rngScope_gen; + clearQmlFormsNative(); + return R_NilValue; +END_RCPP +} +// clearDatasetStateNative +void clearDatasetStateNative(); +RcppExport SEXP _jaspSyntax_clearDatasetStateNative() { +BEGIN_RCPP + Rcpp::RNGScope rcpp_rngScope_gen; + clearDatasetStateNative(); + return R_NilValue; +END_RCPP +} +// clearNativeStateNative +void clearNativeStateNative(); +RcppExport SEXP _jaspSyntax_clearNativeStateNative() { +BEGIN_RCPP + Rcpp::RNGScope rcpp_rngScope_gen; + clearNativeStateNative(); + return R_NilValue; +END_RCPP +} // setParameter bool setParameter(String name, SEXP value); RcppExport SEXP _jaspSyntax_setParameter(SEXP nameSEXP, SEXP valueSEXP) { @@ -126,6 +153,9 @@ END_RCPP static const R_CallMethodDef CallEntries[] = { {"_jaspSyntax_cleanUp", (DL_FUNC) &_jaspSyntax_cleanUp, 0}, + {"_jaspSyntax_clearQmlFormsNative", (DL_FUNC) &_jaspSyntax_clearQmlFormsNative, 0}, + {"_jaspSyntax_clearDatasetStateNative", (DL_FUNC) &_jaspSyntax_clearDatasetStateNative, 0}, + {"_jaspSyntax_clearNativeStateNative", (DL_FUNC) &_jaspSyntax_clearNativeStateNative, 0}, {"_jaspSyntax_setParameter", (DL_FUNC) &_jaspSyntax_setParameter, 2}, {"_jaspSyntax_loadDataSet", (DL_FUNC) &_jaspSyntax_loadDataSet, 1}, {"_jaspSyntax_loadQmlAndParseOptions", (DL_FUNC) &_jaspSyntax_loadQmlAndParseOptions, 6}, diff --git a/src/dataframeimporter.cpp b/src/dataframeimporter.cpp index 771024d..7c944a6 100644 --- a/src/dataframeimporter.cpp +++ b/src/dataframeimporter.cpp @@ -67,6 +67,24 @@ std::vector DataFrameImporter::readCharacterVector(Rcpp::Vector DataFrameImporter::readFactorVector(Rcpp::IntegerVector obj) +{ + Rcpp::CharacterVector levels = obj.attr("levels"); + std::vector result; + result.reserve(obj.size()); + + for (int row = 0; row < obj.size(); row++) + { + int levelIndex = obj[row]; + if (levelIndex == NA_INTEGER || levelIndex < 1 || levelIndex > levels.size() || levels[levelIndex - 1] == NA_STRING) + result.push_back(""); + else + result.push_back(Rcpp::as(levels[levelIndex - 1])); + } + + return result; +} + void DataFrameImporter::freeDataSet() { for (int colNr = 0; colNr < datasetStatic.columnCount; colNr++) @@ -87,7 +105,7 @@ void DataFrameImporter::freeDataSet() datasetStatic.columns = nullptr; } -const SyntaxBridgeDataSet& DataFrameImporter::loadDataFrame(Rcpp::List dataframe) +const SyntaxBridgeDataSet& DataFrameImporter::loadDataFrame(const Rcpp::List& dataframe) { freeDataSet(); @@ -117,7 +135,8 @@ const SyntaxBridgeDataSet& DataFrameImporter::loadDataFrame(Rcpp::List dataframe Rcpp::RObject colObj = (Rcpp::RObject)dataframe[colNr]; - if(Rcpp::is(colObj)) colValues = readCharacterVector((Rcpp::NumericVector)colObj); + if(Rf_inherits(colObj, "factor")) colValues = readFactorVector((Rcpp::IntegerVector)colObj); + else if(Rcpp::is(colObj)) colValues = readCharacterVector((Rcpp::NumericVector)colObj); else if(Rcpp::is(colObj)) colValues = readCharacterVector((Rcpp::IntegerVector)colObj); else if(Rcpp::is(colObj)) colValues = readCharacterVector((Rcpp::LogicalVector)colObj); else if(Rcpp::is(colObj)) colValues = readCharacterVector((Rcpp::CharacterVector)colObj); @@ -128,8 +147,9 @@ const SyntaxBridgeDataSet& DataFrameImporter::loadDataFrame(Rcpp::List dataframe colValues = std::vector(maxRows); } - if (colValues.size() > maxRows) - maxRows = colValues.size(); + const int columnRows = static_cast(colValues.size()); + if (columnRows > maxRows) + maxRows = columnRows; allColumns.push_back(colValues); } diff --git a/src/dataframeimporter.h b/src/dataframeimporter.h index 7e234c3..7969ce1 100644 --- a/src/dataframeimporter.h +++ b/src/dataframeimporter.h @@ -28,7 +28,7 @@ class DataFrameImporter { public: - static const SyntaxBridgeDataSet& loadDataFrame(Rcpp::List dataframe); + static const SyntaxBridgeDataSet& loadDataFrame(const Rcpp::List& dataframe); static Rcpp::List getVariableNames(); static Rcpp::List getVariableValues(Rcpp::String variableName); @@ -36,6 +36,8 @@ class DataFrameImporter static SyntaxBridgeDataSet datasetStatic; static void freeDataSet(); + static std::vector readFactorVector(Rcpp::IntegerVector obj); + template static std::vector readCharacterVector(Rcpp::Vector obj); }; diff --git a/src/install.libs.R b/src/install.libs.R index e52ce3b..fc1e341 100644 --- a/src/install.libs.R +++ b/src/install.libs.R @@ -5,7 +5,8 @@ if (!file.exists(package_library)) { } shared_libraries <- Sys.glob(c("*.dll", "*.so", "*.dylib")) -files <- unique(c(package_library, shared_libraries)) +metadata_files <- Sys.glob("*.provenance") +files <- unique(c(package_library, shared_libraries, metadata_files)) files <- files[file.exists(files)] dest <- file.path(R_PACKAGE_DIR, paste0("libs", R_ARCH)) @@ -14,4 +15,12 @@ dir.create(dest, recursive = TRUE, showWarnings = FALSE) ok <- file.copy(files, dest, overwrite = TRUE) if (!all(ok)) { stop("Failed to copy compiled libraries into the package libs directory.") -} \ No newline at end of file +} + +plugin_dirs <- Sys.glob("platforms") +if (length(plugin_dirs) > 0L) { + ok <- file.copy(plugin_dirs, dest, recursive = TRUE, overwrite = TRUE) + if (!all(ok)) { + stop("Failed to copy Qt platform plugins into the package libs directory.") + } +} diff --git a/src/syntaxfunctions.cpp b/src/syntaxfunctions.cpp index 8e83e89..bfe7c58 100644 --- a/src/syntaxfunctions.cpp +++ b/src/syntaxfunctions.cpp @@ -60,7 +60,36 @@ Json::Value parseBridgeJsonOrStop(const char * rawJson, const char * functionNam // [[Rcpp::export]] void cleanUp() { - syntaxBridgeCleanup(); + callBridgeOrStop("syntaxBridgeClearNativeState", []() { + syntaxBridgeClearNativeState(); + }); + callBridgeOrStop("syntaxBridgeCleanup", []() { + syntaxBridgeCleanup(); + }); +} + +// [[Rcpp::export]] +void clearQmlFormsNative() +{ + callBridgeOrStop("syntaxBridgeClearQmlState", []() { + syntaxBridgeClearQmlState(); + }); +} + +// [[Rcpp::export]] +void clearDatasetStateNative() +{ + callBridgeOrStop("syntaxBridgeClearDataSetState", []() { + syntaxBridgeClearDataSetState(); + }); +} + +// [[Rcpp::export]] +void clearNativeStateNative() +{ + callBridgeOrStop("syntaxBridgeClearNativeState", []() { + syntaxBridgeClearNativeState(); + }); } // [[Rcpp::export]] @@ -92,7 +121,9 @@ void loadDataSet(Rcpp::List data) { const SyntaxBridgeDataSet& dataset = DataFrameImporter::loadDataFrame(data); - syntaxBridgeLoadDataSet(&dataset, global_param_dbInMemory, global_param_threshold, global_param_orderLabelsByValue); + callBridgeOrStop("syntaxBridgeLoadDataSet", [&]() { + syntaxBridgeLoadDataSet(&dataset, global_param_dbInMemory, global_param_threshold, global_param_orderLabelsByValue); + }); } @@ -106,7 +137,9 @@ String loadQmlAndParseOptions(String moduleName, String analysisName, String qml moduleNameStr = moduleName.get_cstring(); - return syntaxBridgeLoadQmlAndParseOptions(moduleNameStr.c_str(), analysisNameStr.c_str(), qmlFileStr.c_str(), optionsStr.c_str(), versionStr.c_str(), preloadData); + return callBridgeOrStop("syntaxBridgeLoadQmlAndParseOptions", [&]() { + return syntaxBridgeLoadQmlAndParseOptions(moduleNameStr.c_str(), analysisNameStr.c_str(), qmlFileStr.c_str(), optionsStr.c_str(), versionStr.c_str(), preloadData); + }); } // [[Rcpp::export]] @@ -114,7 +147,9 @@ String generateModuleWrappers(String modulePath) { std::string modulePathStr = modulePath.get_cstring(); - return syntaxBridgeGenerateModuleWrappers(modulePathStr.c_str()); + return callBridgeOrStop("syntaxBridgeGenerateModuleWrappers", [&]() { + return syntaxBridgeGenerateModuleWrappers(modulePathStr.c_str()); + }); } // [[Rcpp::export]] @@ -143,10 +178,12 @@ Rcpp::List parseDescription(String modulePath) result["isCommon"] = parsedDescription["isCommon"].asBool(); result["version"] = parsedDescription["version"].asString(); - Rcpp::List analyses; + const Json::Value & jsonAnalyses = parsedDescription["analyses"]; + Rcpp::List analyses(jsonAnalyses.size()); - for (const Json::Value & jsonAnalysis : parsedDescription["analyses"]) + for (Json::ArrayIndex i = 0; i < jsonAnalyses.size(); ++i) { + const Json::Value & jsonAnalysis = jsonAnalyses[i]; Rcpp::List analysis; analysis["name"] = jsonAnalysis["name"].asString(); analysis["qml"] = jsonAnalysis["qml"].asString(); @@ -154,7 +191,7 @@ Rcpp::List parseDescription(String modulePath) analysis["preloadData"] = jsonAnalysis["preloadData"].asBool(); analysis["hasWrapper"] = jsonAnalysis["hasWrapper"].asBool(); - analyses.push_back(analysis); + analyses[i] = analysis; } result["analyses"] = analyses; @@ -167,74 +204,100 @@ void loadDataSetFromJaspFile(String jaspFilePath) { std::string jaspFilePathStr = jaspFilePath.get_cstring(); - callBridgeOrStop("syntaxBridgeLoadDataSetFromJaspFile", [&]() { - syntaxBridgeLoadDataSetFromJaspFile(jaspFilePathStr.c_str(), global_param_dbInMemory); - return 0; - }); + Json::Value status = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeLoadDataSetFromJaspFileStatus", [&]() { + return syntaxBridgeLoadDataSetFromJaspFileStatus(jaspFilePathStr.c_str(), global_param_dbInMemory); + }), + "syntaxBridgeLoadDataSetFromJaspFileStatus" + ); + + if (!status["ok"].asBool()) + { + std::string error = status.isMember("error") ? status["error"].asString() : "unknown error"; + Rcpp::stop("syntaxBridgeLoadDataSetFromJaspFile failed: %s", error); + } } -Rcpp::List transformJsonObjectToRcppList(const Json::Value & json); +SEXP transformJsonValueToSEXP(const Json::Value & json); Rcpp::List transformJsonArrayToRcppList(const Json::Value & json) { - Rcpp::List result; - for (const Json::Value & jsonElement : json) - { - if (jsonElement.isBool()) - result.push_back(jsonElement.asBool()); - else if (jsonElement.isInt()) - result.push_back(jsonElement.asInt()); - else if (jsonElement.isDouble()) // must be after isInt! - result.push_back(jsonElement.asDouble()); - else if (jsonElement.isString()) - result.push_back(jsonElement.asString()); - else if (jsonElement.isArray()) - result.push_back(transformJsonArrayToRcppList(jsonElement)); - else if (jsonElement.isObject()) - result.push_back(transformJsonObjectToRcppList(jsonElement)); - } + Rcpp::List result(json.size()); + for (Json::ArrayIndex i = 0; i < json.size(); ++i) + result[i] = transformJsonValueToSEXP(json[i]); return result; } Rcpp::List transformJsonObjectToRcppList(const Json::Value & json) { - Rcpp::List result; + std::vector memberNames = json.getMemberNames(); + Rcpp::List result(memberNames.size()); + Rcpp::CharacterVector resultNames(memberNames.size()); - for(const std::string & memberName : json.getMemberNames()) + for (size_t i = 0; i < memberNames.size(); ++i) { - if(memberName == ".meta") - continue; - - const Json::Value & jsonElement = json[memberName]; - if (jsonElement.isBool()) - result[memberName] = jsonElement.asBool(); - else if (jsonElement.isInt()) - result[memberName] = jsonElement.asInt(); - else if (jsonElement.isDouble()) // must be after isInt! - result[memberName] = jsonElement.asDouble(); - else if (jsonElement.isString()) - result[memberName] = jsonElement.asString(); - else if (jsonElement.isArray()) - result[memberName] = transformJsonArrayToRcppList(jsonElement); - else if (jsonElement.isObject()) - result[memberName] = transformJsonObjectToRcppList(jsonElement); + result[i] = transformJsonValueToSEXP(json[memberNames[i]]); + resultNames[i] = memberNames[i]; } + result.attr("names") = resultNames; + return result; } +SEXP transformJsonValueToSEXP(const Json::Value & json) +{ + if (json.isNull()) + return R_NilValue; + else if (json.isBool()) + return Rcpp::wrap(json.asBool()); + else if (json.isInt()) + return Rcpp::wrap(json.asInt()); + else if (json.isDouble()) // must be after isInt! + return Rcpp::wrap(json.asDouble()); + else if (json.isString()) + return Rcpp::wrap(json.asString()); + else if (json.isArray()) + return Rcpp::wrap(transformJsonArrayToRcppList(json)); + else if (json.isObject()) + return Rcpp::wrap(transformJsonObjectToRcppList(json)); + + return R_NilValue; +} + // [[Rcpp::export]] Rcpp::List analysisOptionsFromJaspFile(String jaspFilePath, int analysisNr) { std::string jaspFilePathStr = jaspFilePath.get_cstring(); - Json::Value parsedOptions = parseBridgeJsonOrStop( - callBridgeOrStop("syntaxBridgeAnalysisOptionsFromJaspFile", [&]() { - return syntaxBridgeAnalysisOptionsFromJaspFile(jaspFilePathStr.c_str(), analysisNr); + Json::Value status = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeAnalysisOptionsFromJaspFileStatus", [&]() { + return syntaxBridgeAnalysisOptionsFromJaspFileStatus(jaspFilePathStr.c_str(), analysisNr); }), - "syntaxBridgeAnalysisOptionsFromJaspFile" + "syntaxBridgeAnalysisOptionsFromJaspFileStatus" ); + if (!status.isObject()) + Rcpp::stop("syntaxBridgeAnalysisOptionsFromJaspFileStatus returned a non-object status."); + if (!status["ok"].asBool()) + { + std::string error = status.isMember("error") ? status["error"].asString() : "unknown error"; + Rcpp::stop( + "syntaxBridgeAnalysisOptionsFromJaspFile failed for analysis %d in file %s: %s", + analysisNr, + jaspFilePathStr.c_str(), + error + ); + } + + const Json::Value & parsedOptions = status["options"]; + if (!parsedOptions.isObject()) + Rcpp::stop( + "syntaxBridgeAnalysisOptionsFromJaspFileStatus returned %s instead of a JSON object for analysis %d in file: %s", + parsedOptions.isNull() ? "null" : "a non-object value", + analysisNr, + jaspFilePathStr.c_str() + ); return transformJsonObjectToRcppList(parsedOptions); } @@ -245,7 +308,9 @@ String generateAnalysisWrapper(String modulePath, String analysisName) std::string modulePathStr = modulePath.get_cstring(), analysisNameStr = analysisName.get_cstring(); - return syntaxBridgeGenerateAnalysisWrapper(modulePathStr.c_str(), analysisNameStr.c_str()); + return callBridgeOrStop("syntaxBridgeGenerateAnalysisWrapper", [&]() { + return syntaxBridgeGenerateAnalysisWrapper(modulePathStr.c_str(), analysisNameStr.c_str()); + }); } // [[Rcpp::export]] diff --git a/tests/testthat.R b/tests/testthat.R index a744b3c..a6f36ce 100644 --- a/tests/testthat.R +++ b/tests/testthat.R @@ -1,4 +1,4 @@ -library(jaspTools) library(testthat) +library(jaspSyntax) -jaspTools::runTestsTravis(module = getwd()) +test_check("jaspSyntax") diff --git a/tests/testthat/fixtures/descriptivesReplayModule/DESCRIPTION b/tests/testthat/fixtures/descriptivesReplayModule/DESCRIPTION new file mode 100644 index 0000000..ba20ca0 --- /dev/null +++ b/tests/testthat/fixtures/descriptivesReplayModule/DESCRIPTION @@ -0,0 +1,8 @@ +Package: jaspDescriptives +Type: Package +Title: Descriptives Replay Fixture +Version: 0.95.5 +Author: JASP Team +Maintainer: JASP Team +Description: Minimal Descriptives module fixture for saved .jasp replay. +License: GPL (>= 2) diff --git a/tests/testthat/fixtures/descriptivesReplayModule/NAMESPACE b/tests/testthat/fixtures/descriptivesReplayModule/NAMESPACE new file mode 100644 index 0000000..9c9f9ac --- /dev/null +++ b/tests/testthat/fixtures/descriptivesReplayModule/NAMESPACE @@ -0,0 +1 @@ +exportPattern("^[^\\.]") diff --git a/tests/testthat/fixtures/descriptivesReplayModule/inst/Description.qml b/tests/testthat/fixtures/descriptivesReplayModule/inst/Description.qml new file mode 100644 index 0000000..b757784 --- /dev/null +++ b/tests/testthat/fixtures/descriptivesReplayModule/inst/Description.qml @@ -0,0 +1,18 @@ +import QtQuick +import JASP.Module + +Description +{ + title: qsTr("Descriptives") + description: qsTr("Minimal fixture for saved Descriptives replay.") + preloadData: true + hasWrappers: true + + Analysis + { + title: qsTr("Descriptive Statistics") + func: "Descriptives" + qml: "Descriptives.qml" + preloadData: true + } +} diff --git a/tests/testthat/fixtures/descriptivesReplayModule/inst/qml/Descriptives.qml b/tests/testthat/fixtures/descriptivesReplayModule/inst/qml/Descriptives.qml new file mode 100644 index 0000000..5d700a4 --- /dev/null +++ b/tests/testthat/fixtures/descriptivesReplayModule/inst/qml/Descriptives.qml @@ -0,0 +1,13 @@ +import QtQuick +import JASP +import JASP.Controls + +Form +{ + CheckBox + { + name: "boxPlot" + label: qsTr("Boxplot") + checked: false + } +} diff --git a/tests/testthat/fixtures/jasp-files/descriptives-sleep.jasp b/tests/testthat/fixtures/jasp-files/descriptives-sleep.jasp new file mode 100644 index 0000000000000000000000000000000000000000..966c50b4a49dd7e0b1d910353966a483bb6414c7 GIT binary patch literal 137301 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 =)t^PqH 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 literal 0 HcmV?d00001 diff --git a/tests/testthat/fixtures/minimalModule/DESCRIPTION b/tests/testthat/fixtures/minimalModule/DESCRIPTION new file mode 100644 index 0000000..571400a --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/DESCRIPTION @@ -0,0 +1,9 @@ +Package: jaspSyntaxTestModule +Type: Package +Title: Syntax Test Module +Version: 0.1.0 +Author: JASP Team +Maintainer: JASP Team +Description: Minimal module fixture for jaspSyntax API tests. +License: GPL (>= 2) +Encoding: UTF-8 diff --git a/tests/testthat/fixtures/minimalModule/NAMESPACE b/tests/testthat/fixtures/minimalModule/NAMESPACE new file mode 100644 index 0000000..07b25ec --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/NAMESPACE @@ -0,0 +1 @@ +export(MinimalAnalysis) diff --git a/tests/testthat/fixtures/minimalModule/inst/Description.qml b/tests/testthat/fixtures/minimalModule/inst/Description.qml new file mode 100644 index 0000000..63daa83 --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/inst/Description.qml @@ -0,0 +1,32 @@ +import QtQuick +import JASP.Module + +Description +{ + title: qsTr("Syntax Test Module") + description: qsTr("Minimal module fixture for jaspSyntax API tests.") + preloadData: true + hasWrappers: true + + Analysis + { + title: qsTr("Default Analysis") + func: "DefaultAnalysis" + } + + Analysis + { + title: qsTr("Minimal Analysis") + func: "MinimalAnalysis" + qml: "MinimalAnalysis.qml" + preloadData: false + } + + Analysis + { + title: qsTr("Variable Analysis") + func: "VariableAnalysis" + qml: "VariableAnalysis.qml" + preloadData: true + } +} diff --git a/tests/testthat/fixtures/minimalModule/inst/icons/.gitkeep b/tests/testthat/fixtures/minimalModule/inst/icons/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/inst/icons/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/testthat/fixtures/minimalModule/inst/qml/DefaultAnalysis.qml b/tests/testthat/fixtures/minimalModule/inst/qml/DefaultAnalysis.qml new file mode 100644 index 0000000..cad53ff --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/inst/qml/DefaultAnalysis.qml @@ -0,0 +1,13 @@ +import QtQuick +import JASP +import JASP.Controls + +Form +{ + CheckBox + { + name: "defaultFlag" + label: qsTr("Default flag") + checked: true + } +} diff --git a/tests/testthat/fixtures/minimalModule/inst/qml/MinimalAnalysis.qml b/tests/testthat/fixtures/minimalModule/inst/qml/MinimalAnalysis.qml new file mode 100644 index 0000000..b0d5cfc --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/inst/qml/MinimalAnalysis.qml @@ -0,0 +1,39 @@ +import QtQuick +import JASP +import JASP.Controls + +Form +{ + CheckBox + { + name: "flag" + label: qsTr("Flag") + checked: true + } + + DoubleField + { + name: "threshold" + label: qsTr("Threshold") + defaultValue: 1.5 + } + + RadioButtonGroup + { + name: "choice" + title: qsTr("Choice") + + RadioButton + { + value: "one" + label: qsTr("One") + } + + RadioButton + { + value: "two" + label: qsTr("Two") + checked: true + } + } +} diff --git a/tests/testthat/fixtures/minimalModule/inst/qml/VariableAnalysis.qml b/tests/testthat/fixtures/minimalModule/inst/qml/VariableAnalysis.qml new file mode 100644 index 0000000..ef6c21f --- /dev/null +++ b/tests/testthat/fixtures/minimalModule/inst/qml/VariableAnalysis.qml @@ -0,0 +1,20 @@ +import QtQuick +import JASP +import JASP.Controls + +Form +{ + VariablesForm + { + AvailableVariablesList + { + name: "allVariablesList" + } + + AssignedVariablesList + { + name: "variables" + title: qsTr("Variables") + } + } +} diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R new file mode 100644 index 0000000..1304bfe --- /dev/null +++ b/tests/testthat/test-dataset-helpers.R @@ -0,0 +1,670 @@ +context("dataset bridge helpers") + +localGlobalBinding <- function(name, value) { + hadValue <- exists(name, envir = .GlobalEnv, inherits = FALSE) + oldValue <- if (hadValue) get(name, envir = .GlobalEnv, inherits = FALSE) else NULL + + assign(name, value, envir = .GlobalEnv) + + function() { + if (hadValue) { + assign(name, oldValue, envir = .GlobalEnv) + } else if (exists(name, envir = .GlobalEnv, inherits = FALSE)) { + rm(list = name, envir = .GlobalEnv) + } + } +} + +localGlobalAbsent <- function(name) { + hadValue <- exists(name, envir = .GlobalEnv, inherits = FALSE) + oldValue <- if (hadValue) get(name, envir = .GlobalEnv, inherits = FALSE) else NULL + + if (hadValue) { + rm(list = name, envir = .GlobalEnv) + } + + function() { + if (hadValue) { + assign(name, oldValue, envir = .GlobalEnv) + } + } +} + +localNamespaceBinding <- function(name, value, namespace) { + oldValue <- get(name, envir = namespace, inherits = FALSE) + wasLocked <- bindingIsLocked(name, namespace) + + if (wasLocked) { + unlockBinding(name, namespace) + } + assign(name, value, envir = namespace) + if (wasLocked) { + lockBinding(name, namespace) + } + + function() { + if (bindingIsLocked(name, namespace)) { + unlockBinding(name, namespace) + } + assign(name, oldValue, envir = namespace) + if (wasLocked) { + lockBinding(name, namespace) + } + } +} + +test_that("decodeColumnNames delegates to the native bridge decoder", { + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c( + JaspColumn_1_Encoded = "raw score", + JaspColumn_2_Encoded = "group" + )[[columnName]] + } + ) + on.exit(restoreDecoder(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnNames(c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")), + c("raw score", "group") + ) + expect_equal( + jaspSyntax::columnMapping(c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")), + c(JaspColumn_1_Encoded = "raw score", JaspColumn_2_Encoded = "group") + ) +}) + +test_that("decodeColumnNames can fall back or fail when the decoder is unavailable", { + restoreDecoder <- localGlobalAbsent(".decodeColNamesStrict") + on.exit(restoreDecoder(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnNames(c("plain", "JaspColumn_1_Encoded")), + c("plain", "JaspColumn_1_Encoded") + ) + expect_error( + jaspSyntax::decodeColumnNames("JaspColumn_1_Encoded", strict = TRUE), + "did not expose `.decodeColNamesStrict`", + fixed = TRUE + ) + expect_error( + jaspSyntax::columnMapping("JaspColumn_1_Encoded", strict = TRUE), + "did not expose `.decodeColNamesStrict`", + fixed = TRUE + ) +}) + +test_that("state readers fail loudly when decode is requested without decoder support", { + restoreDecoder <- localGlobalAbsent(".decodeColNamesStrict") + restoreLoaded <- localGlobalBinding( + ".readFullDatasetToEnd", + function() { + data.frame(JaspColumn_1_Encoded = c(1, 2), check.names = FALSE) + } + ) + restoreRequested <- localGlobalBinding( + ".readDataSetRequestedNative", + function() { + data.frame(JaspColumn_1_Encoded = c(1, 2), check.names = FALSE) + } + ) + restoreNames <- localNamespaceBinding( + "getVariableNames", + function() { + list("JaspColumn_1_Encoded") + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreLoaded(), add = TRUE) + on.exit(restoreRequested(), add = TRUE) + on.exit(restoreNames(), add = TRUE) + + expect_error( + jaspSyntax::readLoadedDataset(decode = TRUE), + "did not expose `.decodeColNamesStrict`", + fixed = TRUE + ) + expect_error( + jaspSyntax::readRequestedDataset(decode = TRUE), + "did not expose `.decodeColNamesStrict`", + fixed = TRUE + ) + expect_error( + jaspSyntax::readDatasetHeader(decode = TRUE), + "did not expose `.decodeColNamesStrict`", + fixed = TRUE + ) + + expect_equal(names(jaspSyntax::readLoadedDataset(decode = FALSE)), "JaspColumn_1_Encoded") + expect_equal( + jaspSyntax::readDatasetHeader(decode = FALSE)$name, + "JaspColumn_1_Encoded" + ) +}) + +test_that("readLoadedDataset reads, decodes, and normalizes bridge data", { + restoreDataset <- localGlobalBinding( + ".readFullDatasetToEnd", + function() { + data.frame( + JaspColumn_1_Encoded = factor(c("1", "2")), + JaspColumn_2_Encoded = factor(c("control", "treatment")), + check.names = FALSE + ) + } + ) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c( + JaspColumn_1_Encoded = "id", + JaspColumn_2_Encoded = "condition" + )[[columnName]] + } + ) + on.exit(restoreDataset(), add = TRUE) + on.exit(restoreDecoder(), add = TRUE) + + dataset <- jaspSyntax::readLoadedDataset() + + expect_equal(names(dataset), c("id", "condition")) + expect_identical(dataset$id, c("1", "2")) + expect_identical(dataset$condition, c("control", "treatment")) + + rawDataset <- jaspSyntax::readLoadedDataset(decode = FALSE, normalize = FALSE) + expect_equal(names(rawDataset), c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")) + expect_s3_class(rawDataset$JaspColumn_1_Encoded, "factor") +}) + +test_that("factor normalization preserves numeric-looking category labels", { + restoreDataset <- localGlobalBinding( + ".readFullDatasetToEnd", + function() { + data.frame( + response = factor(c("1", "2", "1")), + check.names = FALSE + ) + } + ) + on.exit(restoreDataset(), add = TRUE) + + dataset <- jaspSyntax::readLoadedDataset(decode = FALSE) + + expect_identical(dataset$response, c("1", "2", "1")) +}) + +test_that("decodeAnalysisResults decodes native column names and factor value tokens", { + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c(JaspColumn_1_Encoded = "group")[[columnName]] + } + ) + on.exit(restoreDecoder(), add = TRUE) + + requestedDataset <- data.frame( + JaspColumn_1_Encoded = factor(c("control", "treatment")), + check.names = FALSE + ) + results <- list( + results = list( + table = list( + data = list( + list( + JaspColumn_1_Encoded = "1", + label = "JaspColumn_1_Encoded" + ) + ) + ) + ) + ) + + decoded <- jaspSyntax::decodeAnalysisResults(results, requestedDataset = requestedDataset) + firstRow <- decoded$results$table$data[[1L]] + + expect_equal(names(firstRow), c("group", "label")) + expect_equal(firstRow$group, "control") + expect_equal(firstRow$label, "group") +}) + +test_that("decodeAnalysisResults can use captured column mapping without native decoder", { + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + stop("native decoder should not be called") + } + ) + on.exit(restoreDecoder(), add = TRUE) + + requestedDataset <- data.frame( + JaspColumn_1_Encoded = factor(c("control", "treatment")), + check.names = FALSE + ) + results <- list( + results = list( + table = list( + data = list( + list( + JaspColumn_1_Encoded = "2", + Variable = "JaspColumn_1_Encoded" + ) + ) + ) + ) + ) + + decoded <- jaspSyntax::decodeAnalysisResults( + results, + requestedDataset = requestedDataset, + columnMapping = c(JaspColumn_1_Encoded = "group") + ) + firstRow <- decoded$results$table$data[[1L]] + + expect_equal(names(firstRow), c("group", "Variable")) + expect_equal(firstRow$group, "treatment") + expect_equal(firstRow$Variable, "group") +}) + +test_that("decodeAnalysisResults maps factor values with decoded requested datasets", { + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + stop("native decoder should not be called") + } + ) + on.exit(restoreDecoder(), add = TRUE) + + requestedDataset <- data.frame( + group = factor(c("control", "treatment")), + check.names = FALSE + ) + results <- list( + results = list( + table = list( + data = list( + list(JaspColumn_1_Encoded = "1") + ) + ) + ) + ) + + decoded <- jaspSyntax::decodeAnalysisResults( + results, + requestedDataset = requestedDataset, + columnMapping = c(JaspColumn_1_Encoded = "group") + ) + firstRow <- decoded$results$table$data[[1L]] + + expect_equal(names(firstRow), "group") + expect_equal(firstRow$group, "control") +}) + +test_that("readRequestedDataset exposes requested native dataset state", { + restoreDataset <- localGlobalBinding( + ".readDataSetRequestedNative", + function() { + data.frame( + JaspColumn_1_Encoded = c(1.5, 2.5), + check.names = FALSE + ) + } + ) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c(JaspColumn_1_Encoded = "requested")[[columnName]] + } + ) + on.exit(restoreDataset(), add = TRUE) + on.exit(restoreDecoder(), add = TRUE) + + dataset <- jaspSyntax::readRequestedDataset() + + expect_equal(names(dataset), "requested") + expect_equal(dataset$requested, c(1.5, 2.5)) +}) + +test_that("readDatasetHeader decodes native header names", { + restoreNames <- localNamespaceBinding( + "getVariableNames", + function() { + list("JaspColumn_1_Encoded", "JaspColumn_2_Encoded") + }, + asNamespace("jaspSyntax") + ) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c( + JaspColumn_1_Encoded = "score", + JaspColumn_2_Encoded = "group" + )[[columnName]] + } + ) + on.exit(restoreNames(), add = TRUE) + on.exit(restoreDecoder(), add = TRUE) + + header <- jaspSyntax::readDatasetHeader() + + expect_equal(header$name, c("score", "group")) + expect_equal(header$encodedName, c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")) +}) + +test_that("loadAnalysisDataset returns loaded and requested state from native helpers", { + modulePath <- tempfile("jaspSyntaxDatasetModule_") + dir.create(modulePath) + + loadedData <- NULL + replayArgs <- NULL + + restoreClear <- localNamespaceBinding( + "clearDatasetState", + function() invisible(NULL), + asNamespace("jaspSyntax") + ) + restoreLoad <- localNamespaceBinding( + "loadDataSet", + function(data) { + loadedData <<- data + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreReadQml <- localNamespaceBinding( + "readAnalysisOptionsFromQml", + function(modulePath, analysisName, options, fresh, + includeMeta, includeTypeOptions) { + replayArgs <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + list(variables = "JaspColumn_1_Encoded", `variables.types` = "scale") + }, + asNamespace("jaspSyntax") + ) + restoreLoaded <- localGlobalBinding( + ".readFullDatasetToEnd", + function() { + data.frame( + JaspColumn_1_Encoded = c(1, 2), + JaspColumn_2_Encoded = c("a", "b"), + check.names = FALSE + ) + } + ) + restoreRequested <- localGlobalBinding( + ".readDataSetRequestedNative", + function() { + data.frame( + JaspColumn_1_Encoded = factor(c("control", "treatment")), + check.names = FALSE + ) + } + ) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + c( + JaspColumn_1_Encoded = "score", + JaspColumn_2_Encoded = "group" + )[[columnName]] + } + ) + on.exit(restoreClear(), add = TRUE) + on.exit(restoreLoad(), add = TRUE) + on.exit(restoreReadQml(), add = TRUE) + on.exit(restoreLoaded(), add = TRUE) + on.exit(restoreRequested(), add = TRUE) + on.exit(restoreDecoder(), add = TRUE) + + rawDataset <- data.frame(score = c(1, 2), group = c("a", "b")) + savedOptions <- list(variables = list(value = "score", types = "scale")) + + state <- jaspSyntax::loadAnalysisDataset( + rawDataset, + modulePath = modulePath, + analysisName = "ExampleAnalysis", + options = savedOptions, + includeMeta = FALSE + ) + + expect_equal(loadedData, rawDataset) + expect_equal(replayArgs$modulePath, normalizePath(modulePath, winslash = "/", mustWork = FALSE)) + expect_equal(replayArgs$analysisName, "ExampleAnalysis") + expect_equal(replayArgs$options, savedOptions) + expect_true(replayArgs$fresh) + expect_false(replayArgs$includeMeta) + expect_true(replayArgs$includeTypeOptions) + expect_equal(names(state$loadedDataset), c("score", "group")) + expect_equal(names(state$requestedDataset), "score") + expect_equal(state$requestedDataset$score, c("control", "treatment")) + expect_s3_class(state$resultDecodingDataset$score, "factor") + expect_equal(levels(state$resultDecodingDataset$score), c("control", "treatment")) + expect_equal(state$runtimeOptions$variables, "JaspColumn_1_Encoded") + expect_equal(state$columnMapping, c(JaspColumn_1_Encoded = "score", JaspColumn_2_Encoded = "group")) + expect_s3_class(state, "jaspSyntax_analysis_dataset_state") +}) + +test_that("loadAnalysisDataset clears native state when loading fails", { + modulePath <- tempfile("jaspSyntaxDatasetModule_") + dir.create(modulePath) + + clearNativeCalls <- 0L + restoreClearDataset <- localNamespaceBinding( + "clearDatasetState", + function() invisible(NULL), + asNamespace("jaspSyntax") + ) + restoreClearNative <- localNamespaceBinding( + "clearNativeState", + function() { + clearNativeCalls <<- clearNativeCalls + 1L + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreLoad <- localNamespaceBinding( + "loadDataSet", + function(data) invisible(NULL), + asNamespace("jaspSyntax") + ) + restoreReadQml <- localNamespaceBinding( + "readAnalysisOptionsFromQml", + function(...) stop("qml failed", call. = FALSE), + asNamespace("jaspSyntax") + ) + on.exit(restoreClearDataset(), add = TRUE) + on.exit(restoreClearNative(), add = TRUE) + on.exit(restoreLoad(), add = TRUE) + on.exit(restoreReadQml(), add = TRUE) + + expect_error( + jaspSyntax::loadAnalysisDataset( + data.frame(x = 1), + modulePath = modulePath, + analysisName = "ExampleAnalysis" + ), + "qml failed", + fixed = TRUE + ) + expect_equal(clearNativeCalls, 1L) +}) + +test_that("loadAnalysisDataset validates raw dataset input", { + expect_error( + jaspSyntax::loadAnalysisDataset( + list(x = 1), + modulePath = tempdir(), + analysisName = "ExampleAnalysis" + ), + "`dataset` must be a data frame", + fixed = TRUE + ) +}) + +test_that("lifecycle helpers expose explicit split native controls", { + expect_null(names(formals(jaspSyntax::clearQmlForms))) + expect_null(names(formals(jaspSyntax::clearDatasetState))) + expect_null(names(formals(jaspSyntax::clearNativeState))) +}) + +test_that("nativeBridgeProvenance parses recorded bridge metadata", { + provenanceFile <- tempfile("SyntaxInterface", fileext = ".provenance") + writeLines( + c( + "# SyntaxInterface provenance", + "schema=1", + "header_origin=C:/jasp/SyntaxInterface/syntaxbridge_interface.h", + "binary_origin=https://example.invalid/SyntaxInterface.dll", + "value_with_equals=left=right" + ), + provenanceFile + ) + + provenance <- jaspSyntax:::.readNativeBridgeProvenance(provenanceFile) + + expect_equal(provenance[["schema"]], "1") + expect_equal( + provenance[["header_origin"]], + "C:/jasp/SyntaxInterface/syntaxbridge_interface.h" + ) + expect_equal(provenance[["value_with_equals"]], "left=right") + expect_equal( + attr(provenance, "path"), + normalizePath(provenanceFile, winslash = "/", mustWork = FALSE) + ) +}) + +test_that("subprocess package loading distinguishes source checkouts from installed libraries", { + sourceDir <- tempfile("jaspSyntax_source_") + dir.create(file.path(sourceDir, "R"), recursive = TRUE) + dir.create(file.path(sourceDir, "src"), recursive = TRUE) + file.create(file.path(sourceDir, "DESCRIPTION")) + file.create(file.path(sourceDir, "src", "syntaxfunctions.cpp")) + + installedDir <- tempfile("jaspSyntax_installed_") + dir.create(file.path(installedDir, "R"), recursive = TRUE) + file.create(file.path(installedDir, "DESCRIPTION")) + + expect_true(jaspSyntax:::.isSourceCheckoutPath(sourceDir)) + expect_false(jaspSyntax:::.isSourceCheckoutPath(installedDir)) + expect_match( + paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), + "pkgload::load_all", + fixed = TRUE + ) + + descriptionCandidates <- c( + file.path(getwd(), "DESCRIPTION"), + file.path(dirname(getwd()), "DESCRIPTION"), + file.path(dirname(dirname(getwd())), "DESCRIPTION"), + system.file("DESCRIPTION", package = "jaspSyntax") + ) + descriptionPath <- descriptionCandidates[file.exists(descriptionCandidates)][1L] + description <- read.dcf(descriptionPath) + expect_match(description[1L, "Suggests"], "pkgload", fixed = TRUE) +}) + +test_that("SyntaxInterface symbol checker fails when DLL exports cannot be inspected", { + scriptCandidates <- c( + file.path(getwd(), "tools", "check-syntaxinterface-symbols.sh"), + file.path(dirname(getwd()), "tools", "check-syntaxinterface-symbols.sh"), + file.path(dirname(dirname(getwd())), "tools", "check-syntaxinterface-symbols.sh") + ) + script <- scriptCandidates[file.exists(scriptCandidates)][1L] + testthat::skip_if(is.na(script), "symbol checker script is not available") + + bash <- Sys.which("bash") + testthat::skip_if(!nzchar(bash), "bash is not available") + bashPwd <- suppressWarnings(system2(bash, args = c("-lc", "pwd"), stdout = TRUE, stderr = FALSE)) + bashMountPrefix <- if (length(bashPwd) > 0L && grepl("^/mnt/[A-Za-z]/", bashPwd[[1L]])) { + "/mnt" + } else { + "" + } + bashPath <- function(path) { + path <- normalizePath(path, winslash = "/", mustWork = FALSE) + if (grepl("^[A-Za-z]:/", path)) { + return(paste0(bashMountPrefix, "/", tolower(substr(path, 1L, 1L)), substr(path, 3L, nchar(path)))) + } + path + } + + tempDir <- tempfile("jaspSyntax_symbol_check_") + dir.create(tempDir) + on.exit(unlink(tempDir, recursive = TRUE), add = TRUE) + + header <- file.path(tempDir, "syntaxbridge_interface.h") + source <- file.path(tempDir, "syntaxfunctions.cpp") + binary <- file.path(tempDir, "SyntaxInterface.dll") + writeLines("void syntaxBridgeKnown();", header) + writeLines("void useBridge() { syntaxBridgeKnown(); }", source) + writeLines("not a native library", binary) + + oldCheckExports <- Sys.getenv("JASPSYNTAX_CHECK_EXPORTS", unset = NA_character_) + Sys.setenv(JASPSYNTAX_CHECK_EXPORTS = "true") + on.exit({ + if (is.na(oldCheckExports)) { + Sys.unsetenv("JASPSYNTAX_CHECK_EXPORTS") + } else { + Sys.setenv(JASPSYNTAX_CHECK_EXPORTS = oldCheckExports) + } + }, add = TRUE) + + output <- suppressWarnings(system2( + bash, + args = c(bashPath(script), bashPath(header), bashPath(binary), bashPath(source)), + stdout = TRUE, + stderr = TRUE + )) + + status <- attr(output, "status") + if (is.null(status)) { + status <- 0L + } + + expect_false(identical(status, 0L)) + expect_match( + paste(output, collapse = "\n"), + "ERROR: Cannot verify SyntaxInterface binary exports", + fixed = TRUE + ) +}) + +test_that("readDatasetFromJaspFile dispatches through the shared bridge subprocess runner", { + jaspFile <- tempfile(fileext = ".jasp") + file.create(jaspFile) + + runnerCall <- NULL + restoreRunner <- localNamespaceBinding( + ".runBridgeSubprocess", + function(task, target, input, failureLabel) { + runnerCall <<- list( + task = task, + target = target, + input = input, + failureLabel = failureLabel + ) + data.frame(x = 1) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreRunner(), add = TRUE) + + dataset <- jaspSyntax::readDatasetFromJaspFile(jaspFile) + + expect_equal(dataset, data.frame(x = 1)) + expect_equal(runnerCall$task, "read_dataset") + expect_equal(runnerCall$target, ".readDatasetFromJaspFileInProcess") + expect_equal(runnerCall$input$jaspFilePath, jaspFile) + expect_equal(runnerCall$input$dataSetIndex, 1L) + expect_true(runnerCall$input$decode) + expect_true(runnerCall$input$normalize) + expect_equal(runnerCall$failureLabel, "readDatasetFromJaspFile") +}) diff --git a/tests/testthat/test-desktop-jasp-contract.R b/tests/testthat/test-desktop-jasp-contract.R new file mode 100644 index 0000000..b0aa2f9 --- /dev/null +++ b/tests/testthat/test-desktop-jasp-contract.R @@ -0,0 +1,140 @@ +context("Desktop JASP file contract") + +desktopJaspFixture <- function() { + testthat::test_path("fixtures", "jasp-files", "descriptives-sleep.jasp") +} + +readJaspJsonEntry <- function(jaspFile, entry) { + tempDir <- tempfile("jaspSyntax_contract_") + dir.create(tempDir) + on.exit(unlink(tempDir, recursive = TRUE), add = TRUE) + + utils::unzip(jaspFile, files = entry, exdir = tempDir) + jsonlite::fromJSON(file.path(tempDir, entry), simplifyVector = FALSE) +} + +optionValues <- function(x) { + unlist(x, use.names = FALSE) +} + +descriptivesModulePath <- function() { + envPath <- Sys.getenv("JASP_DESCRIPTIVES_MODULE", unset = "") + if (nzchar(envPath)) { + return(envPath) + } + + NA_character_ +} + +test_that("Desktop .jasp fixture contains saved Descriptives state", { + jaspFile <- desktopJaspFixture() + testthat::skip_if_not(file.exists(jaspFile), "Desktop .jasp fixture missing") + + entries <- utils::unzip(jaspFile, list = TRUE)$Name + expect_true(all(c("manifest.json", "analyses.json", "internal.sqlite") %in% entries)) + + manifest <- readJaspJsonEntry(jaspFile, "manifest.json") + expect_equal(manifest$jaspArchiveVersion, "5") + expect_equal(manifest$jaspVersion, "0.96") + + analysis <- readJaspJsonEntry(jaspFile, "analyses.json")$analyses[[1]] + expect_equal(analysis$name, "Descriptives") + expect_equal(analysis$title, "Descriptive Statistics") + expect_equal(analysis$dynamicModule$moduleName, "jaspDescriptives") + expect_equal(analysis$dynamicModule$moduleVersion, "0.95.5") + expect_equal(optionValues(analysis$options$variables$value), "extra") + expect_equal(optionValues(analysis$options$variables$types), "scale") + expect_equal(optionValues(analysis$options$splitBy$value), "group") + expect_equal(optionValues(analysis$options$splitBy$types), "nominal") + expect_true(analysis$options$boxPlot) +}) + +test_that("readDatasetFromJaspFile reads a real Desktop .jasp dataset", { + jaspFile <- desktopJaspFixture() + testthat::skip_if_not(file.exists(jaspFile), "Desktop .jasp fixture missing") + + dataset <- jaspSyntax::readDatasetFromJaspFile(jaspFile) + + expect_s3_class(dataset, "data.frame") + expect_equal(names(dataset), c("extra", "group", "ID")) + expect_equal(dim(dataset), c(20L, 3L)) + expect_equal(dataset$extra[1:5], c(0.7, -1.6, -0.2, -1.2, -0.1)) + expect_equal(as.character(dataset$group[1]), "1") + expect_equal(as.integer(dataset$ID[1:5]), 1:5) +}) + +test_that("readAnalysisOptionsFromJaspFile returns saved bound Desktop options", { + jaspFile <- desktopJaspFixture() + testthat::skip_if_not(file.exists(jaspFile), "Desktop .jasp fixture missing") + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile(jaspFile, runtime = FALSE) + + expect_equal(names(records), "Descriptives") + expect_equal(records$Descriptives$name, "Descriptives") + expect_equal(records$Descriptives$title, "Descriptive Statistics") + expect_equal(records$Descriptives$moduleName, "jaspDescriptives") + expect_equal(records$Descriptives$moduleVersion, "0.95.5") + expect_equal(optionValues(records$Descriptives$options$variables$value), "extra") + expect_equal(optionValues(records$Descriptives$options$variables$types), "scale") + expect_equal(optionValues(records$Descriptives$options$splitBy$value), "group") + expect_equal(optionValues(records$Descriptives$options$splitBy$types), "nominal") + expect_false("variables.types" %in% names(records$Descriptives$options)) +}) + +test_that("readAnalysisOptionsFromJaspFile replays real Desktop options to runtime shape", { + jaspFile <- desktopJaspFixture() + testthat::skip_if_not(file.exists(jaspFile), "Desktop .jasp fixture missing") + + modulePath <- descriptivesModulePath() + testthat::skip_if(is.na(modulePath), "Set JASP_DESCRIPTIVES_MODULE for runtime replay") + testthat::skip_if_not(dir.exists(modulePath), "jaspDescriptives module path missing") + modulePath <- c(jaspDescriptives = normalizePath(modulePath, winslash = "/", mustWork = TRUE)) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = modulePath, + runtime = TRUE, + includeMeta = FALSE, + isolated = TRUE + ) + opts <- records$Descriptives$options + + expect_match(unlist(opts$variables, use.names = FALSE)[[1L]], "^JaspColumn_.*_Encoded$") + expect_equal(optionValues(opts$`variables.types`), "scale") + expect_match(unlist(opts$splitBy, use.names = FALSE)[[1L]], "^JaspColumn_.*_Encoded$") + expect_equal(optionValues(opts$`splitBy.types`), "nominal") + expect_true(opts$boxPlot) + expect_false(".meta" %in% names(opts)) +}) + +test_that("native and R bridge exports keep the expected consumer formals", { + expect_named(formals(jaspSyntax::loadDataSetFromJaspFile), "jaspFilePath") + expect_named(formals(jaspSyntax::analysisOptionsFromJaspFile), c("jaspFilePath", "analysisNr")) + expect_named( + formals(jaspSyntax::loadQmlAndParseOptions), + c("moduleName", "analysisName", "qmlFile", "options", "version", "preloadData") + ) + expect_named(formals(jaspSyntax::readDatasetFromJaspFile), c("jaspFilePath", "dataSetIndex")) + expect_identical(formals(jaspSyntax::readDatasetFromJaspFile)$dataSetIndex, 1L) + expect_named( + formals(jaspSyntax::loadAnalysisDataset), + c( + "dataset", "modulePath", "analysisName", "options", "includeMeta", + "includeTypeOptions", "decode", "normalize" + ) + ) + expect_named(formals(jaspSyntax::readLoadedDataset), c("decode", "normalize")) + expect_named(formals(jaspSyntax::readRequestedDataset), c("decode", "normalize")) + expect_named(formals(jaspSyntax::readDatasetHeader), "decode") + expect_named(formals(jaspSyntax::decodeColumnNames), c("columnNames", "strict")) + expect_named(formals(jaspSyntax::decodeAnalysisResults), c("results", "requestedDataset", "columnMapping")) + expect_named(formals(jaspSyntax::columnMapping), c("encodedColumnNames", "strict")) + expect_named( + formals(jaspSyntax::readAnalysisOptionsFromJaspFile), + c("jaspFilePath", "modulePath", "runtime", "includeMeta", "includeTypeOptions", "isolated") + ) + expect_false(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$runtime) + expect_true(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$includeMeta) + expect_true(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$includeTypeOptions) + expect_true(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$isolated) +}) diff --git a/tests/testthat/test-jasp-file-options.R b/tests/testthat/test-jasp-file-options.R new file mode 100644 index 0000000..b7ca8e0 --- /dev/null +++ b/tests/testthat/test-jasp-file-options.R @@ -0,0 +1,630 @@ +context("JASP file options") + +writeTestJaspFile <- function(path, analyses) { + tempDir <- tempfile("jaspSyntax_test_jasp_") + dir.create(tempDir) + on.exit(unlink(tempDir, recursive = TRUE), add = TRUE) + + jsonlite::write_json( + list(analyses = analyses), + file.path(tempDir, "analyses.json"), + auto_unbox = TRUE, + pretty = TRUE + ) + + oldWd <- getwd() + setwd(tempDir) + on.exit(setwd(oldWd), add = TRUE) + utils::zip(path, "analyses.json") + invisible(path) +} + +localNamespaceBinding <- function(name, value, namespace) { + oldValue <- get(name, envir = namespace, inherits = FALSE) + wasLocked <- bindingIsLocked(name, namespace) + + if (wasLocked) { + unlockBinding(name, namespace) + } + assign(name, value, envir = namespace) + if (wasLocked) { + lockBinding(name, namespace) + } + + function() { + if (bindingIsLocked(name, namespace)) { + unlockBinding(name, namespace) + } + assign(name, oldValue, envir = namespace) + if (wasLocked) { + lockBinding(name, namespace) + } + } +} + +test_that("readAnalysisOptionsFromJaspFile returns records with saved bound options", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "FirstAnalysis", + title = "First Analysis", + dynamicModule = list(moduleName = "jaspFirst", moduleVersion = "1.0.0") + ), + list( + name = "SecondAnalysis", + title = "Second Analysis", + moduleName = "jaspSecond", + version = "2.0.0" + ) + ) + writeTestJaspFile(jaspFile, analyses) + + restoreBinding <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + options <- list( + index = analysisNr, + option = paste0("option", analysisNr), + variables = list(types = c("scale", "nominal"), value = c("x", "y")), + `.meta` = list(variables = list(shouldEncode = TRUE)) + ) + options["emptyOption"] <- list(NULL) + options + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreBinding(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile(jaspFile, isolated = FALSE) + + expect_length(records, 2) + expect_equal(names(records), c("FirstAnalysis", "SecondAnalysis")) + expect_equal(records[[1]]$name, "FirstAnalysis") + expect_equal(records[[1]]$moduleName, "jaspFirst") + expect_equal(records[[1]]$options$index, 0) + expect_equal(records[[2]]$options$index, 1) + expect_equal(records[[2]]$moduleName, "jaspSecond") + expect_equal(records[[2]]$moduleVersion, "2.0.0") + expect_true(".meta" %in% names(records[[1]]$options)) + expect_true("emptyOption" %in% names(records[[1]]$options)) + expect_null(records[[1]]$options$emptyOption) + expect_equal(attr(records[[2]]$options, "analysisName"), "SecondAnalysis") + expect_equal(attr(records[[2]]$options, "moduleVersion"), "2.0.0") +}) + +test_that("readAnalysisOptionsFromJaspFile can filter saved metadata", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "FilterAnalysis", + title = "Filter Analysis", + dynamicModule = list(moduleName = "jaspFilter", moduleVersion = "1.0.0") + ) + ) + writeTestJaspFile(jaspFile, analyses) + + restoreBinding <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + list( + variables = list(types = c("scale", "nominal"), value = c("x", "y")), + `.meta` = list(variables = list(shouldEncode = TRUE)) + ) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreBinding(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + includeMeta = FALSE, + includeTypeOptions = FALSE, + isolated = FALSE + ) + + expect_equal(records[[1]]$options$variables$value, c("x", "y")) + expect_false("types" %in% names(records[[1]]$options$variables)) + expect_false(".meta" %in% names(records[[1]]$options)) +}) + +test_that("includeTypeOptions false removes nested saved-bound type metadata", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "NestedTypeAnalysis", + title = "Nested Type Analysis", + dynamicModule = list(moduleName = "jaspNested", moduleVersion = "1.0.0") + ) + ) + writeTestJaspFile(jaspFile, analyses) + + restoreBinding <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + list( + variables = list(types = c("scale", "nominal"), value = c("x", "y")), + nested = list( + splitBy = list(value = "group", types = "nominal") + ), + `variables.types` = "scale" + ) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreBinding(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + includeTypeOptions = FALSE, + isolated = FALSE + ) + + expect_equal(records[[1]]$options$variables, list(value = c("x", "y"))) + expect_equal(records[[1]]$options$nested$splitBy, list(value = "group")) + expect_false("variables.types" %in% names(records[[1]]$options)) +}) + +test_that("readAnalysisOptionsFromJaspFile can replay saved options through QML runtime path", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "RuntimeAnalysis", + title = "Runtime Analysis", + dynamicModule = list(moduleName = "jaspRuntime", moduleVersion = "1.2.3") + ) + ) + writeTestJaspFile(jaspFile, analyses) + modulePath <- tempfile("jaspRuntimeModule_") + dir.create(modulePath) + + loadedData <- FALSE + replayArgs <- NULL + + restoreAnalysisOptions <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + list( + variables = list(types = "scale", value = "x"), + `.meta` = list(variables = list(shouldEncode = TRUE)) + ) + }, + asNamespace("jaspSyntax") + ) + restoreLoadData <- localNamespaceBinding( + "loadDataSetFromJaspFile", + function(jaspFilePath) { + loadedData <<- TRUE + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreReadQml <- localNamespaceBinding( + "readAnalysisOptionsFromQml", + function(modulePath, analysisName, options, version, fresh, + includeMeta, includeTypeOptions) { + replayArgs <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + list(variables = "JaspColumn_1_Encoded", `variables.types` = "scale") + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreAnalysisOptions(), add = TRUE) + on.exit(restoreLoadData(), add = TRUE) + on.exit(restoreReadQml(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = modulePath, + runtime = TRUE, + includeMeta = FALSE, + isolated = FALSE + ) + + expect_true(loadedData) + expect_equal(replayArgs$modulePath, normalizePath(modulePath, winslash = "/", mustWork = FALSE)) + expect_equal(replayArgs$analysisName, "RuntimeAnalysis") + expect_equal(replayArgs$version, "1.2.3") + expect_true(replayArgs$fresh) + expect_false(replayArgs$includeMeta) + expect_true(replayArgs$includeTypeOptions) + expect_equal(records[[1]]$options$variables, "JaspColumn_1_Encoded") + expect_equal(records[[1]]$options$`variables.types`, "scale") +}) + +test_that("runtime replay resolves QML metadata through the module description", { + parseArgs <- NULL + restoreParseQml <- localNamespaceBinding( + "parseQmlOptions", + function(qmlFile, options, moduleName, analysisName, version, + preloadData, fresh, includeMeta, includeTypeOptions) { + parseArgs <<- list( + qmlFile = qmlFile, + options = options, + moduleName = moduleName, + analysisName = analysisName, + version = version, + preloadData = preloadData, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + list(runtime = TRUE) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreParseQml(), add = TRUE) + + record <- list( + name = "MinimalAnalysis", + moduleName = "jaspSyntaxTestModule", + moduleVersion = "9.9.9", + options = list(flag = TRUE) + ) + modulePath <- testthat::test_path("fixtures", "minimalModule") + + replayed <- jaspSyntax:::.runtimeOptionsForJaspRecord( + record, + modulePath = modulePath, + includeMeta = FALSE, + includeTypeOptions = TRUE + ) + + expect_equal(basename(parseArgs$qmlFile), "MinimalAnalysis.qml") + expect_equal(parseArgs$options, record$options) + expect_equal(parseArgs$analysisName, "MinimalAnalysis") + expect_equal(parseArgs$version, "9.9.9") + expect_false(parseArgs$preloadData) + expect_true(parseArgs$fresh) + expect_false(parseArgs$includeMeta) + expect_true(parseArgs$includeTypeOptions) + expect_true(replayed$options$runtime) +}) + +test_that("runtime module paths are only reused blindly when unnamed", { + modulePath <- tempfile("jaspRuntimeModule_") + dir.create(modulePath) + normalizedModulePath <- normalizePath(modulePath, winslash = "/", mustWork = FALSE) + + matchedRecord <- list(name = "RuntimeAnalysis", moduleName = "jaspRuntime") + expect_equal( + jaspSyntax:::.modulePathForRecord( + matchedRecord, + list(jaspRuntime = modulePath) + ), + normalizedModulePath + ) + + expect_equal( + jaspSyntax:::.modulePathForRecord( + matchedRecord, + list(RuntimeAnalysis = modulePath) + ), + normalizedModulePath + ) + + expect_equal( + jaspSyntax:::.modulePathForRecord( + matchedRecord, + modulePath + ), + normalizedModulePath + ) + + unmatchedRecord <- list( + name = "OtherAnalysis", + moduleName = "jaspSyntaxDefinitelyMissingModule" + ) + expect_error( + jaspSyntax:::.modulePathForRecord( + unmatchedRecord, + list(jaspRuntime = modulePath) + ), + "Installed-module fallback is only used when `modulePath = NULL`" + ) +}) + +test_that("runtime replay fails clearly when the resolved module lacks the analysis", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "MissingAnalysis", + title = "Missing Analysis", + dynamicModule = list(moduleName = "jaspSyntaxTestModule", moduleVersion = "0.1") + ) + ) + writeTestJaspFile(jaspFile, analyses) + modulePath <- testthat::test_path("fixtures", "minimalModule") + + restoreAnalysisOptions <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + list(flag = TRUE) + }, + asNamespace("jaspSyntax") + ) + restoreLoadData <- localNamespaceBinding( + "loadDataSetFromJaspFile", + function(jaspFilePath) { + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreAnalysisOptions(), add = TRUE) + on.exit(restoreLoadData(), add = TRUE) + + expect_error( + jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = list(jaspSyntaxTestModule = modulePath), + runtime = TRUE, + isolated = FALSE + ), + "Could not locate analysis" + ) +}) + +test_that("multi-analysis runtime replay loads data once and replays each record", { + jaspFile <- tempfile(fileext = ".jasp") + analyses <- list( + list( + name = "RuntimeOne", + title = "Runtime One", + dynamicModule = list(moduleName = "jaspRuntime", moduleVersion = "1.0.0") + ), + list( + name = "RuntimeTwo", + title = "Runtime Two", + dynamicModule = list(moduleName = "jaspRuntime", moduleVersion = "1.0.0") + ) + ) + writeTestJaspFile(jaspFile, analyses) + modulePath <- tempfile("jaspRuntimeModule_") + dir.create(modulePath) + + loadCalls <- 0L + replayCalls <- list() + + restoreAnalysisOptions <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(jaspFilePath, analysisNr) { + list(source = paste0("saved-", analysisNr)) + }, + asNamespace("jaspSyntax") + ) + restoreLoadData <- localNamespaceBinding( + "loadDataSetFromJaspFile", + function(jaspFilePath) { + loadCalls <<- loadCalls + 1L + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreReadQml <- localNamespaceBinding( + "readAnalysisOptionsFromQml", + function(modulePath, analysisName, options, version, fresh, + includeMeta, includeTypeOptions) { + replayCalls[[length(replayCalls) + 1L]] <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + list(replayed = analysisName, source = options$source) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreAnalysisOptions(), add = TRUE) + on.exit(restoreLoadData(), add = TRUE) + on.exit(restoreReadQml(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = list(jaspRuntime = modulePath), + runtime = TRUE, + includeMeta = FALSE, + isolated = FALSE + ) + + expect_equal(loadCalls, 1L) + expect_equal(names(records), c("RuntimeOne", "RuntimeTwo")) + expect_equal(vapply(replayCalls, `[[`, character(1L), "analysisName"), + c("RuntimeOne", "RuntimeTwo")) + expect_true(all(vapply(replayCalls, `[[`, logical(1L), "fresh"))) + expect_equal(records$RuntimeOne$options, list(replayed = "RuntimeOne", source = "saved-0")) + expect_equal(records$RuntimeTwo$options, list(replayed = "RuntimeTwo", source = "saved-1")) +}) + +test_that("readAnalysisOptionsFromJaspFile isolates native extraction by default", { + jaspFile <- tempfile(fileext = ".jasp") + writeTestJaspFile( + jaspFile, + list(list(name = "IsolatedAnalysis", title = "Isolated Analysis")) + ) + + runnerCall <- NULL + restoreBinding <- localNamespaceBinding( + ".runBridgeSubprocess", + function(task, target, input, failureLabel) { + runnerCall <<- list( + task = task, + target = target, + input = input, + failureLabel = failureLabel + ) + list(IsolatedAnalysis = list(name = "IsolatedAnalysis", options = list())) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreBinding(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = "C:/fake/module", + runtime = FALSE, + includeMeta = FALSE + ) + + expect_equal(names(records), "IsolatedAnalysis") + expect_equal(runnerCall$task, "read_options") + expect_equal(runnerCall$target, ".readAnalysisOptionsFromJaspFileInProcess") + expect_equal(runnerCall$input$jaspFilePath, normalizePath(jaspFile, winslash = "/", mustWork = FALSE)) + expect_equal(runnerCall$input$modulePath, "C:/fake/module") + expect_false(runnerCall$input$runtime) + expect_false(runnerCall$input$includeMeta) + expect_true(runnerCall$input$includeTypeOptions) + expect_equal(runnerCall$failureLabel, "readAnalysisOptionsFromJaspFile") +}) + +test_that("isolated runtime .jasp option reads replay from extracted dataset", { + jaspFile <- tempfile(fileext = ".jasp") + writeTestJaspFile( + jaspFile, + list(list( + name = "RuntimeAnalysis", + title = "Runtime Analysis", + dynamicModule = list(moduleName = "jaspRuntime", moduleVersion = "1.0.0") + )) + ) + + calls <- list() + restoreRunner <- localNamespaceBinding( + ".runReadAnalysisOptionsSubprocess", + function(jaspFilePath, modulePath, runtime, includeMeta, includeTypeOptions) { + calls$saved <<- list( + jaspFilePath = jaspFilePath, + modulePath = modulePath, + runtime = runtime, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions + ) + list(RuntimeAnalysis = list( + name = "RuntimeAnalysis", + moduleName = "jaspRuntime", + options = list(variable = list(value = "score", types = "scale")) + )) + }, + asNamespace("jaspSyntax") + ) + restoreDataset <- localNamespaceBinding( + ".runReadDatasetSubprocess", + function(jaspFilePath, dataSetIndex, decode, normalize) { + calls$dataset <<- list( + jaspFilePath = jaspFilePath, + dataSetIndex = dataSetIndex, + decode = decode, + normalize = normalize + ) + data.frame( + score = factor(c("low", "high"), levels = c("low", "high")), + check.names = FALSE + ) + }, + asNamespace("jaspSyntax") + ) + restoreBridge <- localNamespaceBinding( + ".runBridgeSubprocess", + function(task, target, input, failureLabel) { + calls$runtime <<- list( + task = task, + target = target, + input = input, + failureLabel = failureLabel + ) + input$records + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreRunner(), add = TRUE) + on.exit(restoreDataset(), add = TRUE) + on.exit(restoreBridge(), add = TRUE) + + records <- jaspSyntax::readAnalysisOptionsFromJaspFile( + jaspFile, + modulePath = c(jaspRuntime = "C:/fake/module"), + runtime = TRUE, + includeMeta = FALSE + ) + + expect_equal(names(records), "RuntimeAnalysis") + expect_false(calls$saved$runtime) + expect_true(calls$saved$includeMeta) + expect_true(calls$saved$includeTypeOptions) + expect_equal(calls$dataset$jaspFilePath, normalizePath(jaspFile, winslash = "/", mustWork = FALSE)) + expect_equal(calls$dataset$dataSetIndex, 1L) + expect_true(calls$dataset$decode) + expect_false(calls$dataset$normalize) + expect_equal(calls$runtime$task, "read_runtime_options") + expect_equal(calls$runtime$target, ".runtimeOptionsForJaspRecordsInProcess") + expect_s3_class(calls$runtime$input$dataset$score, "factor") + expect_equal(levels(calls$runtime$input$dataset$score), c("low", "high")) + expect_false(calls$runtime$input$includeMeta) + expect_true(calls$runtime$input$includeTypeOptions) + expect_equal( + calls$runtime$failureLabel, + "readAnalysisOptionsFromJaspFile(runtime = TRUE)" + ) +}) + +test_that("in-process .jasp option reads clear native state on exit", { + jaspFile <- tempfile(fileext = ".jasp") + writeTestJaspFile( + jaspFile, + list(list(name = "CleanupAnalysis", title = "Cleanup Analysis")) + ) + + clearCalls <- 0L + restoreClear <- localNamespaceBinding( + "clearNativeState", + function() { + clearCalls <<- clearCalls + 1L + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreOptions <- localNamespaceBinding( + "analysisOptionsFromJaspFile", + function(...) stop("forced native read failure", call. = FALSE), + asNamespace("jaspSyntax") + ) + on.exit(restoreClear(), add = TRUE) + on.exit(restoreOptions(), add = TRUE) + + expect_error( + jaspSyntax:::.readAnalysisOptionsFromJaspFileInProcess(jaspFile), + "forced native read failure", + fixed = TRUE + ) + expect_equal(clearCalls, 2L) +}) + +test_that("readAnalysisOptionsFromJaspFile validates input", { + expect_error( + jaspSyntax::readAnalysisOptionsFromJaspFile("missing.jasp"), + "File not found" + ) + + csvFile <- tempfile(fileext = ".csv") + writeLines("x", csvFile) + expect_error( + jaspSyntax::readAnalysisOptionsFromJaspFile(csvFile), + ".jasp extension", + fixed = TRUE + ) + + jaspFile <- tempfile(fileext = ".jasp") + writeTestJaspFile(jaspFile, list(list(name = "ValidationAnalysis"))) + expect_error( + jaspSyntax::readAnalysisOptionsFromJaspFile(jaspFile, isolated = NA), + "isolated" + ) +}) diff --git a/tests/testthat/test-module-options.R b/tests/testthat/test-module-options.R new file mode 100644 index 0000000..a409730 --- /dev/null +++ b/tests/testthat/test-module-options.R @@ -0,0 +1,158 @@ +context("module options") + +fixtureModule <- testthat::test_path("fixtures", "minimalModule") + +test_that("readModuleDescription returns native module metadata", { + desc <- jaspSyntax::readModuleDescription(fixtureModule) + + expect_equal(desc$name, "jaspSyntaxTestModule") + expect_equal(desc$title, "Syntax Test Module") + expect_equal(desc$version, "0.1") + expect_length(desc$analyses, 3) + expect_equal(names(desc$analyses), c("DefaultAnalysis", "MinimalAnalysis", "VariableAnalysis")) + expect_equal(desc$analyses$DefaultAnalysis$qml, "DefaultAnalysis.qml") + expect_true(desc$analyses$DefaultAnalysis$preloadData) + expect_equal(desc$analyses$MinimalAnalysis$qml, "MinimalAnalysis.qml") + expect_false(desc$analyses$MinimalAnalysis$preloadData) +}) + +test_that("parseModuleDescription accepts Description.qml paths", { + descPath <- testthat::test_path("fixtures", "minimalModule", "inst", "Description.qml") + desc <- jaspSyntax::parseModuleDescription(descPath) + + expect_equal(desc$name, "jaspSyntaxTestModule") + expect_equal(desc$analyses$MinimalAnalysis$name, "MinimalAnalysis") +}) + +test_that("resolveAnalysisQml resolves qml overrides and preload flags", { + resolved <- jaspSyntax::resolveAnalysisQml(fixtureModule, "MinimalAnalysis") + + expect_equal(resolved$moduleName, "jaspSyntaxTestModule") + expect_equal(resolved$qmlFileName, "MinimalAnalysis.qml") + expect_true(file.exists(resolved$qmlFile)) + expect_false(resolved$preloadData) +}) + +test_that("readDefaultAnalysisOptions returns QML defaults", { + opts <- jaspSyntax::readDefaultAnalysisOptions(fixtureModule, "MinimalAnalysis") + + expect_true(opts$flag) + expect_equal(opts$threshold, 1.5) + expect_equal(opts$choice, "two") + expect_equal(opts$plotWidth, 480) + expect_equal(opts$plotHeight, 320) + expect_equal(attr(opts, "analysisName"), "MinimalAnalysis") + expect_equal(attr(opts, "moduleName"), "jaspSyntaxTestModule") + expect_false(attr(opts, "preloadData")) +}) + +test_that("readDefaultAnalysisOptions can omit metadata explicitly", { + opts <- jaspSyntax::readDefaultAnalysisOptions( + fixtureModule, + "MinimalAnalysis", + includeMeta = FALSE + ) + + expect_false(".meta" %in% names(opts)) + expect_false(any(grepl("\\.types$", names(opts)))) + expect_true(opts$flag) +}) + +test_that("readAnalysisOptionsFromQml applies supplied options", { + opts <- jaspSyntax::readAnalysisOptionsFromQml( + fixtureModule, + "MinimalAnalysis", + options = list(flag = FALSE, threshold = 2.5, choice = "one") + ) + + expect_false(opts$flag) + expect_equal(opts$threshold, 2.5) + expect_equal(opts$choice, "one") +}) + +test_that("readAnalysisOptionsFromQml returns Desktop runtime-encoded variable options", { + jaspSyntax::cleanUp() + on.exit(jaspSyntax::cleanUp(), add = TRUE) + jaspSyntax::loadDataSet(data.frame( + x = c(1.1, 2.2, 3.3), + group = factor(c("a", "b", "a")), + rating = factor(c("10", "20", "10"), levels = c("10", "20")), + check.names = FALSE + )) + + opts <- jaspSyntax::readAnalysisOptionsFromQml( + fixtureModule, + "VariableAnalysis", + options = list(variables = "x"), + includeMeta = FALSE + ) + + expect_true("variables.types" %in% names(opts)) + expect_equal(opts$`variables.types`, list("scale")) + expect_match(opts$variables[[1]], "^JaspColumn_.*_Encoded$") + + loadedDataset <- jaspSyntax::readLoadedDataset(decode = FALSE) + expect_true(any(vapply( + loadedDataset, + identical, + logical(1L), + c("a", "b", "a") + ))) + expect_true(any(vapply( + loadedDataset, + identical, + logical(1L), + c("10", "20", "10") + ))) +}) + +test_that("fresh parsing resets cached QML state", { + overridden <- jaspSyntax::readAnalysisOptionsFromQml( + fixtureModule, + "MinimalAnalysis", + options = list(flag = FALSE, threshold = 9.5, choice = "one") + ) + expect_false(overridden$flag) + + defaults <- jaspSyntax::readDefaultAnalysisOptions(fixtureModule, "MinimalAnalysis") + expect_true(defaults$flag) + expect_equal(defaults$threshold, 1.5) + expect_equal(defaults$choice, "two") +}) + +test_that("parseQmlOptions supports raw JSON output", { + resolved <- jaspSyntax::resolveAnalysisQml(fixtureModule, "MinimalAnalysis") + json <- jaspSyntax::parseQmlOptions( + resolved$qmlFile, + moduleName = resolved$moduleName, + analysisName = resolved$analysisName, + version = resolved$version, + preloadData = resolved$preloadData, + output = "json" + ) + + expect_true(jsonlite::validate(json)) +}) + +test_that("parseQmlOptions requires JSON object options", { + resolved <- jaspSyntax::resolveAnalysisQml(fixtureModule, "MinimalAnalysis") + + expect_error( + jaspSyntax::parseQmlOptions( + resolved$qmlFile, + options = "[]", + moduleName = resolved$moduleName, + analysisName = resolved$analysisName, + version = resolved$version, + preloadData = resolved$preloadData + ), + "JSON object" + ) +}) + +test_that("readAnalysisOptionsFromQml validates analysis names", { + expect_error( + jaspSyntax::readDefaultAnalysisOptions(fixtureModule, "MissingAnalysis"), + "Could not locate analysis" + ) +}) diff --git a/tools/check-syntaxinterface-symbols.sh b/tools/check-syntaxinterface-symbols.sh new file mode 100644 index 0000000..8d47b78 --- /dev/null +++ b/tools/check-syntaxinterface-symbols.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +set -euo pipefail + +HEADER_PATH="${1:-}" +BINARY_PATH="${2:-}" +SOURCE_PATH="${3:-src/syntaxfunctions.cpp}" + +HEADER_ORIGIN="${SYNTAXINTERFACE_HEADER_ORIGIN:-}" +BINARY_ORIGIN="${SYNTAXINTERFACE_BINARY_ORIGIN:-}" + +function usage() { + echo "Usage: $0 [syntaxfunctions.cpp]" >&2 +} + +function print_path_context() { + echo " Header path: ${HEADER_PATH}" >&2 + if [ -n "${HEADER_ORIGIN}" ] && [ "${HEADER_ORIGIN}" != "${HEADER_PATH}" ]; then + echo " Header source: ${HEADER_ORIGIN}" >&2 + fi + echo " Binary path: ${BINARY_PATH}" >&2 + if [ -n "${BINARY_ORIGIN}" ] && [ "${BINARY_ORIGIN}" != "${BINARY_PATH}" ]; then + echo " Binary source: ${BINARY_ORIGIN}" >&2 + fi + echo " Native source: ${SOURCE_PATH}" >&2 +} + +function print_missing_symbols() { + local FILE_PATH="$1" + local SYMBOL + + while IFS= read -r SYMBOL; do + [ -n "${SYMBOL}" ] && echo " ${SYMBOL}" >&2 + done < "${FILE_PATH}" +} + +function fail_with_missing_symbols() { + local TITLE="$1" + local DETAILS="$2" + local MISSING_FILE="$3" + + echo "" >&2 + echo "ERROR: ${TITLE}" >&2 + print_path_context + echo " Missing symbols:" >&2 + print_missing_symbols "${MISSING_FILE}" + echo "" >&2 + echo "${DETAILS}" >&2 + exit 1 +} + +function find_export_tool() { + local CANDIDATE + + for CANDIDATE in dumpbin llvm-objdump objdump x86_64-w64-mingw32-objdump nm x86_64-w64-mingw32-nm; do + if command -v "${CANDIDATE}" >/dev/null 2>&1; then + command -v "${CANDIDATE}" + return 0 + fi + done + + for CANDIDATE in \ + /c/rtools46/ucrt64/bin/objdump \ + /c/rtools45/ucrt64/bin/objdump \ + /c/rtools44/ucrt64/bin/objdump \ + /c/rtools43/ucrt64/bin/objdump \ + /c/rtools42/ucrt64/bin/objdump + do + if [ -x "${CANDIDATE}" ]; then + echo "${CANDIDATE}" + return 0 + fi + done + + return 1 +} + +function write_exports() { + local TOOL_PATH="$1" + local DLL_PATH="$2" + local OUTPUT_PATH="$3" + local TOOL_NAME + + TOOL_NAME="$(basename "${TOOL_PATH}")" + + case "${TOOL_NAME}" in + dumpbin*) + "${TOOL_PATH}" /exports "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 + ;; + *objdump*) + case "${DLL_PATH}" in + *.dll|*.DLL) + "${TOOL_PATH}" -p "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 + ;; + *) + "${TOOL_PATH}" -T "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 || + "${TOOL_PATH}" -t "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 + ;; + esac + ;; + *nm*) + "${TOOL_PATH}" -g "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 + ;; + *) + return 1 + ;; + esac +} + +if [ -z "${HEADER_PATH}" ] || [ -z "${BINARY_PATH}" ]; then + usage + exit 2 +fi + +if [ ! -f "${SOURCE_PATH}" ]; then + echo "ERROR: Cannot verify SyntaxInterface symbols because the native source file is missing." >&2 + print_path_context + exit 1 +fi + +if [ ! -f "${HEADER_PATH}" ]; then + echo "ERROR: Cannot verify SyntaxInterface symbols because the header is missing." >&2 + print_path_context + exit 1 +fi + +if [ ! -f "${BINARY_PATH}" ]; then + echo "ERROR: Cannot verify SyntaxInterface symbols because the binary is missing." >&2 + print_path_context + exit 1 +fi + +TMP_DIR="${TMPDIR:-/tmp}" +TMP_BASE="${TMP_DIR}/jaspsyntax-symbol-check.$$" +SYMBOLS_FILE="${TMP_BASE}.symbols" +MISSING_HEADER_FILE="${TMP_BASE}.missing-header" +MISSING_EXPORTS_FILE="${TMP_BASE}.missing-exports" +EXPORTS_FILE="${TMP_BASE}.exports" +trap 'rm -f "${SYMBOLS_FILE}" "${MISSING_HEADER_FILE}" "${MISSING_EXPORTS_FILE}" "${EXPORTS_FILE}"' EXIT + +: > "${MISSING_HEADER_FILE}" +: > "${MISSING_EXPORTS_FILE}" + +{ grep -Eho 'syntaxBridge[A-Za-z0-9_]+[[:space:]]*\(' "${SOURCE_PATH}" || true; } \ + | sed -E 's/[[:space:]]*\($//' \ + | sort -u > "${SYMBOLS_FILE}" + +if [ ! -s "${SYMBOLS_FILE}" ]; then + echo "ERROR: No SyntaxInterface bridge symbols were found in ${SOURCE_PATH}." >&2 + print_path_context + exit 1 +fi + +while IFS= read -r SYMBOL; do + if ! grep -Eq "(^|[^[:alnum:]_])${SYMBOL}[[:space:]]*\\(" "${HEADER_PATH}"; then + echo "${SYMBOL}" >> "${MISSING_HEADER_FILE}" + fi +done < "${SYMBOLS_FILE}" + +if [ -s "${MISSING_HEADER_FILE}" ]; then + fail_with_missing_symbols \ + "SyntaxInterface header does not declare every native bridge symbol used by jaspSyntax." \ + "The header and src/syntaxfunctions.cpp are out of sync. Use a jasp-desktop checkout whose SyntaxInterface header matches this jaspSyntax source, or remove stale generated headers and reinstall." \ + "${MISSING_HEADER_FILE}" +fi + +SYMBOL_COUNT="$(wc -l < "${SYMBOLS_FILE}" | tr -d '[:space:]')" +echo "Verified ${SYMBOL_COUNT} SyntaxInterface declarations in ${HEADER_PATH}" + +case "${BINARY_PATH}" in + *.dll|*.DLL|*.so|*.dylib) ;; + *) exit 0 ;; +esac + +case "${JASPSYNTAX_CHECK_EXPORTS:-auto}" in + 0|false|FALSE|no|NO|never|NEVER) + echo "Skipping SyntaxInterface DLL export check because JASPSYNTAX_CHECK_EXPORTS=${JASPSYNTAX_CHECK_EXPORTS}." + exit 0 + ;; +esac + +EXPORT_TOOL="$(find_export_tool || true)" +if [ -z "${EXPORT_TOOL}" ]; then + echo "ERROR: Cannot verify SyntaxInterface binary exports because dumpbin, objdump, and nm were not found." >&2 + print_path_context + echo "" >&2 + echo "Install an export inspection tool or explicitly set JASPSYNTAX_CHECK_EXPORTS=false to bypass this ABI check." >&2 + exit 1 +fi + +if ! write_exports "${EXPORT_TOOL}" "${BINARY_PATH}" "${EXPORTS_FILE}"; then + echo "ERROR: Cannot verify SyntaxInterface binary exports with ${EXPORT_TOOL}." >&2 + print_path_context + echo "" >&2 + echo "Use a readable SyntaxInterface binary or explicitly set JASPSYNTAX_CHECK_EXPORTS=false to bypass this ABI check." >&2 + exit 1 +fi + +while IFS= read -r SYMBOL; do + if ! grep -Eq "(^|[^[:alnum:]_])_?${SYMBOL}(@[0-9]+)?([^[:alnum:]_]|$)" "${EXPORTS_FILE}"; then + echo "${SYMBOL}" >> "${MISSING_EXPORTS_FILE}" + fi +done < "${SYMBOLS_FILE}" + +if [ -s "${MISSING_EXPORTS_FILE}" ]; then + fail_with_missing_symbols \ + "SyntaxInterface binary does not export every native bridge symbol used by jaspSyntax." \ + "The header and binary likely come from different jasp-desktop checkouts or branches. Rebuild or copy a matching SyntaxInterface binary, or point JASP_BUILD_DIR/JASPSYNTAX_LIB_DIR/JASPSYNTAX_LIB_PATH at the matching artifact." \ + "${MISSING_EXPORTS_FILE}" +fi + +echo "Verified ${SYMBOL_COUNT} SyntaxInterface exports in ${BINARY_PATH} using ${EXPORT_TOOL}" From 79258ee2314f39e76a8a558af88cb4f5bb174ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 16:34:46 +0200 Subject: [PATCH 22/30] Preserve JASP replay provenance --- R/options.R | 172 +++++++++++++++++++++++++- R/readDatasetFromJaspFile.R | 56 ++++++++- man/readModuleDescription.Rd | 4 +- tests/testthat/test-dataset-helpers.R | 82 +++++++++++- tests/testthat/test-module-options.R | 4 +- 5 files changed, 311 insertions(+), 7 deletions(-) diff --git a/R/options.R b/R/options.R index bd6eccc..2560fc7 100644 --- a/R/options.R +++ b/R/options.R @@ -133,6 +133,168 @@ normalizePath(qmlFile, winslash = "/", mustWork = TRUE) } +.moduleDescriptionQmlPath <- function(modulePath) { + candidates <- c( + file.path(modulePath, "inst", "Description.qml"), + file.path(modulePath, "Description.qml") + ) + + descriptionFile <- candidates[file.exists(candidates)][1L] + if (is.na(descriptionFile)) { + return(NULL) + } + + normalizePath(descriptionFile, winslash = "/", mustWork = TRUE) +} + +.sourceModuleDescription <- function(modulePath, byName = TRUE) { + descriptionFile <- .moduleDescriptionQmlPath(modulePath) + if (is.null(descriptionFile)) { + return(NULL) + } + + descriptionText <- paste(readLines(descriptionFile, warn = FALSE), collapse = "\n") + packageInfo <- .readSourceModuleDescriptionFile(modulePath) + modulePreloadData <- .qmlLogicalProperty(descriptionText, "preloadData", TRUE) + moduleHasWrappers <- .qmlLogicalProperty(descriptionText, "hasWrappers", FALSE) + analyses <- .qmlAnalysisEntries(descriptionText, modulePreloadData, moduleHasWrappers) + + if (length(analyses) == 0L) { + stop("Module description does not contain analyses", call. = FALSE) + } + + description <- list( + name = .defaultString(packageInfo[["Package"]], basename(modulePath)), + title = .qmlStringProperty(descriptionText, "title", .defaultString(packageInfo[["Title"]], "")), + author = .defaultString(packageInfo[["Author"]], ""), + website = .defaultString(packageInfo[["Website"]], ""), + license = .defaultString(packageInfo[["License"]], ""), + maintainer = .defaultString(packageInfo[["Maintainer"]], ""), + description = .qmlStringProperty(descriptionText, "description", .defaultString(packageInfo[["Description"]], "")), + requiresData = .qmlLogicalProperty(descriptionText, "requiresData", TRUE), + hasWrappers = moduleHasWrappers, + isCommon = FALSE, + version = .defaultString(packageInfo[["Version"]], ""), + analyses = analyses + ) + + if (isTRUE(byName)) { + names(description[["analyses"]]) <- vapply( + description[["analyses"]], + function(analysis) .analysisValue(analysis, "name", ""), + character(1L) + ) + } + + attr(description, "modulePath") <- modulePath + description +} + +.readSourceModuleDescriptionFile <- function(modulePath) { + descriptionPath <- file.path(modulePath, "DESCRIPTION") + if (!file.exists(descriptionPath)) { + return(list()) + } + + dcf <- tryCatch( + read.dcf(descriptionPath), + error = function(e) matrix(character(0), nrow = 0L, ncol = 0L) + ) + if (nrow(dcf) == 0L) { + return(list()) + } + + as.list(dcf[1L, , drop = TRUE]) +} + +.qmlAnalysisEntries <- function(descriptionText, modulePreloadData, moduleHasWrappers) { + blocks <- .qmlBlocks(descriptionText, "Analysis") + lapply(blocks, function(block) { + name <- .qmlStringProperty(block, "func") + if (is.null(name)) { + stop("Analysis entry is missing a `func` property", call. = FALSE) + } + + list( + name = name, + qml = .qmlStringProperty(block, "qml", paste0(name, ".qml")), + title = .qmlStringProperty(block, "title", .qmlStringProperty(block, "menu", name)), + preloadData = .qmlLogicalProperty(block, "preloadData", modulePreloadData), + hasWrapper = .qmlLogicalProperty(block, "hasWrapper", moduleHasWrappers) + ) + }) +} + +.qmlBlocks <- function(text, blockName) { + lines <- strsplit(text, "\n", fixed = TRUE)[[1L]] + lines <- sub("^\\s*//.*$", "", lines) + blocks <- list() + inBlock <- FALSE + sawOpeningBrace <- FALSE + depth <- 0L + block <- character(0L) + blockPattern <- paste0("^\\s*", blockName, "\\b") + + for (line in lines) { + if (!inBlock && grepl(blockPattern, line)) { + inBlock <- TRUE + sawOpeningBrace <- FALSE + depth <- 0L + block <- character(0L) + } + + if (!inBlock) { + next + } + + block <- c(block, line) + openingBraces <- lengths(regmatches(line, gregexpr("\\{", line))) + closingBraces <- lengths(regmatches(line, gregexpr("\\}", line))) + sawOpeningBrace <- sawOpeningBrace || openingBraces > 0L + depth <- depth + openingBraces - closingBraces + + if (sawOpeningBrace && depth <= 0L) { + blocks[[length(blocks) + 1L]] <- paste(block, collapse = "\n") + inBlock <- FALSE + } + } + + blocks +} + +.qmlStringProperty <- function(text, property, default = NULL) { + pattern <- paste0( + "(?m)^\\s*", property, "\\s*:\\s*", + "(?:qsTr\\s*\\(\\s*)?\"([^\"]*)\"" + ) + match <- regexec(pattern, text, perl = TRUE) + value <- regmatches(text, match)[[1L]] + if (length(value) < 2L) { + return(default) + } + + value[[2L]] +} + +.qmlLogicalProperty <- function(text, property, default = NULL) { + pattern <- paste0("(?m)^\\s*", property, "\\s*:\\s*(true|false)\\b") + match <- regexec(pattern, text, perl = TRUE) + value <- regmatches(text, match)[[1L]] + if (length(value) < 2L) { + return(default) + } + + identical(value[[2L]], "true") +} + +.defaultString <- function(x, y) { + if (is.null(x) || length(x) == 0L || is.na(x[[1L]]) || !nzchar(x[[1L]])) { + return(y) + } + + as.character(x[[1L]]) +} + .analysisValue <- function(analysis, name, default = NULL) { value <- analysis[[name]] if (is.null(value) || length(value) == 0L || is.na(value)) { @@ -214,7 +376,9 @@ #' Read a JASP Module Description #' -#' Reads a module's `Description.qml` through the native SyntaxInterface bridge. +#' Reads a module's `Description.qml` metadata. Source checkouts are resolved +#' directly from `Description.qml`/`DESCRIPTION`; installed or binary modules +#' fall back to the native SyntaxInterface bridge. #' #' @param modulePath Path to a JASP module source directory or its #' `inst/Description.qml` file. @@ -225,6 +389,12 @@ #' @export parseModuleDescription <- function(modulePath, byName = TRUE) { modulePath <- .validateModulePath(modulePath) + + description <- .sourceModuleDescription(modulePath, byName = byName) + if (!is.null(description)) { + return(description) + } + description <- parseDescription(modulePath) if (!is.list(description) || is.null(names(description))) { diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R index 64de04c..843cff0 100644 --- a/R/readDatasetFromJaspFile.R +++ b/R/readDatasetFromJaspFile.R @@ -46,9 +46,60 @@ return(NULL) } + .attachJaspDatasetSource(dataset, jaspFilePath, dataSetIndex) +} + +.attachJaspDatasetSource <- function(dataset, jaspFilePath, dataSetIndex) { + if (!is.data.frame(dataset)) { + return(dataset) + } + + attr(dataset, "jaspSyntax.jaspFilePath") <- normalizePath(jaspFilePath, winslash = "/", mustWork = FALSE) + attr(dataset, "jaspSyntax.dataSetIndex") <- as.integer(dataSetIndex) + attr(dataset, "jaspSyntax.jaspFileDim") <- dim(dataset) + attr(dataset, "jaspSyntax.jaspFileNames") <- names(dataset) dataset } +.jaspDatasetSource <- function(dataset) { + jaspFilePath <- attr(dataset, "jaspSyntax.jaspFilePath", exact = TRUE) + dataSetIndex <- attr(dataset, "jaspSyntax.dataSetIndex", exact = TRUE) + jaspFileDim <- attr(dataset, "jaspSyntax.jaspFileDim", exact = TRUE) + jaspFileNames <- attr(dataset, "jaspSyntax.jaspFileNames", exact = TRUE) + + if (is.null(jaspFilePath) || is.null(dataSetIndex) || + is.null(jaspFileDim) || is.null(jaspFileNames)) { + return(NULL) + } + + if (!is.character(jaspFilePath) || length(jaspFilePath) != 1L || + is.na(jaspFilePath) || !file.exists(jaspFilePath)) { + return(NULL) + } + + if (!identical(as.integer(dataSetIndex), 1L) || + !identical(as.integer(dim(dataset)), as.integer(jaspFileDim)) || + !identical(names(dataset), jaspFileNames)) { + return(NULL) + } + + list( + jaspFilePath = jaspFilePath, + dataSetIndex = as.integer(dataSetIndex) + ) +} + +.loadDatasetForAnalysis <- function(dataset) { + source <- .jaspDatasetSource(dataset) + if (!is.null(source)) { + loadDataSetFromJaspFile(source$jaspFilePath) + return(invisible(source)) + } + + loadDataSet(dataset) + invisible(NULL) +} + .normalizeBridgeColumn <- function(column) { if (!is.factor(column)) { return(column) @@ -281,7 +332,7 @@ loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NUL } }, add = TRUE) - loadDataSet(dataset) + .loadDatasetForAnalysis(dataset) runtimeOptions <- readAnalysisOptionsFromQml( modulePath = modulePath, analysisName = analysisName, @@ -340,10 +391,11 @@ loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NUL readDatasetFromJaspFile <- function(jaspFilePath, dataSetIndex = 1L) { args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) - .runReadDatasetSubprocess( + dataset <- .runReadDatasetSubprocess( args$jaspFilePath, args$dataSetIndex, decode = TRUE, normalize = TRUE ) + .attachJaspDatasetSource(dataset, args$jaspFilePath, args$dataSetIndex) } diff --git a/man/readModuleDescription.Rd b/man/readModuleDescription.Rd index dea6852..fbc642f 100644 --- a/man/readModuleDescription.Rd +++ b/man/readModuleDescription.Rd @@ -16,5 +16,7 @@ readModuleDescription(modulePath, byName = TRUE) A list with module metadata and an \code{analyses} list. } \description{ -Reads a module's \code{Description.qml} through the native SyntaxInterface bridge. +Reads a module's \code{Description.qml} metadata. Source checkouts are resolved +directly from \code{Description.qml}/\code{DESCRIPTION}; installed or binary modules +fall back to the native SyntaxInterface bridge. } diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R index 1304bfe..cadc4d1 100644 --- a/tests/testthat/test-dataset-helpers.R +++ b/tests/testthat/test-dataset-helpers.R @@ -451,6 +451,77 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he expect_s3_class(state, "jaspSyntax_analysis_dataset_state") }) +test_that("loadAnalysisDataset reuses native .jasp source when provenance is intact", { + modulePath <- tempfile("jaspSyntaxDatasetModule_") + dir.create(modulePath) + jaspFile <- tempfile(fileext = ".jasp") + file.create(jaspFile) + + loadedJaspFile <- NULL + loadedDataFrame <- FALSE + + restoreClear <- localNamespaceBinding( + "clearDatasetState", + function() invisible(NULL), + asNamespace("jaspSyntax") + ) + restoreLoadDataFrame <- localNamespaceBinding( + "loadDataSet", + function(data) { + loadedDataFrame <<- TRUE + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreLoadJaspFile <- localNamespaceBinding( + "loadDataSetFromJaspFile", + function(path) { + loadedJaspFile <<- path + invisible(NULL) + }, + asNamespace("jaspSyntax") + ) + restoreReadQml <- localNamespaceBinding( + "readAnalysisOptionsFromQml", + function(...) list(variables = "JaspColumn_1_Encoded"), + asNamespace("jaspSyntax") + ) + restoreLoaded <- localGlobalBinding( + ".readFullDatasetToEnd", + function() data.frame(JaspColumn_1_Encoded = 1, check.names = FALSE) + ) + restoreRequested <- localGlobalBinding( + ".readDataSetRequestedNative", + function() data.frame(JaspColumn_1_Encoded = 1, check.names = FALSE) + ) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) c(JaspColumn_1_Encoded = "score")[[columnName]] + ) + on.exit(restoreClear(), add = TRUE) + on.exit(restoreLoadDataFrame(), add = TRUE) + on.exit(restoreLoadJaspFile(), add = TRUE) + on.exit(restoreReadQml(), add = TRUE) + on.exit(restoreLoaded(), add = TRUE) + on.exit(restoreRequested(), add = TRUE) + on.exit(restoreDecoder(), add = TRUE) + + dataset <- jaspSyntax:::.attachJaspDatasetSource( + data.frame(score = 1), + jaspFile, + 1L + ) + + jaspSyntax::loadAnalysisDataset( + dataset, + modulePath = modulePath, + analysisName = "ExampleAnalysis" + ) + + expect_equal(loadedJaspFile, normalizePath(jaspFile, winslash = "/", mustWork = FALSE)) + expect_false(loadedDataFrame) +}) + test_that("loadAnalysisDataset clears native state when loading fails", { modulePath <- tempfile("jaspSyntaxDatasetModule_") dir.create(modulePath) @@ -659,7 +730,16 @@ test_that("readDatasetFromJaspFile dispatches through the shared bridge subproce dataset <- jaspSyntax::readDatasetFromJaspFile(jaspFile) - expect_equal(dataset, data.frame(x = 1)) + expect_s3_class(dataset, "data.frame") + expect_equal(names(dataset), "x") + expect_equal(dataset$x, 1) + expect_equal( + attr(dataset, "jaspSyntax.jaspFilePath"), + normalizePath(jaspFile, winslash = "/", mustWork = FALSE) + ) + expect_equal(attr(dataset, "jaspSyntax.dataSetIndex"), 1L) + expect_equal(attr(dataset, "jaspSyntax.jaspFileDim"), c(1L, 1L)) + expect_equal(attr(dataset, "jaspSyntax.jaspFileNames"), "x") expect_equal(runnerCall$task, "read_dataset") expect_equal(runnerCall$target, ".readDatasetFromJaspFileInProcess") expect_equal(runnerCall$input$jaspFilePath, jaspFile) diff --git a/tests/testthat/test-module-options.R b/tests/testthat/test-module-options.R index a409730..d239a1b 100644 --- a/tests/testthat/test-module-options.R +++ b/tests/testthat/test-module-options.R @@ -2,12 +2,12 @@ context("module options") fixtureModule <- testthat::test_path("fixtures", "minimalModule") -test_that("readModuleDescription returns native module metadata", { +test_that("readModuleDescription returns module metadata", { desc <- jaspSyntax::readModuleDescription(fixtureModule) expect_equal(desc$name, "jaspSyntaxTestModule") expect_equal(desc$title, "Syntax Test Module") - expect_equal(desc$version, "0.1") + expect_equal(desc$version, "0.1.0") expect_length(desc$analyses, 3) expect_equal(names(desc$analyses), c("DefaultAnalysis", "MinimalAnalysis", "VariableAnalysis")) expect_equal(desc$analyses$DefaultAnalysis$qml, "DefaultAnalysis.qml") From 06117aaa4edb671c14ed3339c85fb77df91e74a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 20:03:17 +0200 Subject: [PATCH 23/30] Harden native bridge replay contracts --- R/bridgeSubprocess.R | 2 +- R/options.R | 183 ++++++++++++++++++++++---- R/readDatasetFromJaspFile.R | 68 +++++++++- man/datasetBridgeHelpers.Rd | 2 +- tests/testthat/test-dataset-helpers.R | 54 ++++++++ tests/testthat/test-module-options.R | 44 +++++++ 6 files changed, 318 insertions(+), 35 deletions(-) diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R index ad1c2c7..53a77f3 100644 --- a/R/bridgeSubprocess.R +++ b/R/bridgeSubprocess.R @@ -34,7 +34,7 @@ " dllDirs <- c(file.path(packagePath, 'src'), file.path(packagePath, 'libs', R.version$arch), file.path(packagePath, 'libs'))", " dllDirs <- dllDirs[dir.exists(dllDirs)]", " if (.Platform$OS.type == 'windows' && length(dllDirs) > 0L) {", - " Sys.setenv(PATH = paste(c(dllDirs, Sys.getenv('PATH')), collapse = .Platform$path.sep))", + " Sys.setenv(PATH = paste(c(Sys.getenv('PATH'), dllDirs), collapse = .Platform$path.sep))", " }", " if (!requireNamespace('pkgload', quietly = TRUE)) {", " stop('pkgload is required to load source-checkout jaspSyntax in a subprocess')", diff --git a/R/options.R b/R/options.R index 2560fc7..c817583 100644 --- a/R/options.R +++ b/R/options.R @@ -226,36 +226,27 @@ } .qmlBlocks <- function(text, blockName) { - lines <- strsplit(text, "\n", fixed = TRUE)[[1L]] - lines <- sub("^\\s*//.*$", "", lines) + maskedText <- .qmlMaskNonCode(text) blocks <- list() - inBlock <- FALSE - sawOpeningBrace <- FALSE - depth <- 0L - block <- character(0L) - blockPattern <- paste0("^\\s*", blockName, "\\b") - - for (line in lines) { - if (!inBlock && grepl(blockPattern, line)) { - inBlock <- TRUE - sawOpeningBrace <- FALSE - depth <- 0L - block <- character(0L) - } - if (!inBlock) { + blockPattern <- paste0("\\b", blockName, "\\b\\s*\\{") + starts <- gregexpr(blockPattern, maskedText, perl = TRUE)[[1L]] + if (identical(starts, -1L)) { + return(blocks) + } + + matchLengths <- attr(starts, "match.length") + for (i in seq_along(starts)) { + matchedText <- substring(maskedText, starts[[i]], starts[[i]] + matchLengths[[i]] - 1L) + openingOffset <- regexpr("\\{", matchedText, perl = TRUE)[[1L]] + if (openingOffset < 0L) { next } - block <- c(block, line) - openingBraces <- lengths(regmatches(line, gregexpr("\\{", line))) - closingBraces <- lengths(regmatches(line, gregexpr("\\}", line))) - sawOpeningBrace <- sawOpeningBrace || openingBraces > 0L - depth <- depth + openingBraces - closingBraces - - if (sawOpeningBrace && depth <= 0L) { - blocks[[length(blocks) + 1L]] <- paste(block, collapse = "\n") - inBlock <- FALSE + openingBrace <- starts[[i]] + openingOffset - 1L + closingBrace <- .qmlMatchingBrace(maskedText, openingBrace) + if (!is.na(closingBrace)) { + blocks[[length(blocks) + 1L]] <- substring(text, starts[[i]], closingBrace) } } @@ -263,9 +254,10 @@ } .qmlStringProperty <- function(text, property, default = NULL) { + text <- .qmlMaskComments(text) pattern <- paste0( - "(?m)^\\s*", property, "\\s*:\\s*", - "(?:qsTr\\s*\\(\\s*)?\"([^\"]*)\"" + "(?s)(?:^|[\\{;\\n])\\s*", property, "\\s*:\\s*", + "(?:qsTr\\s*\\(\\s*)?\"((?:[^\"\\\\]|\\\\.)*)\"" ) match <- regexec(pattern, text, perl = TRUE) value <- regmatches(text, match)[[1L]] @@ -273,11 +265,12 @@ return(default) } - value[[2L]] + .qmlUnescapeString(value[[2L]]) } .qmlLogicalProperty <- function(text, property, default = NULL) { - pattern <- paste0("(?m)^\\s*", property, "\\s*:\\s*(true|false)\\b") + text <- .qmlMaskComments(text) + pattern <- paste0("(?s)(?:^|[\\{;\\n])\\s*", property, "\\s*:\\s*(true|false)\\b") match <- regexec(pattern, text, perl = TRUE) value <- regmatches(text, match)[[1L]] if (length(value) < 2L) { @@ -287,6 +280,138 @@ identical(value[[2L]], "true") } +.qmlMatchingBrace <- function(text, openingBrace) { + chars <- strsplit(text, "", fixed = TRUE)[[1L]] + depth <- 0L + + for (i in seq.int(openingBrace, length(chars))) { + if (identical(chars[[i]], "{")) { + depth <- depth + 1L + } else if (identical(chars[[i]], "}")) { + depth <- depth - 1L + if (depth == 0L) { + return(i) + } + } + } + + NA_integer_ +} + +.qmlMaskComments <- function(text) { + chars <- strsplit(text, "", fixed = TRUE)[[1L]] + out <- chars + inString <- FALSE + quote <- "" + escaped <- FALSE + inLineComment <- FALSE + inBlockComment <- FALSE + i <- 1L + + while (i <= length(chars)) { + ch <- chars[[i]] + nextCh <- if (i < length(chars)) chars[[i + 1L]] else "" + + if (inLineComment) { + if (identical(ch, "\n")) { + inLineComment <- FALSE + } else { + out[[i]] <- " " + } + i <- i + 1L + next + } + + if (inBlockComment) { + out[[i]] <- if (identical(ch, "\n")) "\n" else " " + if (identical(ch, "*") && identical(nextCh, "/")) { + out[[i + 1L]] <- " " + inBlockComment <- FALSE + i <- i + 2L + } else { + i <- i + 1L + } + next + } + + if (inString) { + if (escaped) { + escaped <- FALSE + } else if (identical(ch, "\\")) { + escaped <- TRUE + } else if (identical(ch, quote)) { + inString <- FALSE + } + i <- i + 1L + next + } + + if (identical(ch, "\"") || identical(ch, "'")) { + inString <- TRUE + quote <- ch + i <- i + 1L + next + } + + if (identical(ch, "/") && identical(nextCh, "/")) { + out[[i]] <- " " + out[[i + 1L]] <- " " + inLineComment <- TRUE + i <- i + 2L + next + } + + if (identical(ch, "/") && identical(nextCh, "*")) { + out[[i]] <- " " + out[[i + 1L]] <- " " + inBlockComment <- TRUE + i <- i + 2L + next + } + + i <- i + 1L + } + + paste(out, collapse = "") +} + +.qmlMaskNonCode <- function(text) { + text <- .qmlMaskComments(text) + chars <- strsplit(text, "", fixed = TRUE)[[1L]] + out <- chars + inString <- FALSE + quote <- "" + escaped <- FALSE + + for (i in seq_along(chars)) { + ch <- chars[[i]] + + if (inString) { + out[[i]] <- if (identical(ch, "\n")) "\n" else " " + if (escaped) { + escaped <- FALSE + } else if (identical(ch, "\\")) { + escaped <- TRUE + } else if (identical(ch, quote)) { + inString <- FALSE + } + next + } + + if (identical(ch, "\"") || identical(ch, "'")) { + out[[i]] <- " " + inString <- TRUE + quote <- ch + } + } + + paste(out, collapse = "") +} + +.qmlUnescapeString <- function(x) { + gsub("\\\\([\"\\\\])", "\\1", x, perl = TRUE) +} + .defaultString <- function(x, y) { if (is.null(x) || length(x) == 0L || is.na(x[[1L]]) || !nzchar(x[[1L]])) { return(y) diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R index 843cff0..5648ffb 100644 --- a/R/readDatasetFromJaspFile.R +++ b/R/readDatasetFromJaspFile.R @@ -58,6 +58,8 @@ attr(dataset, "jaspSyntax.dataSetIndex") <- as.integer(dataSetIndex) attr(dataset, "jaspSyntax.jaspFileDim") <- dim(dataset) attr(dataset, "jaspSyntax.jaspFileNames") <- names(dataset) + attr(dataset, "jaspSyntax.jaspFileSignature") <- .jaspFileSignature(jaspFilePath) + attr(dataset, "jaspSyntax.jaspFileDataHash") <- .datasetHash(dataset) dataset } @@ -66,9 +68,12 @@ dataSetIndex <- attr(dataset, "jaspSyntax.dataSetIndex", exact = TRUE) jaspFileDim <- attr(dataset, "jaspSyntax.jaspFileDim", exact = TRUE) jaspFileNames <- attr(dataset, "jaspSyntax.jaspFileNames", exact = TRUE) + jaspFileSignature <- attr(dataset, "jaspSyntax.jaspFileSignature", exact = TRUE) + jaspFileDataHash <- attr(dataset, "jaspSyntax.jaspFileDataHash", exact = TRUE) if (is.null(jaspFilePath) || is.null(dataSetIndex) || - is.null(jaspFileDim) || is.null(jaspFileNames)) { + is.null(jaspFileDim) || is.null(jaspFileNames) || + is.null(jaspFileSignature) || is.null(jaspFileDataHash)) { return(NULL) } @@ -83,12 +88,55 @@ return(NULL) } + if (!identical(.jaspFileSignature(jaspFilePath), jaspFileSignature) || + !identical(.datasetHash(dataset), jaspFileDataHash)) { + return(NULL) + } + list( jaspFilePath = jaspFilePath, dataSetIndex = as.integer(dataSetIndex) ) } +.jaspDatasetSourceAttrs <- c( + "jaspSyntax.jaspFilePath", + "jaspSyntax.dataSetIndex", + "jaspSyntax.jaspFileDim", + "jaspSyntax.jaspFileNames", + "jaspSyntax.jaspFileSignature", + "jaspSyntax.jaspFileDataHash" +) + +.stripJaspDatasetSourceAttrs <- function(dataset) { + for (attrName in .jaspDatasetSourceAttrs) { + attr(dataset, attrName) <- NULL + } + dataset +} + +.datasetHash <- function(dataset) { + dataset <- .stripJaspDatasetSourceAttrs(dataset) + tempFile <- tempfile("jaspSyntax-dataset-", fileext = ".rds") + on.exit(unlink(tempFile, force = TRUE), add = TRUE) + saveRDS(dataset, tempFile, version = 2) + unname(tools::md5sum(tempFile)) +} + +.jaspFileSignature <- function(jaspFilePath) { + jaspFilePath <- normalizePath(jaspFilePath, winslash = "/", mustWork = FALSE) + fileInfo <- file.info(jaspFilePath) + if (nrow(fileInfo) != 1L || is.na(fileInfo$size)) { + return(NULL) + } + + list( + path = jaspFilePath, + size = as.numeric(fileInfo$size), + mtime = as.numeric(fileInfo$mtime) + ) +} + .loadDatasetForAnalysis <- function(dataset) { source <- .jaspDatasetSource(dataset) if (!is.null(source)) { @@ -154,8 +202,8 @@ #' return names unchanged so callers can still operate on non-encoded inputs. #' #' @param columnNames Character vector of column names. -#' @param strict Whether to fail when the native decoder is unavailable or a -#' name cannot be decoded. +#' @param strict Whether to fail when an encoded bridge name cannot be decoded. +#' Raw/non-encoded names are returned unchanged. #' #' @return A character vector with decoded names. #' @@ -166,6 +214,11 @@ decodeColumnNames <- function(columnNames, strict = FALSE) { } strict <- .validateFlag(strict, "strict") + encoded <- .isEncodedBridgeColumnName(columnNames) + if (!any(encoded)) { + return(columnNames) + } + decodeName <- get0(".decodeColNamesStrict", envir = .GlobalEnv, inherits = FALSE) if (!is.function(decodeName)) { if (strict) { @@ -177,7 +230,8 @@ decodeColumnNames <- function(columnNames, strict = FALSE) { return(columnNames) } - vapply(columnNames, function(columnName) { + decodedNames <- columnNames + decodedNames[encoded] <- vapply(columnNames[encoded], function(columnName) { tryCatch( { decoded <- as.character(decodeName(columnName)) @@ -198,6 +252,12 @@ decodeColumnNames <- function(columnNames, strict = FALSE) { } ) }, character(1L), USE.NAMES = FALSE) + decodedNames +} + +.isEncodedBridgeColumnName <- function(columnNames) { + grepl("^JaspColumn_[[:alnum:]_]+_Encoded$", columnNames) | + grepl("^jaspColumn[0-9]+$", columnNames) } #' @rdname decodeColumnNames diff --git a/man/datasetBridgeHelpers.Rd b/man/datasetBridgeHelpers.Rd index 06bde3e..4b0f3db 100644 --- a/man/datasetBridgeHelpers.Rd +++ b/man/datasetBridgeHelpers.Rd @@ -47,7 +47,7 @@ columnMapping(encodedColumnNames = NULL, strict = FALSE) \item{columnNames}{Character vector of column names.} -\item{strict}{Whether to fail when the native decoder is unavailable or a name cannot be decoded.} +\item{strict}{Whether to fail when an encoded bridge name cannot be decoded. Raw/non-encoded names are returned unchanged.} \item{encodedColumnNames}{Optional encoded column names. When omitted, the current native dataset header is used.} } diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R index cadc4d1..3710288 100644 --- a/tests/testthat/test-dataset-helpers.R +++ b/tests/testthat/test-dataset-helpers.R @@ -95,6 +95,33 @@ test_that("decodeColumnNames can fall back or fail when the decoder is unavailab ) }) +test_that("decodeColumnNames does not send raw names to the strict native decoder", { + decoderCalls <- character(0) + restoreDecoder <- localGlobalBinding( + ".decodeColNamesStrict", + function(columnName) { + decoderCalls <<- c(decoderCalls, columnName) + c(JaspColumn_1_Encoded = "score")[[columnName]] + } + ) + on.exit(restoreDecoder(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnNames( + c("score", "score.scale", "JaspColumn_1_Encoded"), + strict = TRUE + ), + c("score", "score.scale", "score") + ) + expect_equal(decoderCalls, "JaspColumn_1_Encoded") + + restoreDecoder() + expect_equal( + jaspSyntax::decodeColumnNames(c("score", "score.scale"), strict = TRUE), + c("score", "score.scale") + ) +}) + test_that("state readers fail loudly when decode is requested without decoder support", { restoreDecoder <- localGlobalAbsent(".decodeColNamesStrict") restoreLoaded <- localGlobalBinding( @@ -522,6 +549,28 @@ test_that("loadAnalysisDataset reuses native .jasp source when provenance is int expect_false(loadedDataFrame) }) +test_that("JASP dataset provenance is invalidated by data or archive changes", { + jaspFile <- tempfile(fileext = ".jasp") + writeLines("archive-v1", jaspFile) + + dataset <- jaspSyntax:::.attachJaspDatasetSource( + data.frame(score = c(1, 2), group = c("a", "b")), + jaspFile, + 1L + ) + + expect_false(is.null(jaspSyntax:::.jaspDatasetSource(dataset))) + expect_false(is.null(attr(dataset, "jaspSyntax.jaspFileSignature", exact = TRUE))) + expect_false(is.null(attr(dataset, "jaspSyntax.jaspFileDataHash", exact = TRUE))) + + changedValues <- dataset + changedValues$score <- c(2, 1) + expect_null(jaspSyntax:::.jaspDatasetSource(changedValues)) + + writeLines(c("archive-v2", "changed"), jaspFile) + expect_null(jaspSyntax:::.jaspDatasetSource(dataset)) +}) + test_that("loadAnalysisDataset clears native state when loading fails", { modulePath <- tempfile("jaspSyntaxDatasetModule_") dir.create(modulePath) @@ -630,6 +679,11 @@ test_that("subprocess package loading distinguishes source checkouts from instal "pkgload::load_all", fixed = TRUE ) + expect_match( + paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), + "c(Sys.getenv('PATH'), dllDirs)", + fixed = TRUE + ) descriptionCandidates <- c( file.path(getwd(), "DESCRIPTION"), diff --git a/tests/testthat/test-module-options.R b/tests/testthat/test-module-options.R index d239a1b..1000b9d 100644 --- a/tests/testthat/test-module-options.R +++ b/tests/testthat/test-module-options.R @@ -16,6 +16,50 @@ test_that("readModuleDescription returns module metadata", { expect_false(desc$analyses$MinimalAnalysis$preloadData) }) +test_that("readModuleDescription handles one-line analysis entries", { + modulePath <- tempfile("jaspSyntaxInlineModule_") + dir.create(file.path(modulePath, "inst"), recursive = TRUE) + on.exit(unlink(modulePath, recursive = TRUE), add = TRUE) + writeLines( + c( + "Package: jaspSyntaxInlineModule", + "Type: Package", + "Title: Inline Module", + "Version: 0.1.0", + "Description: Inline analysis fixture.", + "License: GPL (>= 2)" + ), + file.path(modulePath, "DESCRIPTION") + ) + writeLines( + c( + "import QtQuick", + "import JASP.Module", + "", + "Description {", + " title: qsTr(\"Inline Module\")", + " preloadData: false", + " hasWrappers: true", + " // Analysis { func: \"CommentedOut\" }", + " Analysis { title: qsTr(\"ANOVA\"); func: \"Anova\" }", + " Analysis { title: qsTr(\"Custom\"); func: \"CustomAnalysis\"; qml: \"CustomForm.qml\"; preloadData: true; hasWrapper: false }", + "}" + ), + file.path(modulePath, "inst", "Description.qml") + ) + + desc <- jaspSyntax::readModuleDescription(modulePath) + + expect_equal(names(desc$analyses), c("Anova", "CustomAnalysis")) + expect_equal(desc$analyses$Anova$title, "ANOVA") + expect_equal(desc$analyses$Anova$qml, "Anova.qml") + expect_false(desc$analyses$Anova$preloadData) + expect_true(desc$analyses$Anova$hasWrapper) + expect_equal(desc$analyses$CustomAnalysis$qml, "CustomForm.qml") + expect_true(desc$analyses$CustomAnalysis$preloadData) + expect_false(desc$analyses$CustomAnalysis$hasWrapper) +}) + test_that("parseModuleDescription accepts Description.qml paths", { descPath <- testthat::test_path("fixtures", "minimalModule", "inst", "Description.qml") desc <- jaspSyntax::parseModuleDescription(descPath) From b30247b2af5ecb25d41df07cea15433f9e33d7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 20:08:22 +0200 Subject: [PATCH 24/30] Prefer bridge DLLs in source subprocesses --- R/bridgeSubprocess.R | 12 ++++++++++-- tests/testthat/test-dataset-helpers.R | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R index 53a77f3..478667b 100644 --- a/R/bridgeSubprocess.R +++ b/R/bridgeSubprocess.R @@ -34,7 +34,14 @@ " dllDirs <- c(file.path(packagePath, 'src'), file.path(packagePath, 'libs', R.version$arch), file.path(packagePath, 'libs'))", " dllDirs <- dllDirs[dir.exists(dllDirs)]", " if (.Platform$OS.type == 'windows' && length(dllDirs) > 0L) {", - " Sys.setenv(PATH = paste(c(Sys.getenv('PATH'), dllDirs), collapse = .Platform$path.sep))", + " pathEntries <- strsplit(Sys.getenv('PATH'), .Platform$path.sep, fixed = TRUE)[[1L]]", + " pathEntries <- pathEntries[nzchar(pathEntries)]", + " buildDir <- Sys.getenv('JASP_BUILD_DIR')", + " buildDirs <- if (nzchar(buildDir) && dir.exists(buildDir)) buildDir else character(0)", + " Sys.setenv(PATH = paste(unique(c(buildDirs, dllDirs, pathEntries)), collapse = .Platform$path.sep))", + " message('jaspSyntax subprocess source package: ', packagePath)", + " message('jaspSyntax subprocess DLL dirs: ', paste(dllDirs, collapse = ';'))", + " message('jaspSyntax subprocess PATH head: ', paste(head(strsplit(Sys.getenv('PATH'), .Platform$path.sep, fixed = TRUE)[[1L]], 8L), collapse = ';'))", " }", " if (!requireNamespace('pkgload', quietly = TRUE)) {", " stop('pkgload is required to load source-checkout jaspSyntax in a subprocess')", @@ -108,8 +115,9 @@ outputSuffix <- .bridgeSubprocessOutputSuffix(output) if (!file.exists(outputPath)) { + statusMessage <- if (!is.null(status)) paste0(" (exit status ", status, ")") else "" stop( - failureLabel, " failed before producing a result.", + failureLabel, " failed before producing a result", statusMessage, ".", outputSuffix, call. = FALSE ) diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R index 3710288..a55b546 100644 --- a/tests/testthat/test-dataset-helpers.R +++ b/tests/testthat/test-dataset-helpers.R @@ -681,7 +681,12 @@ test_that("subprocess package loading distinguishes source checkouts from instal ) expect_match( paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), - "c(Sys.getenv('PATH'), dllDirs)", + "c(buildDirs, dllDirs, pathEntries)", + fixed = TRUE + ) + expect_match( + paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), + "jaspSyntax subprocess PATH head", fixed = TRUE ) From 27def8886ca7110143f255817d9e969fec4024f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 21:41:58 +0200 Subject: [PATCH 25/30] Clean up native bridge warning lifecycle --- R/RcppExports.R | 4 ++++ R/zzz.R | 9 +++++++++ configure.win | 2 +- src/RcppExports.cpp | 10 ++++++++++ src/syntaxfunctions.cpp | 8 ++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/R/RcppExports.R b/R/RcppExports.R index 758a792..5ffd922 100644 --- a/R/RcppExports.R +++ b/R/RcppExports.R @@ -5,6 +5,10 @@ cleanUp <- function() { invisible(.Call(`_jaspSyntax_cleanUp`)) } +shutdownNative <- function() { + invisible(.Call(`_jaspSyntax_shutdownNative`)) +} + clearQmlFormsNative <- function() { invisible(.Call(`_jaspSyntax_clearQmlFormsNative`)) } diff --git a/R/zzz.R b/R/zzz.R index 18a8424..8f91cac 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -29,6 +29,11 @@ } .onLoad <- function(libname, pkgname) { + namespace <- asNamespace(pkgname) + reg.finalizer(namespace, function(e) { + try(get("shutdownNative", envir = e)(), silent = TRUE) + }, onexit = TRUE) + rArch <- sub("^/", "", .Platform$r_arch) namespacePath <- getNamespaceInfo(pkgname, "path") packageLibRoot <- file.path(libname, pkgname, "libs", rArch) @@ -82,3 +87,7 @@ Sys.setenv(QML_IMPORT_PATH = qmlPaths) } } + +.onUnload <- function(libpath) { + try(shutdownNative(), silent = TRUE) +} diff --git a/configure.win b/configure.win index 7710ca8..38bf702 100644 --- a/configure.win +++ b/configure.win @@ -705,8 +705,8 @@ RUNTIME_DLLS="libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll" addRuntimeSearchDir "${JASPSYNTAX_RUNTIME_DIR}" addRuntimeSearchDir "${JASPSYNTAX_LIB_DIR}" -addRuntimeSearchDir "${JASP_BUILD_DIR}" addRuntimeSearchDir "${JASP_BUILD_DIR}/R-Interface" +addRuntimeSearchDir "${JASP_BUILD_DIR}" discoverLocalRtoolsRuntimeDirs discoverWindowsSystemRuntimeDirs diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index 77b68c3..f22fd56 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -19,6 +19,15 @@ BEGIN_RCPP return R_NilValue; END_RCPP } +// shutdownNative +void shutdownNative(); +RcppExport SEXP _jaspSyntax_shutdownNative() { +BEGIN_RCPP + Rcpp::RNGScope rcpp_rngScope_gen; + shutdownNative(); + return R_NilValue; +END_RCPP +} // clearQmlFormsNative void clearQmlFormsNative(); RcppExport SEXP _jaspSyntax_clearQmlFormsNative() { @@ -153,6 +162,7 @@ END_RCPP static const R_CallMethodDef CallEntries[] = { {"_jaspSyntax_cleanUp", (DL_FUNC) &_jaspSyntax_cleanUp, 0}, + {"_jaspSyntax_shutdownNative", (DL_FUNC) &_jaspSyntax_shutdownNative, 0}, {"_jaspSyntax_clearQmlFormsNative", (DL_FUNC) &_jaspSyntax_clearQmlFormsNative, 0}, {"_jaspSyntax_clearDatasetStateNative", (DL_FUNC) &_jaspSyntax_clearDatasetStateNative, 0}, {"_jaspSyntax_clearNativeStateNative", (DL_FUNC) &_jaspSyntax_clearNativeStateNative, 0}, diff --git a/src/syntaxfunctions.cpp b/src/syntaxfunctions.cpp index bfe7c58..faa4d26 100644 --- a/src/syntaxfunctions.cpp +++ b/src/syntaxfunctions.cpp @@ -68,6 +68,14 @@ void cleanUp() }); } +// [[Rcpp::export]] +void shutdownNative() +{ + callBridgeOrStop("syntaxBridgeShutdown", []() { + syntaxBridgeShutdown(); + }); +} + // [[Rcpp::export]] void clearQmlFormsNative() { From 0e3c2388dd5c39ccaf94a2e89a958cd37a7d45d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Thu, 14 May 2026 22:40:54 +0200 Subject: [PATCH 26/30] Harden native bridge subprocess isolation --- .github/workflows/build-syntaxinterface.yml | 198 ++++++--------- DESCRIPTION | 1 + R/bridgeSubprocess.R | 258 +++++++++++++------- R/options.R | 48 +++- R/readDatasetFromJaspFile.R | 3 +- R/zzz.R | 16 +- configure | 11 +- configure.win | 14 +- man/parseQmlOptions.Rd | 5 +- man/readAnalysisOptionsFromQml.Rd | 8 +- man/readDefaultAnalysisOptions.Rd | 5 +- tests/testthat/test-dataset-helpers.R | 16 +- tests/testthat/test-jasp-file-options.R | 18 +- tests/testthat/test-module-options.R | 3 +- 14 files changed, 360 insertions(+), 244 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 5c133de..2b4c158 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -18,8 +18,8 @@ on: description: 'Release tag to upload assets to' required: false default: 'syntaxinterface-libs' - schedule: - - cron: '0 6 1,15 * *' # 1st and 15th of each month at 06:00 UTC +# schedule: +# - cron: '0 6 1,15 * *' # 1st and 15th of each month at 06:00 UTC # Cancel previous runs of this workflow on the same ref concurrency: @@ -29,10 +29,9 @@ concurrency: env: JASP_DESKTOP_REF: ${{ github.event.inputs.jasp_desktop_ref || 'development' }} RELEASE_TAG: ${{ github.event.inputs.release_tag || 'syntaxinterface-libs' }} - QT_VERSION: '6.10.1' + QT_VERSION: '6.10.2' QT_SUBMODULES: 'qtbase,qtdeclarative,qtshadertools' - #NOTE: if you change QT_SUBMODULES or Qt configure flags, also update the cache key below in "Cache static Qt build". - # We cannot reuse the QT_SUBMODULES env var for the cache key because it contains commas. + QT_SUBMODULES_CACHE_KEY: 'qtbase-qtdeclarative-qtshadertools' jobs: @@ -74,15 +73,6 @@ jobs: # ---- System dependencies ---- - - name: Free disk space (Linux) - if: runner.os == 'Linux' - run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL - sudo docker image prune --all --force - df -h / - # Yes, we occasionally get "no space left on device" errors on ubuntu-latest, even though it should have ~14 GB free when clean. - # The above commands are a common workaround to free up some extra space, we should occasionally re-evaluate the necessity of this. - - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | @@ -96,7 +86,8 @@ jobs: libxcb-xinerama0 libxcb-cursor0 \ libboost-dev libboost-filesystem-dev libboost-system-dev \ libboost-date-time-dev libboost-timer-dev libboost-chrono-dev \ - libminizip-dev libnss3-dev libnspr4-dev \ + librdata-dev libfreexl-dev libminizip-dev libglpk-dev \ + libjsoncpp-dev libnss3-dev libnspr4-dev \ libxcomposite-dev libxdamage-dev libxrandr-dev libxtst-dev \ libxi-dev libasound2-dev libxkbfile-dev \ libxcb-icccm4-dev libxcb-shape0-dev libxcb-keysyms1-dev \ @@ -106,8 +97,9 @@ jobs: - name: Install system dependencies (macOS) if: runner.os == 'macOS' run: | - brew install cmake ninja autoconf automake libtool pkg-config \ - boost jsoncpp libarchive openssl sqlite + brew install cmake ninja autoconf automake libtool pkg-config + pip3 install --break-system-packages conan + conan profile detect --force - name: Install system dependencies (Windows) if: runner.os == 'Windows' @@ -115,106 +107,72 @@ jobs: run: | choco install ninja -y - - name: Restore vcpkg cache (Windows) - if: runner.os == 'Windows' - id: cache-vcpkg - uses: actions/cache@v5 + # ---- Install readstat (Linux) ---- + + - name: Install ReadStat from source (Linux) + if: runner.os == 'Linux' + run: | + wget -q https://github.com/WizardMac/ReadStat/releases/download/v1.1.9/readstat-1.1.9.tar.gz + tar -xzf readstat-1.1.9.tar.gz + cd readstat-1.1.9 + ./configure --prefix=/usr/local + make -j$(nproc) CFLAGS='-Wno-error=use-after-free' + sudo make install + sudo ldconfig + + # ---- Setup R ---- + + - name: Setup R + uses: r-lib/actions/setup-r@v2 with: - path: C:/vcpkg/installed - key: vcpkg-v1-x64-windows-static-boost-sqlite3-libarchive + r-version: 'release' - - name: Install C++ dependencies via vcpkg (Windows) - if: runner.os == 'Windows' && steps.cache-vcpkg.outputs.cache-hit != 'true' - shell: cmd + - name: Install Rcpp and RInside run: | - call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 - vcpkg install boost-headers boost-filesystem boost-system boost-date-time boost-timer boost-chrono boost-interprocess sqlite3 libarchive --triplet x64-windows-static + Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' # ---- Install Qt from source and build static ---- - - name: Restore Qt cache + - name: Cache static Qt build id: cache-qt - uses: actions/cache/restore@v5 + uses: actions/cache@v5 with: path: ${{ github.workspace }}/qt-static - key: qt-static-v4-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools + key: qt-static-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-${{ env.QT_SUBMODULES_CACHE_KEY }} + + - name: Install aqtinstall + if: steps.cache-qt.outputs.cache-hit != 'true' + run: pip3 install --break-system-packages aqtinstall - name: Download Qt source if: steps.cache-qt.outputs.cache-hit != 'true' shell: bash run: | - git clone --depth 1 --branch v${{ env.QT_VERSION }} https://code.qt.io/qt/qt5.git qt-src - cd qt-src - perl init-repository --module-subset=${{ env.QT_SUBMODULES }} + aqt install-src -O qt-src all_os desktop "${{ env.QT_VERSION }}" - name: Build static Qt (Unix) if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash run: | - cd qt-src + cd qt-src/${{ env.QT_VERSION }}/Src ./configure -release -static -opensource -confirm-license \ -prefix ${{ github.workspace }}/qt-static \ -submodules ${{ env.QT_SUBMODULES }} \ - -nomake examples -nomake tests -nomake benchmarks \ - -optimize-size \ - -no-icu \ - -no-feature-printsupport -no-feature-sql \ - -no-feature-dbus -no-feature-cups \ - -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ - -DQT_FEATURE_qml_jit=OFF \ - -DQT_FEATURE_qml_debug=OFF \ - -DQT_FEATURE_qml_network=OFF + -- -DCMAKE_POSITION_INDEPENDENT_CODE=ON cmake --build . --parallel $(nproc 2>/dev/null || sysctl -n hw.ncpu) cmake --install . - cd .. && rm -rf qt-src - name: Build static Qt (Windows) if: steps.cache-qt.outputs.cache-hit != 'true' && runner.os == 'Windows' shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" amd64 - cd qt-src - call configure.bat -release -static -opensource -confirm-license ^ + cd qt-src\${{ env.QT_VERSION }}\Src + configure.bat -release -static -opensource -confirm-license ^ -prefix ${{ github.workspace }}\qt-static ^ - -submodules ${{ env.QT_SUBMODULES }} ^ - -nomake examples -nomake tests -nomake benchmarks ^ - -optimize-size ^ - -no-icu ^ - -no-feature-printsupport -no-feature-sql ^ - -no-feature-dbus -no-feature-cups ^ - -- -DQT_FEATURE_qml_jit=OFF ^ - -DQT_FEATURE_qml_debug=OFF ^ - -DQT_FEATURE_qml_network=OFF + -submodules ${{ env.QT_SUBMODULES }} cmake --build . --parallel cmake --install . - cd .. && rmdir /s /q qt-src - - - name: Save Qt cache - if: steps.cache-qt.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: ${{ github.workspace }}/qt-static - key: qt-static-v4-${{ matrix.os }}-${{ matrix.arch }}-${{ env.QT_VERSION }}-qtbase-qtdeclarative-qtshadertools - - # ---- Setup R (after Qt build to avoid rtools45 PATH pollution on Windows) ---- - - - name: Setup R - uses: r-lib/actions/setup-r@v2 - with: - r-version: 'release' - - - name: Install rtools45 MinGW toolchain (Windows) - if: runner.os == 'Windows' - shell: bash - run: | - # R-Interface on Windows is cross-compiled with MinGW (R uses MinGW, not MSVC). - # setup-r installs rtools45 but the ucrt64 toolchain may not be present. - # See also: jasp-desktop/Docs/development/jasp-build-guide-windows.md - /c/rtools45/usr/bin/pacman.exe -Sy --noconfirm mingw-w64-ucrt-x86_64-toolchain make - - - name: Install Rcpp and RInside - run: | - Rscript -e 'install.packages(c("Rcpp", "RInside"), repos = "https://cloud.r-project.org")' # ---- Clone and build jasp-desktop / SyntaxInterface ---- @@ -224,25 +182,41 @@ jobs: repository: jasp-stats/jasp-desktop ref: ${{ env.JASP_DESKTOP_REF }} path: jasp-desktop - submodules: true token: ${{ secrets.GITHUB_TOKEN }} - - name: Configure jasp-desktop (Unix) - if: runner.os != 'Windows' + - name: Restore Conan package cache (macOS) + if: runner.os == 'macOS' + id: cache-conan + uses: actions/cache/restore@v5 + with: + path: ~/.conan2/p + key: conan-v1-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('jasp-desktop/conanfile.py') }} + restore-keys: | + conan-v1-${{ matrix.os }}-${{ matrix.arch }}- + + - name: Configure jasp-desktop (macOS) + if: runner.os == 'macOS' shell: bash run: | R_HOME=$(R RHOME) - # On macOS, add brew prefix paths for keg-only libraries - EXTRA_PREFIX="" - if [ "$(uname)" = "Darwin" ]; then - EXTRA_PREFIX="$(brew --prefix libarchive);$(brew --prefix sqlite);$(brew --prefix openssl)" - fi cmake -G Ninja -S jasp-desktop -B build \ -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_PREFIX_PATH="${{ github.workspace }}/qt-static;${EXTRA_PREFIX}" \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/qt-static" \ -DCUSTOM_R_PATH="${R_HOME}" \ -DREQUIRE_GITHUB_PAT=OFF \ - -DUSE_CONAN=OFF \ + -DJASP_SYNTAX_INTERFACE_ONLY=ON \ + -DINSTALL_R_MODULES=OFF \ + -DBUILD_TESTS=OFF + + - name: Configure jasp-desktop (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + R_HOME=$(R RHOME) + cmake -G Ninja -S jasp-desktop -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/qt-static" \ + -DCUSTOM_R_PATH="${R_HOME}" \ -DINSTALL_R_MODULES=OFF \ -DBUILD_TESTS=OFF @@ -255,19 +229,22 @@ jobs: cmake -G Ninja -S jasp-desktop -B build ^ -DCMAKE_BUILD_TYPE=Release ^ -DCMAKE_PREFIX_PATH=${{ github.workspace }}\qt-static ^ - -DCMAKE_TOOLCHAIN_FILE=%VCPKG_INSTALLATION_ROOT%\scripts\buildsystems\vcpkg.cmake ^ - -DVCPKG_TARGET_TRIPLET=x64-windows-static ^ - -DVS_PATH="C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC" ^ -DREQUIRE_GITHUB_PAT=OFF ^ - -DUSE_CONAN=OFF ^ + -DJASP_SYNTAX_INTERFACE_ONLY=ON ^ -DINSTALL_R_MODULES=OFF ^ -DBUILD_TESTS=OFF - - name: Build SyntaxInterface (Unix) - if: runner.os != 'Windows' + - name: Build SyntaxInterface shell: bash run: cmake --build build --target SyntaxInterface --parallel + - name: Save Conan package cache (macOS) + if: runner.os == 'macOS' && steps.cache-conan.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ~/.conan2/p + key: conan-v1-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('jasp-desktop/conanfile.py') }} + - name: Build SyntaxInterface (Windows) if: runner.os == 'Windows' shell: cmd @@ -287,23 +264,10 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - # Ninja on Windows may place DLLs in build/bin/ or build/SyntaxInterface/ — search for them - echo "--- Searching for SyntaxInterface.dll ---" - find build -name 'SyntaxInterface.dll' -type f - SI_DLL=$(find build -name 'SyntaxInterface.dll' -type f | head -1) - if [ -z "$SI_DLL" ]; then - echo "ERROR: SyntaxInterface.dll not found anywhere under build/" - exit 1 - fi - echo "Found: $SI_DLL" - cp "$SI_DLL" ${{ matrix.artifact }} - - echo "--- Searching for libR-InterfaceNoRInside.dll ---" - find build -name 'libR-InterfaceNoRInside.dll' -type f - RI_DLL=$(find build -name 'libR-InterfaceNoRInside.dll' -type f | head -1) - if [ -n "$RI_DLL" ]; then - echo "Found: $RI_DLL" - cp "$RI_DLL" libR-InterfaceNoRInside-windows-x86_64.dll + # On Windows we also need libR-InterfaceNoRInside.dll + cp build/SyntaxInterface/${{ matrix.lib_file }} ${{ matrix.artifact }} + if [ -f build/R-Interface/libR-InterfaceNoRInside.dll ]; then + cp build/R-Interface/libR-InterfaceNoRInside.dll libR-InterfaceNoRInside-windows-x86_64.dll fi - name: Upload artifact diff --git a/DESCRIPTION b/DESCRIPTION index a2435c8..8569dcb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -13,6 +13,7 @@ URL: https://github.com/jasp-stats/jaspSyntax BugReports: https://github.com/jasp-stats/jaspSyntax/issues SystemRequirements: libcurl or wget (for downloading the SyntaxInterface library during installation) Imports: + callr, jsonlite, Rcpp (>= 1.0.5) Suggests: diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R index 478667b..9a8bc50 100644 --- a/R/bridgeSubprocess.R +++ b/R/bridgeSubprocess.R @@ -1,7 +1,3 @@ -.getRscriptBinary <- function() { - file.path(R.home("bin"), if (.Platform$OS.type == "windows") "Rscript.exe" else "Rscript") -} - .isSourceCheckoutPath <- function(packagePath) { packagePath <- normalizePath(packagePath, winslash = "/", mustWork = FALSE) @@ -24,34 +20,147 @@ ) } -.bridgeSubprocessPackageLoaderScript <- function() { - c( - "loadJaspSyntaxForSubprocess <- function(packageSpec) {", - " libPaths <- packageSpec$libPaths", - " if (length(libPaths) > 0L) .libPaths(c(libPaths, .libPaths()))", - " packagePath <- packageSpec$packagePath", - " if (isTRUE(packageSpec$sourceCheckout)) {", - " dllDirs <- c(file.path(packagePath, 'src'), file.path(packagePath, 'libs', R.version$arch), file.path(packagePath, 'libs'))", - " dllDirs <- dllDirs[dir.exists(dllDirs)]", - " if (.Platform$OS.type == 'windows' && length(dllDirs) > 0L) {", - " pathEntries <- strsplit(Sys.getenv('PATH'), .Platform$path.sep, fixed = TRUE)[[1L]]", - " pathEntries <- pathEntries[nzchar(pathEntries)]", - " buildDir <- Sys.getenv('JASP_BUILD_DIR')", - " buildDirs <- if (nzchar(buildDir) && dir.exists(buildDir)) buildDir else character(0)", - " Sys.setenv(PATH = paste(unique(c(buildDirs, dllDirs, pathEntries)), collapse = .Platform$path.sep))", - " message('jaspSyntax subprocess source package: ', packagePath)", - " message('jaspSyntax subprocess DLL dirs: ', paste(dllDirs, collapse = ';'))", - " message('jaspSyntax subprocess PATH head: ', paste(head(strsplit(Sys.getenv('PATH'), .Platform$path.sep, fixed = TRUE)[[1L]], 8L), collapse = ';'))", - " }", - " if (!requireNamespace('pkgload', quietly = TRUE)) {", - " stop('pkgload is required to load source-checkout jaspSyntax in a subprocess')", - " }", - " suppressPackageStartupMessages(pkgload::load_all(packagePath, quiet = TRUE, recompile = FALSE))", - " } else {", - " suppressPackageStartupMessages(library(jaspSyntax))", - " }", - "}" +.pathEntries <- function(path = Sys.getenv("PATH", unset = "")) { + entries <- strsplit(path, .Platform$path.sep, fixed = TRUE)[[1L]] + normalizePath(entries[nzchar(entries)], winslash = "/", mustWork = FALSE) +} + +.qtRootForPathEntry <- function(path) { + path <- normalizePath(path, winslash = "/", mustWork = FALSE) + if (basename(path) == "bin") { + path <- dirname(path) + } + + if (dir.exists(file.path(path, "plugins")) || dir.exists(file.path(path, "qml"))) { + return(path) + } + + character(0) +} + +.selectedQtRootForSubprocess <- function(pathEntries) { + explicit <- .qtRootForPathEntry(Sys.getenv("JASPSYNTAX_QT_DIR", unset = "")) + if (length(explicit) > 0L) { + return(explicit[[1L]]) + } + + roots <- unique(unlist(lapply(pathEntries, .qtRootForPathEntry), use.names = FALSE)) + roots <- roots[nzchar(roots)] + msvcRoots <- roots[grepl("/msvc", roots, ignore.case = TRUE)] + if (length(msvcRoots) > 0L) { + return(msvcRoots[[1L]]) + } + + siblingMsvcRoots <- unique(unlist(lapply(dirname(roots), function(parent) { + Sys.glob(file.path(parent, "msvc*")) + }), use.names = FALSE)) + siblingMsvcRoots <- normalizePath(siblingMsvcRoots[nzchar(siblingMsvcRoots)], winslash = "/", mustWork = FALSE) + siblingMsvcRoots <- siblingMsvcRoots[dir.exists(file.path(siblingMsvcRoots, "qml"))] + if (length(siblingMsvcRoots) > 0L) { + return(siblingMsvcRoots[[1L]]) + } + + if (length(roots) > 0L) { + roots[[1L]] + } else { + character(0) + } +} + +.sanitizeBridgeSubprocessPath <- function(packageSpec) { + pathEntries <- .pathEntries() + packagePath <- normalizePath(packageSpec$packagePath, winslash = "/", mustWork = FALSE) + selectedQtRoot <- .selectedQtRootForSubprocess(pathEntries) + selectedQtBin <- if (length(selectedQtRoot) > 0L) file.path(selectedQtRoot, "bin") else character(0) + + keep <- vapply(pathEntries, function(path) { + normalized <- normalizePath(path, winslash = "/", mustWork = FALSE) + isOtherJaspSyntaxRuntime <- grepl("/jaspSyntax/(libs|src)(/|$)", normalized, ignore.case = TRUE) && + !startsWith(normalized, packagePath) + qtRoot <- .qtRootForPathEntry(normalized) + isOtherQtRuntime <- length(selectedQtRoot) > 0L && length(qtRoot) > 0L && + !identical(normalizePath(qtRoot, winslash = "/", mustWork = FALSE), normalizePath(selectedQtRoot, winslash = "/", mustWork = FALSE)) + + !isOtherJaspSyntaxRuntime && !isOtherQtRuntime + }, logical(1L), USE.NAMES = FALSE) + + pathEntries <- pathEntries[keep] + unique(c(selectedQtBin, pathEntries)) +} + +.bridgeSubprocessEnv <- function(packageSpec) { + inherited <- c( + "JASP_BUILD_DIR", + "JASPSYNTAX_LIB_DIR", + "JASPSYNTAX_LIB_PATH", + "JASPSYNTAX_RUNTIME_DIR", + "JASPSYNTAX_QT_DIR" ) + values <- Sys.getenv(inherited, unset = NA_character_) + values <- values[!is.na(values)] + sanitizedPath <- .sanitizeBridgeSubprocessPath(packageSpec) + selectedQtRoot <- .selectedQtRootForSubprocess(sanitizedPath) + qtEnv <- character(0) + if (length(selectedQtRoot) > 0L) { + qtPlugins <- file.path(selectedQtRoot, "plugins") + qtQml <- file.path(selectedQtRoot, "qml") + qtEnv <- c( + QT_PLUGIN_PATH = if (dir.exists(qtPlugins)) qtPlugins else "", + QT_QPA_PLATFORM_PLUGIN_PATH = if (dir.exists(file.path(qtPlugins, "platforms"))) file.path(qtPlugins, "platforms") else "", + QML2_IMPORT_PATH = if (dir.exists(qtQml)) qtQml else "", + QML_IMPORT_PATH = if (dir.exists(qtQml)) qtQml else "" + ) + } + + values <- c(PATH = paste(sanitizedPath, collapse = .Platform$path.sep), qtEnv, values) + values +} + +.bridgeSubprocessPackageLoader <- function() { + function(packageSpec) { + pathEntries <- function(path = Sys.getenv("PATH", unset = "")) { + entries <- strsplit(path, .Platform$path.sep, fixed = TRUE)[[1L]] + normalizePath(entries[nzchar(entries)], winslash = "/", mustWork = FALSE) + } + + libPaths <- packageSpec$libPaths + if (length(libPaths) > 0L) { + .libPaths(c(libPaths, .libPaths())) + } + + packagePath <- packageSpec$packagePath + if (isTRUE(packageSpec$sourceCheckout)) { + dllDirs <- c( + file.path(packagePath, "src"), + file.path(packagePath, "libs", R.version$arch), + file.path(packagePath, "libs") + ) + dllDirs <- dllDirs[dir.exists(dllDirs)] + + if (.Platform$OS.type == "windows" && length(dllDirs) > 0L) { + currentPathEntries <- pathEntries() + buildDir <- Sys.getenv("JASP_BUILD_DIR") + buildDirs <- if (nzchar(buildDir)) { + c(file.path(buildDir, "R-Interface"), buildDir) + } else { + character(0) + } + buildDirs <- buildDirs[dir.exists(buildDirs)] + Sys.setenv(PATH = paste(unique(c(dllDirs, buildDirs, currentPathEntries)), collapse = .Platform$path.sep)) + message("jaspSyntax subprocess source package: ", packagePath) + message("jaspSyntax subprocess DLL dirs: ", paste(dllDirs, collapse = ";")) + message("jaspSyntax subprocess PATH head: ", paste(head(strsplit(Sys.getenv("PATH"), .Platform$path.sep, fixed = TRUE)[[1L]], 8L), collapse = ";")) + } + + if (!requireNamespace("pkgload", quietly = TRUE)) { + stop("pkgload is required to load source-checkout jaspSyntax in a subprocess", call. = FALSE) + } + + suppressPackageStartupMessages(pkgload::load_all(packagePath, quiet = TRUE, recompile = FALSE)) + } else { + suppressPackageStartupMessages(library(jaspSyntax)) + } + } } .readBridgeSubprocessOutput <- function(stdoutPath, stderrPath) { @@ -70,60 +179,45 @@ } .runBridgeSubprocess <- function(task, target, input, failureLabel) { - scriptPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".R") - inputPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".rds") - outputPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".rds") stdoutPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".out") stderrPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".err") - on.exit(unlink(c(scriptPath, inputPath, outputPath, stdoutPath, stderrPath)), add = TRUE) - - saveRDS( - list( - input = input, - target = target, - packageSpec = .bridgeSubprocessPackageSpec() + on.exit(unlink(c(stdoutPath, stderrPath)), add = TRUE) + packageSpec <- .bridgeSubprocessPackageSpec() + + result <- tryCatch( + callr::r( + func = function(target, input, packageSpec, loadPackage) { + tryCatch( + { + loadPackage(packageSpec) + do.call(getNamespace("jaspSyntax")[[target]], input) + }, + error = function(e) { + structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") + } + ) + }, + args = list( + target = target, + input = input, + packageSpec = packageSpec, + loadPackage = .bridgeSubprocessPackageLoader() + ), + libpath = .libPaths(), + stdout = stdoutPath, + stderr = stderrPath, + env = .bridgeSubprocessEnv(packageSpec), + cmdargs = c("--slave", "--no-save", "--no-restore"), + error = "error" ), - inputPath - ) - - script <- c( - "args <- commandArgs(trailingOnly = TRUE)", - "inputPath <- args[[1L]]", - "outputPath <- args[[2L]]", - .bridgeSubprocessPackageLoaderScript(), - "payload <- readRDS(inputPath)", - "input <- payload$input", - "target <- payload$target", - "packageSpec <- payload$packageSpec", - "result <- tryCatch(local({", - " loadJaspSyntaxForSubprocess(packageSpec)", - " do.call(getNamespace('jaspSyntax')[[target]], input)", - "}), error = function(e) structure(list(message = conditionMessage(e)), class = 'jaspSyntax_subprocess_error'))", - "saveRDS(result, outputPath)" - ) - - writeLines(script, scriptPath) - - status <- system2( - .getRscriptBinary(), - args = c("--vanilla", scriptPath, inputPath, outputPath), - stdout = stdoutPath, - stderr = stderrPath + error = function(e) { + structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") + } ) output <- .readBridgeSubprocessOutput(stdoutPath, stderrPath) outputSuffix <- .bridgeSubprocessOutputSuffix(output) - if (!file.exists(outputPath)) { - statusMessage <- if (!is.null(status)) paste0(" (exit status ", status, ")") else "" - stop( - failureLabel, " failed before producing a result", statusMessage, ".", - outputSuffix, - call. = FALSE - ) - } - - result <- readRDS(outputPath) if (inherits(result, "jaspSyntax_subprocess_error")) { stop( failureLabel, " failed: ", @@ -133,15 +227,5 @@ ) } - if (!is.null(status) && status != 0L) { - stop( - failureLabel, " failed with exit status ", - status, - ".", - outputSuffix, - call. = FALSE - ) - } - result } diff --git a/R/options.R b/R/options.R index c817583..d66de08 100644 --- a/R/options.R +++ b/R/options.R @@ -600,6 +600,7 @@ resolveAnalysisQml <- function(modulePath, analysisName) { #' @param output Return parsed R `list` output or raw `json`. #' @param includeMeta Whether to retain the `.meta` option in list output. #' @param includeTypeOptions Whether to retain `*.types` options in list output. +#' @param isolated Whether to run native QML parsing in a separate R process. #' #' @return A named list of parsed options, or a JSON string when #' `output = "json"`. @@ -610,7 +611,8 @@ parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", preloadData = TRUE, fresh = TRUE, output = c("list", "json"), includeMeta = TRUE, - includeTypeOptions = TRUE) { + includeTypeOptions = TRUE, + isolated = TRUE) { output <- match.arg(output) qmlFile <- .validateQmlFile(qmlFile) @@ -630,6 +632,28 @@ parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", stop("`fresh` must be a single TRUE/FALSE value", call. = FALSE) } + isolated <- .validateFlag(isolated, "isolated") + if (isolated) { + return(.runBridgeSubprocess( + task = "parse_qml_options", + target = "parseQmlOptions", + input = list( + qmlFile = qmlFile, + options = options, + moduleName = moduleName, + analysisName = analysisName, + version = version, + preloadData = preloadData, + fresh = fresh, + output = output, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = FALSE + ), + failureLabel = "parseQmlOptions" + )) + } + if (fresh) { clearQmlForms() } @@ -676,6 +700,7 @@ parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", #' @param fresh Whether to clear cached QML/native state before parsing. #' @param includeMeta Whether to retain the `.meta` option in list output. #' @param includeTypeOptions Whether to retain `*.types` options in list output. +#' @param isolated Whether to run native QML parsing in a separate R process. #' #' @return A named list of parsed options. #' @@ -684,7 +709,8 @@ readAnalysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, version = NULL, preloadData = NULL, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE) { + includeTypeOptions = TRUE, + isolated = TRUE) { resolved <- resolveAnalysisQml(modulePath, analysisName) description <- resolved$description analysis <- resolved$analysis @@ -710,7 +736,8 @@ readAnalysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, preloadData = preloadData, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) .attachOptionAttributes(parsedOptions, description, analysis, resolved$qmlFile) @@ -722,7 +749,8 @@ analysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, version = NULL, preloadData = NULL, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE) { + includeTypeOptions = TRUE, + isolated = TRUE) { readAnalysisOptionsFromQml( modulePath = modulePath, analysisName = analysisName, @@ -731,7 +759,8 @@ analysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, preloadData = preloadData, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) } @@ -747,14 +776,16 @@ analysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, #' @export readDefaultAnalysisOptions <- function(modulePath, analysisName, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE) { + includeTypeOptions = TRUE, + isolated = TRUE) { readAnalysisOptionsFromQml( modulePath = modulePath, analysisName = analysisName, options = NULL, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) } @@ -931,7 +962,8 @@ readDefaultAnalysisOptions <- function(modulePath, analysisName, fresh = TRUE, version = version, fresh = TRUE, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = FALSE ) record$options <- runtimeOptions diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R index 5648ffb..4826c69 100644 --- a/R/readDatasetFromJaspFile.R +++ b/R/readDatasetFromJaspFile.R @@ -399,7 +399,8 @@ loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NUL options = options, fresh = TRUE, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = FALSE ) loadedRaw <- .readBridgeDataset(".readFullDatasetToEnd", "loaded dataset") diff --git a/R/zzz.R b/R/zzz.R index 8f91cac..b09557e 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -16,16 +16,14 @@ .prioritizeQtRoots <- function(qtRoots, explicitRoots = character(0)) { qtRoots <- unique(normalizePath(qtRoots[nzchar(qtRoots)], winslash = "/", mustWork = FALSE)) explicitRoots <- unique(normalizePath(explicitRoots[nzchar(explicitRoots)], winslash = "/", mustWork = FALSE)) - discoveredRoots <- setdiff(qtRoots, explicitRoots) + msvcRoots <- qtRoots[grepl("/msvc", qtRoots, ignore.case = TRUE)] + siblingMsvcRoots <- unique(unlist(lapply(dirname(qtRoots), function(parent) { + Sys.glob(file.path(parent, "msvc*")) + }), use.names = FALSE)) + siblingMsvcRoots <- normalizePath(siblingMsvcRoots[nzchar(siblingMsvcRoots)], winslash = "/", mustWork = FALSE) + siblingMsvcRoots <- siblingMsvcRoots[dir.exists(file.path(siblingMsvcRoots, "bin"))] - siblingRoots <- unlist(lapply(discoveredRoots, function(qtRoot) { - parent <- dirname(qtRoot) - c(Sys.glob(file.path(parent, "msvc*")), Sys.glob(file.path(parent, "mingw*"))) - }), use.names = FALSE) - siblingRoots <- normalizePath(siblingRoots[nzchar(siblingRoots)], winslash = "/", mustWork = FALSE) - siblingRoots <- siblingRoots[dir.exists(file.path(siblingRoots, "bin"))] - - unique(c(explicitRoots, siblingRoots, discoveredRoots)) + unique(c(explicitRoots, msvcRoots, siblingMsvcRoots, qtRoots)) } .onLoad <- function(libname, pkgname) { diff --git a/configure b/configure index 14d1a9d..d36920f 100755 --- a/configure +++ b/configure @@ -88,6 +88,13 @@ JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" # JASPSYNTAX_LIB_PATH: direct path to a pre-built libSyntaxInterface (.so/.dylib) # Overrides both JASP_BUILD_DIR and the GitHub Release download. JASPSYNTAX_LIB_PATH="${JASPSYNTAX_LIB_PATH:-}" + +if [ -z "${JASP_SOURCE_DIR}" ] && [ -n "${JASP_BUILD_DIR}" ]; then + JASP_BUILD_PARENT="$(cd "${JASP_BUILD_DIR}/.." 2>/dev/null && pwd)" + if [ -f "${JASP_BUILD_PARENT}/SyntaxInterface/syntaxbridge_interface.h" ]; then + JASP_SOURCE_DIR="${JASP_BUILD_PARENT}" + fi +fi SYNTAXINTERFACE_HEADER_PATH="src/syntaxbridge_interface.h" SYNTAXINTERFACE_HEADER_ORIGIN="" SYNTAXINTERFACE_BINARY_PATH="src/${DLL_NAME}" @@ -148,7 +155,7 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" done -elif [ ! -f "src/syntaxbridge_interface.h" ]; then +else if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" fi @@ -173,7 +180,7 @@ elif [[ "${JASP_BUILD_DIR}" ]]; then echo "JASP_BUILD_DIR: ${JASP_BUILD_DIR}" SYNTAXINTERFACE_BINARY_ORIGIN="${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${DLL_NAME}" -elif [ ! -f "src/${DLL_NAME}" ]; then +else echo "Downloading pre-built ${RELEASE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." if ! downloadFile "${GITHUB_RELEASE_URL}/${RELEASE_ASSET}" "src/${DLL_NAME}"; then diff --git a/configure.win b/configure.win index 38bf702..8bae035 100644 --- a/configure.win +++ b/configure.win @@ -543,6 +543,14 @@ JASP_BUILD_DIR="${JASP_BUILD_DIR:-}" JASPSYNTAX_LIB_DIR="${JASPSYNTAX_LIB_DIR:-}" JASPSYNTAX_RUNTIME_DIR="${JASPSYNTAX_RUNTIME_DIR:-}" JASPSYNTAX_QT_DIR="${JASPSYNTAX_QT_DIR:-}" + +if [ -z "${JASP_SOURCE_DIR}" ] && [ -n "${JASP_BUILD_DIR}" ]; then + JASP_BUILD_PARENT="$(cd "${JASP_BUILD_DIR}/.." 2>/dev/null && pwd)" + if [ -f "${JASP_BUILD_PARENT}/SyntaxInterface/syntaxbridge_interface.h" ]; then + JASP_SOURCE_DIR="${JASP_BUILD_PARENT}" + fi +fi + RUNTIME_SEARCH_DIRS=() PROCESSED_RUNTIME_BINARIES=() MISSING_TRANSITIVE_RUNTIME_DLLS=() @@ -609,7 +617,7 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" done -elif [ ! -f "src/syntaxbridge_interface.h" ]; then +else if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" fi @@ -653,7 +661,7 @@ elif [[ "${JASP_BUILD_DIR}" ]]; then exit 1 fi cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${SYNTAXINTERFACE_DLL}" -elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then +else echo "Downloading pre-built ${SYNTAXINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." if ! downloadFile "${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" "src/${SYNTAXINTERFACE_DLL}"; then echo "ERROR: Could not download ${SYNTAXINTERFACE_ASSET}" @@ -691,7 +699,7 @@ elif [[ "${JASP_BUILD_DIR}" ]]; then exit 1 fi cp "${RINTERFACE_ORIGIN}" src/ -elif [ ! -f "src/${RINTERFACE_DLL}" ]; then +else echo "Downloading pre-built ${RINTERFACE_ASSET} from GitHub Release (${GITHUB_RELEASE_TAG})..." if ! downloadFile "${GITHUB_RELEASE_URL}/${RINTERFACE_ASSET}" "src/${RINTERFACE_DLL}"; then echo "ERROR: Could not download ${RINTERFACE_ASSET}" diff --git a/man/parseQmlOptions.Rd b/man/parseQmlOptions.Rd index 110d4d5..9308ff2 100644 --- a/man/parseQmlOptions.Rd +++ b/man/parseQmlOptions.Rd @@ -12,7 +12,8 @@ parseQmlOptions( fresh = TRUE, output = c("list", "json"), includeMeta = TRUE, - includeTypeOptions = TRUE + includeTypeOptions = TRUE, + isolated = TRUE ) } \arguments{ @@ -35,6 +36,8 @@ parseQmlOptions( \item{includeMeta}{Whether to retain the \code{.meta} option in list output.} \item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} + +\item{isolated}{Whether to run native QML parsing in a separate R process.} } \value{ A named list of parsed options, or a JSON string when \code{output = "json"}. diff --git a/man/readAnalysisOptionsFromQml.Rd b/man/readAnalysisOptionsFromQml.Rd index 2f8fc33..195c908 100644 --- a/man/readAnalysisOptionsFromQml.Rd +++ b/man/readAnalysisOptionsFromQml.Rd @@ -11,7 +11,8 @@ readAnalysisOptionsFromQml( preloadData = NULL, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE + includeTypeOptions = TRUE, + isolated = TRUE ) analysisOptionsFromQml( @@ -22,7 +23,8 @@ analysisOptionsFromQml( preloadData = NULL, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE + includeTypeOptions = TRUE, + isolated = TRUE ) } \arguments{ @@ -41,6 +43,8 @@ analysisOptionsFromQml( \item{includeMeta}{Whether to retain the \code{.meta} option in list output.} \item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} + +\item{isolated}{Whether to run native QML parsing in a separate R process.} } \value{ A named list of parsed options. diff --git a/man/readDefaultAnalysisOptions.Rd b/man/readDefaultAnalysisOptions.Rd index 925e2d6..c2fca9e 100644 --- a/man/readDefaultAnalysisOptions.Rd +++ b/man/readDefaultAnalysisOptions.Rd @@ -7,7 +7,8 @@ readDefaultAnalysisOptions( analysisName, fresh = TRUE, includeMeta = TRUE, - includeTypeOptions = TRUE + includeTypeOptions = TRUE, + isolated = TRUE ) } \arguments{ @@ -20,6 +21,8 @@ readDefaultAnalysisOptions( \item{includeMeta}{Whether to retain the \code{.meta} option in list output.} \item{includeTypeOptions}{Whether to retain \code{*.types} options in list output.} + +\item{isolated}{Whether to run native QML parsing in a separate R process.} } \value{ A named list of default options. diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R index a55b546..062d62e 100644 --- a/tests/testthat/test-dataset-helpers.R +++ b/tests/testthat/test-dataset-helpers.R @@ -402,14 +402,15 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he restoreReadQml <- localNamespaceBinding( "readAnalysisOptionsFromQml", function(modulePath, analysisName, options, fresh, - includeMeta, includeTypeOptions) { + includeMeta, includeTypeOptions, isolated) { replayArgs <<- list( modulePath = modulePath, analysisName = analysisName, options = options, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) list(variables = "JaspColumn_1_Encoded", `variables.types` = "scale") }, @@ -468,6 +469,7 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he expect_true(replayArgs$fresh) expect_false(replayArgs$includeMeta) expect_true(replayArgs$includeTypeOptions) + expect_false(replayArgs$isolated) expect_equal(names(state$loadedDataset), c("score", "group")) expect_equal(names(state$requestedDataset), "score") expect_equal(state$requestedDataset$score, c("control", "treatment")) @@ -674,18 +676,19 @@ test_that("subprocess package loading distinguishes source checkouts from instal expect_true(jaspSyntax:::.isSourceCheckoutPath(sourceDir)) expect_false(jaspSyntax:::.isSourceCheckoutPath(installedDir)) + loaderBody <- paste(deparse(body(jaspSyntax:::.bridgeSubprocessPackageLoader())), collapse = "\n") expect_match( - paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), + loaderBody, "pkgload::load_all", fixed = TRUE ) expect_match( - paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), - "c(buildDirs, dllDirs, pathEntries)", + loaderBody, + "file.path(buildDir, \"R-Interface\")", fixed = TRUE ) expect_match( - paste(jaspSyntax:::.bridgeSubprocessPackageLoaderScript(), collapse = "\n"), + loaderBody, "jaspSyntax subprocess PATH head", fixed = TRUE ) @@ -698,6 +701,7 @@ test_that("subprocess package loading distinguishes source checkouts from instal ) descriptionPath <- descriptionCandidates[file.exists(descriptionCandidates)][1L] description <- read.dcf(descriptionPath) + expect_match(description[1L, "Imports"], "callr", fixed = TRUE) expect_match(description[1L, "Suggests"], "pkgload", fixed = TRUE) }) diff --git a/tests/testthat/test-jasp-file-options.R b/tests/testthat/test-jasp-file-options.R index b7ca8e0..cbe17b6 100644 --- a/tests/testthat/test-jasp-file-options.R +++ b/tests/testthat/test-jasp-file-options.R @@ -201,7 +201,7 @@ test_that("readAnalysisOptionsFromJaspFile can replay saved options through QML restoreReadQml <- localNamespaceBinding( "readAnalysisOptionsFromQml", function(modulePath, analysisName, options, version, fresh, - includeMeta, includeTypeOptions) { + includeMeta, includeTypeOptions, isolated) { replayArgs <<- list( modulePath = modulePath, analysisName = analysisName, @@ -209,7 +209,8 @@ test_that("readAnalysisOptionsFromJaspFile can replay saved options through QML version = version, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) list(variables = "JaspColumn_1_Encoded", `variables.types` = "scale") }, @@ -234,6 +235,7 @@ test_that("readAnalysisOptionsFromJaspFile can replay saved options through QML expect_true(replayArgs$fresh) expect_false(replayArgs$includeMeta) expect_true(replayArgs$includeTypeOptions) + expect_false(replayArgs$isolated) expect_equal(records[[1]]$options$variables, "JaspColumn_1_Encoded") expect_equal(records[[1]]$options$`variables.types`, "scale") }) @@ -243,7 +245,7 @@ test_that("runtime replay resolves QML metadata through the module description", restoreParseQml <- localNamespaceBinding( "parseQmlOptions", function(qmlFile, options, moduleName, analysisName, version, - preloadData, fresh, includeMeta, includeTypeOptions) { + preloadData, fresh, includeMeta, includeTypeOptions, isolated) { parseArgs <<- list( qmlFile = qmlFile, options = options, @@ -253,7 +255,8 @@ test_that("runtime replay resolves QML metadata through the module description", preloadData = preloadData, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) list(runtime = TRUE) }, @@ -284,6 +287,7 @@ test_that("runtime replay resolves QML metadata through the module description", expect_true(parseArgs$fresh) expect_false(parseArgs$includeMeta) expect_true(parseArgs$includeTypeOptions) + expect_false(parseArgs$isolated) expect_true(replayed$options$runtime) }) @@ -409,7 +413,7 @@ test_that("multi-analysis runtime replay loads data once and replays each record restoreReadQml <- localNamespaceBinding( "readAnalysisOptionsFromQml", function(modulePath, analysisName, options, version, fresh, - includeMeta, includeTypeOptions) { + includeMeta, includeTypeOptions, isolated) { replayCalls[[length(replayCalls) + 1L]] <<- list( modulePath = modulePath, analysisName = analysisName, @@ -417,7 +421,8 @@ test_that("multi-analysis runtime replay loads data once and replays each record version = version, fresh = fresh, includeMeta = includeMeta, - includeTypeOptions = includeTypeOptions + includeTypeOptions = includeTypeOptions, + isolated = isolated ) list(replayed = analysisName, source = options$source) }, @@ -440,6 +445,7 @@ test_that("multi-analysis runtime replay loads data once and replays each record expect_equal(vapply(replayCalls, `[[`, character(1L), "analysisName"), c("RuntimeOne", "RuntimeTwo")) expect_true(all(vapply(replayCalls, `[[`, logical(1L), "fresh"))) + expect_false(any(vapply(replayCalls, `[[`, logical(1L), "isolated"))) expect_equal(records$RuntimeOne$options, list(replayed = "RuntimeOne", source = "saved-0")) expect_equal(records$RuntimeTwo$options, list(replayed = "RuntimeTwo", source = "saved-1")) }) diff --git a/tests/testthat/test-module-options.R b/tests/testthat/test-module-options.R index 1000b9d..e2363c6 100644 --- a/tests/testthat/test-module-options.R +++ b/tests/testthat/test-module-options.R @@ -128,7 +128,8 @@ test_that("readAnalysisOptionsFromQml returns Desktop runtime-encoded variable o fixtureModule, "VariableAnalysis", options = list(variables = "x"), - includeMeta = FALSE + includeMeta = FALSE, + isolated = FALSE ) expect_true("variables.types" %in% names(opts)) From c8f8526fb5faf240eec31584c4527cb52055f97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Fri, 15 May 2026 07:32:10 +0200 Subject: [PATCH 27/30] Tighten SyntaxInterface source packaging --- .Rbuildignore | 6 +++ .github/workflows/build-syntaxinterface.yml | 22 +++++++++++ .gitignore | 1 + R/options.R | 2 +- configure | 42 +++++++++++++++++--- configure.win | 44 +++++++++++++++++---- 6 files changed, 103 insertions(+), 14 deletions(-) diff --git a/.Rbuildignore b/.Rbuildignore index 9770cf0..8ed19a6 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,9 @@ ^\.github$ ^\.editorconfig$ ^demo\.R$ +^inst/libs$ +^src/Makevars$ +^src/Makevars\.win$ +^src/SyntaxInterface\.provenance$ +^src/syntaxbridge_interface\.h$ +^src/json$ diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 2b4c158..e4255e4 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -217,6 +217,8 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="${{ github.workspace }}/qt-static" \ -DCUSTOM_R_PATH="${R_HOME}" \ + -DREQUIRE_GITHUB_PAT=OFF \ + -DJASP_SYNTAX_INTERFACE_ONLY=ON \ -DINSTALL_R_MODULES=OFF \ -DBUILD_TESTS=OFF @@ -235,6 +237,7 @@ jobs: -DBUILD_TESTS=OFF - name: Build SyntaxInterface + if: runner.os != 'Windows' shell: bash run: cmake --build build --target SyntaxInterface --parallel @@ -270,6 +273,17 @@ jobs: cp build/R-Interface/libR-InterfaceNoRInside.dll libR-InterfaceNoRInside-windows-x86_64.dll fi + - name: Collect SyntaxInterface source bundle + if: runner.os == 'Linux' && matrix.arch == 'x86_64' + shell: bash + run: | + mkdir -p syntaxinterface-sources/SyntaxInterface syntaxinterface-sources/Common/json + cp jasp-desktop/SyntaxInterface/syntaxbridge_interface.h syntaxinterface-sources/SyntaxInterface/ + for file in allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h; do + cp "jasp-desktop/Common/json/${file}" "syntaxinterface-sources/Common/json/${file}" + done + tar -czf SyntaxInterface-sources.tar.gz -C syntaxinterface-sources . + - name: Upload artifact uses: actions/upload-artifact@v7 with: @@ -279,6 +293,14 @@ jobs: libR-InterfaceNoRInside-windows-*.dll if-no-files-found: error + - name: Upload SyntaxInterface source bundle + if: runner.os == 'Linux' && matrix.arch == 'x86_64' + uses: actions/upload-artifact@v7 + with: + name: SyntaxInterface-sources + path: SyntaxInterface-sources.tar.gz + if-no-files-found: error + # --------------------------------------------------------------------------- release: needs: build diff --git a/.gitignore b/.gitignore index 6568099..5e25f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ src/Makevars.win src/syntaxbridge_interface.h src/SyntaxInterface.provenance src/json/* +inst/libs/ diff --git a/R/options.R b/R/options.R index d66de08..632c6a7 100644 --- a/R/options.R +++ b/R/options.R @@ -1053,7 +1053,7 @@ readDefaultAnalysisOptions <- function(modulePath, analysisName, fresh = TRUE, on.exit(clearNativeState(), add = TRUE) if (is.data.frame(dataset)) { - loadDataSet(dataset) + .loadDatasetForAnalysis(dataset) } recordNames <- names(records) diff --git a/configure b/configure index d36920f..f1122c6 100755 --- a/configure +++ b/configure @@ -10,7 +10,9 @@ set -e # Pre-built binaries are hosted as GitHub Release assets. # The build-syntaxinterface.yml workflow produces these. GITHUB_RELEASE_TAG="${JASPSYNTAX_RELEASE_TAG:-syntaxinterface-libs}" -GITHUB_RELEASE_URL="https://github.com/jasp-stats/jaspSyntax/releases/download/${GITHUB_RELEASE_TAG}" +GITHUB_RELEASE_REPO="${JASPSYNTAX_RELEASE_REPO:-jasp-stats/jaspSyntax}" +GITHUB_RELEASE_URL="https://github.com/${GITHUB_RELEASE_REPO}/releases/download/${GITHUB_RELEASE_TAG}" +SOURCE_BUNDLE_ASSET="SyntaxInterface-sources.tar.gz" function downloadFile() { @@ -52,6 +54,35 @@ Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually an fi } +function loadReleaseSourceBundle() { + local ARCHIVE_PATH="src/${SOURCE_BUNDLE_ASSET}" + local EXTRACT_DIR="src/.SyntaxInterface-sources" + local FILE_NAME + + if ! downloadFile "${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}" "${ARCHIVE_PATH}"; then + printf "Installing jaspSyntax failed because the SyntaxInterface source bundle is missing from release %s.\n\ +Set JASP_SOURCE_DIR to a matching jasp-desktop checkout, or publish %s together with the SyntaxInterface binaries.\n" "${GITHUB_RELEASE_TAG}" "${SOURCE_BUNDLE_ASSET}" + exit 1 + fi + + rm -rf "${EXTRACT_DIR}" + mkdir -p "${EXTRACT_DIR}" "src/json" + tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" + + if [ ! -f "${EXTRACT_DIR}/SyntaxInterface/syntaxbridge_interface.h" ]; then + echo "Installing jaspSyntax failed because ${SOURCE_BUNDLE_ASSET} does not contain SyntaxInterface/syntaxbridge_interface.h" + exit 1 + fi + + cp "${EXTRACT_DIR}/SyntaxInterface/syntaxbridge_interface.h" "${SYNTAXINTERFACE_HEADER_PATH}" + for FILE_NAME in ${JSON_FILES}; do + cp "${EXTRACT_DIR}/Common/json/${FILE_NAME}" "src/json/${FILE_NAME}" + done + + rm -rf "${EXTRACT_DIR}" "${ARCHIVE_PATH}" + SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}:SyntaxInterface/syntaxbridge_interface.h" +} + # ---------- Detect platform and architecture ---------- UNAME_S="$(uname -s)" UNAME_M="$(uname -m)" @@ -128,6 +159,7 @@ recorded_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date) platform=${UNAME_S} architecture=${ARCH} release_tag=${GITHUB_RELEASE_TAG} +release_repo=${GITHUB_RELEASE_REPO} header_path=${SYNTAXINTERFACE_HEADER_PATH} header_origin=${SYNTAXINTERFACE_HEADER_ORIGIN} binary_path=${SYNTAXINTERFACE_BINARY_PATH} @@ -155,11 +187,7 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" done -else - if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then - GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" - fi - +elif [ -n "${GITHUB_JASP_DESKTOP_FILES}" ]; then loadFile "${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface" "syntaxbridge_interface.h" SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface/syntaxbridge_interface.h" @@ -168,6 +196,8 @@ else for i in ${JSON_FILES}; do loadFile "${GITHUB_JASP_DESKTOP_FILES}/Common" "json/${i}" done +else + loadReleaseSourceBundle fi # ---------- Download pre-built library if needed ---------- diff --git a/configure.win b/configure.win index 8bae035..b0fdd17 100644 --- a/configure.win +++ b/configure.win @@ -8,7 +8,9 @@ set -e # ---------- GitHub Release configuration ---------- GITHUB_RELEASE_TAG="${JASPSYNTAX_RELEASE_TAG:-syntaxinterface-libs}" -GITHUB_RELEASE_URL="https://github.com/jasp-stats/jaspSyntax/releases/download/${GITHUB_RELEASE_TAG}" +GITHUB_RELEASE_REPO="${JASPSYNTAX_RELEASE_REPO:-jasp-stats/jaspSyntax}" +GITHUB_RELEASE_URL="https://github.com/${GITHUB_RELEASE_REPO}/releases/download/${GITHUB_RELEASE_TAG}" +SOURCE_BUNDLE_ASSET="SyntaxInterface-sources.tar.gz" function downloadFile() { @@ -46,6 +48,35 @@ Either download from \"https://github.com/jasp-stats/jasp-desktop/\" manually an fi } +function loadReleaseSourceBundle() { + local ARCHIVE_PATH="src/${SOURCE_BUNDLE_ASSET}" + local EXTRACT_DIR="src/.SyntaxInterface-sources" + local FILE_NAME + + if ! downloadFile "${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}" "${ARCHIVE_PATH}"; then + printf "Installing jaspSyntax failed because the SyntaxInterface source bundle is missing from release %s.\n\ +Set JASP_SOURCE_DIR to a matching jasp-desktop checkout, or publish %s together with the SyntaxInterface binaries.\n" "${GITHUB_RELEASE_TAG}" "${SOURCE_BUNDLE_ASSET}" + exit 1 + fi + + rm -rf "${EXTRACT_DIR}" + mkdir -p "${EXTRACT_DIR}" "src/json" + tar -xzf "${ARCHIVE_PATH}" -C "${EXTRACT_DIR}" + + if [ ! -f "${EXTRACT_DIR}/SyntaxInterface/syntaxbridge_interface.h" ]; then + echo "Installing jaspSyntax failed because ${SOURCE_BUNDLE_ASSET} does not contain SyntaxInterface/syntaxbridge_interface.h" + exit 1 + fi + + cp "${EXTRACT_DIR}/SyntaxInterface/syntaxbridge_interface.h" "${SYNTAXINTERFACE_HEADER_PATH}" + for FILE_NAME in ${JSON_FILES}; do + cp "${EXTRACT_DIR}/Common/json/${FILE_NAME}" "src/json/${FILE_NAME}" + done + + rm -rf "${EXTRACT_DIR}" "${ARCHIVE_PATH}" + SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}:SyntaxInterface/syntaxbridge_interface.h" +} + function verifyChecksum() { # verifyChecksum # Verifies the checksum of against SHA256SUMS from the GitHub Release. @@ -330,7 +361,7 @@ function isPlatformRuntimeDll() { DLL_LOWER=$(dllNameLower "$1") case "${DLL_LOWER}" in - api-ms-*.dll|ext-ms-*.dll|advapi32.dll|authz.dll|bcrypt.dll|cfgmgr32.dll|comctl32.dll|comdlg32.dll|crypt32.dll|cryptbase.dll|d3d*.dll|dnsapi.dll|dwmapi.dll|dwrite.dll|dxgi.dll|gdi32.dll|glu32.dll|imm32.dll|iphlpapi.dll|kernel32.dll|mpr.dll|msvcp140*.dll|msvcrt.dll|netapi32.dll|ntdll.dll|ole32.dll|oleacc.dll|oleaut32.dll|opengl32.dll|powrprof.dll|propsys.dll|psapi.dll|rpcrt4.dll|secur32.dll|setupapi.dll|shell32.dll|shlwapi.dll|user32.dll|userenv.dll|usp10.dll|ucrtbase.dll|uxtheme.dll|vcruntime140*.dll|version.dll|winhttp.dll|wininet.dll|winmm.dll|ws2_32.dll|wtsapi32.dll|xmllite.dll|r.dll|rgraphapp.dll|rblas.dll|rlapack.dll) + api-ms-*.dll|ext-ms-*.dll|advapi32.dll|authz.dll|bcrypt.dll|cfgmgr32.dll|clbcatq.dll|combase.dll|comctl32.dll|comdlg32.dll|coremessaging.dll|coreuicomponents.dll|crypt32.dll|cryptbase.dll|cryptsp.dll|d3d*.dll|dcomp.dll|dnsapi.dll|dwmapi.dll|dwrite.dll|dxgi.dll|gdi32.dll|gdi32full.dll|glu32.dll|icu*.dll|imm32.dll|iphlpapi.dll|kernel32.dll|kernelbase.dll|mpr.dll|msasn1.dll|msvcp140*.dll|msvcp_win.dll|msvcrt.dll|ncrypt.dll|ncryptsslp.dll|normaliz.dll|ntasn1.dll|ntdll.dll|ole32.dll|oleacc.dll|oleaut32.dll|opengl32.dll|powrprof.dll|profapi.dll|propsys.dll|psapi.dll|rpcrt4.dll|secur32.dll|setupapi.dll|shell32.dll|shlwapi.dll|sspicli.dll|textinputframework.dll|uiautomationcore.dll|user32.dll|userenv.dll|usp10.dll|ucrtbase.dll|uxtheme.dll|vcruntime140*.dll|version.dll|win32u.dll|windows.storage.dll|winhttp.dll|wininet.dll|winmm.dll|wldp.dll|ws2_32.dll|wtsapi32.dll|xmllite.dll|r.dll|rgraphapp.dll|rblas.dll|rlapack.dll) return 0 ;; esac @@ -588,6 +619,7 @@ recorded_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date) platform=Windows architecture=x86_64 release_tag=${GITHUB_RELEASE_TAG} +release_repo=${GITHUB_RELEASE_REPO} header_path=${SYNTAXINTERFACE_HEADER_PATH} header_origin=${SYNTAXINTERFACE_HEADER_ORIGIN} binary_path=${SYNTAXINTERFACE_BINARY_PATH} @@ -617,11 +649,7 @@ if [[ "${JASP_SOURCE_DIR}" ]]; then for i in ${JSON_FILES}; do cp "${JASP_SOURCE_DIR}/Common/json/${i}" "src/json/${i}" done -else - if [ "${GITHUB_JASP_DESKTOP_FILES}" = "" ]; then - GITHUB_JASP_DESKTOP_FILES="https://raw.githubusercontent.com/jasp-stats/jasp-desktop/refs/heads/development" - fi - +elif [ -n "${GITHUB_JASP_DESKTOP_FILES}" ]; then loadFile "${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface" "syntaxbridge_interface.h" SYNTAXINTERFACE_HEADER_ORIGIN="${GITHUB_JASP_DESKTOP_FILES}/SyntaxInterface/syntaxbridge_interface.h" @@ -630,6 +658,8 @@ else for i in ${JSON_FILES}; do loadFile "${GITHUB_JASP_DESKTOP_FILES}/Common" "json/${i}" done +else + loadReleaseSourceBundle fi # ---------- Download SyntaxInterface.dll ---------- From a3f09b214fc7d6769a51e3ba02970f7dff86b6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Barto=C5=A1?= Date: Fri, 15 May 2026 09:27:34 +0200 Subject: [PATCH 28/30] Record SyntaxInterface source provenance --- .github/workflows/build-syntaxinterface.yml | 36 +++++++++++++++++---- configure | 27 ++++++++++++++++ configure.win | 27 ++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index e4255e4..2a769b5 100644 --- a/.github/workflows/build-syntaxinterface.yml +++ b/.github/workflows/build-syntaxinterface.yml @@ -278,6 +278,14 @@ jobs: shell: bash run: | mkdir -p syntaxinterface-sources/SyntaxInterface syntaxinterface-sources/Common/json + { + echo "schema=1" + echo "jasp_desktop_ref=${{ env.JASP_DESKTOP_REF }}" + echo "jasp_desktop_sha=$(git -C jasp-desktop rev-parse HEAD)" + echo "jasp_syntax_sha=$(git rev-parse HEAD)" + echo "qt_version=${{ env.QT_VERSION }}" + echo "qt_submodules=${{ env.QT_SUBMODULES }}" + } > syntaxinterface-sources/BUILD_PROVENANCE cp jasp-desktop/SyntaxInterface/syntaxbridge_interface.h syntaxinterface-sources/SyntaxInterface/ for file in allocator.h assertions.h config.h forwards.h json.h json_features.h json_reader.cpp json_tool.h json_value.cpp json_valueiterator.inl json_writer.cpp reader.h value.h version.h writer.h; do cp "jasp-desktop/Common/json/${file}" "syntaxinterface-sources/Common/json/${file}" @@ -322,18 +330,32 @@ jobs: - name: Show checksums run: cat libs/SHA256SUMS + - name: Write release notes + shell: bash + run: | + tar -xOf libs/SyntaxInterface-sources.tar.gz ./BUILD_PROVENANCE > BUILD_PROVENANCE + JASP_DESKTOP_REF=$(grep -E '^jasp_desktop_ref=' BUILD_PROVENANCE | sed -E 's/^[^=]+=//') + JASP_DESKTOP_SHA=$(grep -E '^jasp_desktop_sha=' BUILD_PROVENANCE | sed -E 's/^[^=]+=//') + JASP_SYNTAX_SHA=$(grep -E '^jasp_syntax_sha=' BUILD_PROVENANCE | sed -E 's/^[^=]+=//') + QT_VERSION=$(grep -E '^qt_version=' BUILD_PROVENANCE | sed -E 's/^[^=]+=//') + QT_SUBMODULES=$(grep -E '^qt_submodules=' BUILD_PROVENANCE | sed -E 's/^[^=]+=//') + cat > release-body.md < Date: Fri, 15 May 2026 13:12:27 +0200 Subject: [PATCH 29/30] Fix macOS dylib export check: use nm -g instead of objdump -T On macOS, objdump -T exits with code 0 for Mach-O files but emits only a warning ("this operation is not currently supported for this file format") with no actual symbol data. The fallback to -t never fires, so the export check reports all symbols as missing. Add an explicit *.dylib case that calls nm -g directly, which works correctly on macOS for reading exported symbols. Co-Authored-By: Claude Sonnet 4.6 --- tools/check-syntaxinterface-symbols.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/check-syntaxinterface-symbols.sh b/tools/check-syntaxinterface-symbols.sh index 8d47b78..cd514ef 100644 --- a/tools/check-syntaxinterface-symbols.sh +++ b/tools/check-syntaxinterface-symbols.sh @@ -92,6 +92,11 @@ function write_exports() { *.dll|*.DLL) "${TOOL_PATH}" -p "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 ;; + *.dylib) + # macOS's objdump exits 0 for -T on Mach-O but emits only a + # warning with no symbol data. Use nm -g instead. + nm -g "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 + ;; *) "${TOOL_PATH}" -T "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 || "${TOOL_PATH}" -t "${DLL_PATH}" > "${OUTPUT_PATH}" 2>&1 From 0085f617bc6a98ac118a44650a78cf73656c8d29 Mon Sep 17 00:00:00 2001 From: boutinb Date: Fri, 15 May 2026 13:24:25 +0200 Subject: [PATCH 30/30] Fix NULL from unlist(list()) crashing normalizePath in .onLoad When no Qt dirs are in PATH, lapply returns an empty list and unlist(list()) returns NULL (not character(0)). normalizePath(NULL) then calls path.expand(NULL) which fails with "invalid 'path' argument". Wrap all three unlist(lapply(...)) calls in as.character() so the result is always a character vector, even when lapply produces an empty list. Co-Authored-By: Claude Sonnet 4.6 --- R/bridgeSubprocess.R | 6 +++--- R/zzz.R | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R index 9a8bc50..ed5703b 100644 --- a/R/bridgeSubprocess.R +++ b/R/bridgeSubprocess.R @@ -44,16 +44,16 @@ return(explicit[[1L]]) } - roots <- unique(unlist(lapply(pathEntries, .qtRootForPathEntry), use.names = FALSE)) + roots <- unique(as.character(unlist(lapply(pathEntries, .qtRootForPathEntry), use.names = FALSE))) roots <- roots[nzchar(roots)] msvcRoots <- roots[grepl("/msvc", roots, ignore.case = TRUE)] if (length(msvcRoots) > 0L) { return(msvcRoots[[1L]]) } - siblingMsvcRoots <- unique(unlist(lapply(dirname(roots), function(parent) { + siblingMsvcRoots <- unique(as.character(unlist(lapply(dirname(roots), function(parent) { Sys.glob(file.path(parent, "msvc*")) - }), use.names = FALSE)) + }), use.names = FALSE))) siblingMsvcRoots <- normalizePath(siblingMsvcRoots[nzchar(siblingMsvcRoots)], winslash = "/", mustWork = FALSE) siblingMsvcRoots <- siblingMsvcRoots[dir.exists(file.path(siblingMsvcRoots, "qml"))] if (length(siblingMsvcRoots) > 0L) { diff --git a/R/zzz.R b/R/zzz.R index b09557e..e369796 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -17,9 +17,9 @@ qtRoots <- unique(normalizePath(qtRoots[nzchar(qtRoots)], winslash = "/", mustWork = FALSE)) explicitRoots <- unique(normalizePath(explicitRoots[nzchar(explicitRoots)], winslash = "/", mustWork = FALSE)) msvcRoots <- qtRoots[grepl("/msvc", qtRoots, ignore.case = TRUE)] - siblingMsvcRoots <- unique(unlist(lapply(dirname(qtRoots), function(parent) { + siblingMsvcRoots <- unique(as.character(unlist(lapply(dirname(qtRoots), function(parent) { Sys.glob(file.path(parent, "msvc*")) - }), use.names = FALSE)) + }), use.names = FALSE))) siblingMsvcRoots <- normalizePath(siblingMsvcRoots[nzchar(siblingMsvcRoots)], winslash = "/", mustWork = FALSE) siblingMsvcRoots <- siblingMsvcRoots[dir.exists(file.path(siblingMsvcRoots, "bin"))]