diff --git a/.Rbuildignore b/.Rbuildignore index 112ad26..8ed19a6 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,3 +1,15 @@ +^renv$ +^renv\.lock$ +^\.Rprofile$ ^.*\.Rproj$ ^\.Rproj\.user$ ^\.travis\.yml$ +^\.github$ +^\.editorconfig$ +^demo\.R$ +^inst/libs$ +^src/Makevars$ +^src/Makevars\.win$ +^src/SyntaxInterface\.provenance$ +^src/syntaxbridge_interface\.h$ +^src/json$ 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/.github/workflows/build-syntaxinterface.yml b/.github/workflows/build-syntaxinterface.yml index 2b4c158..2a769b5 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,25 @@ 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 + { + 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}" + done + tar -czf SyntaxInterface-sources.tar.gz -C syntaxinterface-sources . + - name: Upload artifact uses: actions/upload-artifact@v7 with: @@ -279,6 +301,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 @@ -300,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 < -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) -Imports: Rcpp (>= 1.0.5) +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: + callr, + 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..5ffd922 100644 --- a/R/RcppExports.R +++ b/R/RcppExports.R @@ -5,6 +5,22 @@ cleanUp <- function() { invisible(.Call(`_jaspSyntax_cleanUp`)) } +shutdownNative <- function() { + invisible(.Call(`_jaspSyntax_shutdownNative`)) +} + +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..ed5703b --- /dev/null +++ b/R/bridgeSubprocess.R @@ -0,0 +1,231 @@ +.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() + ) +} + +.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(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(as.character(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) { + 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) { + stdoutPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".out") + stderrPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".err") + 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" + ), + error = function(e) { + structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") + } + ) + + output <- .readBridgeSubprocessOutput(stdoutPath, stderrPath) + outputSuffix <- .bridgeSubprocessOutputSuffix(output) + + if (inherits(result, "jaspSyntax_subprocess_error")) { + stop( + failureLabel, " failed: ", + result$message, + 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..632c6a7 --- /dev/null +++ b/R/options.R @@ -0,0 +1,1169 @@ +.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) +} + +.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) { + maskedText <- .qmlMaskNonCode(text) + blocks <- list() + + 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 + } + + openingBrace <- starts[[i]] + openingOffset - 1L + closingBrace <- .qmlMatchingBrace(maskedText, openingBrace) + if (!is.na(closingBrace)) { + blocks[[length(blocks) + 1L]] <- substring(text, starts[[i]], closingBrace) + } + } + + blocks +} + +.qmlStringProperty <- function(text, property, default = NULL) { + text <- .qmlMaskComments(text) + pattern <- paste0( + "(?s)(?:^|[\\{;\\n])\\s*", property, "\\s*:\\s*", + "(?:qsTr\\s*\\(\\s*)?\"((?:[^\"\\\\]|\\\\.)*)\"" + ) + match <- regexec(pattern, text, perl = TRUE) + value <- regmatches(text, match)[[1L]] + if (length(value) < 2L) { + return(default) + } + + .qmlUnescapeString(value[[2L]]) +} + +.qmlLogicalProperty <- function(text, property, default = NULL) { + 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) { + return(default) + } + + 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) + } + + as.character(x[[1L]]) +} + +.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` 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. +#' @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 <- .sourceModuleDescription(modulePath, byName = byName) + if (!is.null(description)) { + return(description) + } + + 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. +#' @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"`. +#' +#' @export +parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", + analysisName = NULL, version = "0", + preloadData = TRUE, fresh = TRUE, + output = c("list", "json"), + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = 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) + } + + 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() + } + + 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. +#' @param isolated Whether to run native QML parsing in a separate R process. +#' +#' @return A named list of parsed options. +#' +#' @export +readAnalysisOptionsFromQml <- function(modulePath, analysisName, options = NULL, + version = NULL, preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = 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, + isolated = isolated + ) + + .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, + isolated = TRUE) { + readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + preloadData = preloadData, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) +} + +#' 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, + isolated = TRUE) { + readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = NULL, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) +} + +.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, + isolated = FALSE + ) + + 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)) { + .loadDatasetForAnalysis(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 new file mode 100644 index 0000000..4826c69 --- /dev/null +++ b/R/readDatasetFromJaspFile.R @@ -0,0 +1,462 @@ +.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, + decode = TRUE, + normalize = TRUE) { + args <- .validateReadDatasetFromJaspFileArgs(jaspFilePath, dataSetIndex) + decode <- .validateFlag(decode, "decode") + normalize <- .validateFlag(normalize, "normalize") + jaspFilePath <- args$jaspFilePath + dataSetIndex <- args$dataSetIndex + + clearNativeState() + on.exit(clearNativeState(), add = TRUE) + + loadDataSetFromJaspFile(jaspFilePath) + + dataset <- readLoadedDataset(decode = decode, normalize = normalize) + + if (ncol(dataset) == 0L) { + 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) + attr(dataset, "jaspSyntax.jaspFileSignature") <- .jaspFileSignature(jaspFilePath) + attr(dataset, "jaspSyntax.jaspFileDataHash") <- .datasetHash(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) + 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(jaspFileSignature) || is.null(jaspFileDataHash)) { + 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) + } + + 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)) { + loadDataSetFromJaspFile(source$jaspFilePath) + return(invisible(source)) + } + + loadDataSet(dataset) + invisible(NULL) +} + +.normalizeBridgeColumn <- function(column) { + if (!is.factor(column)) { + return(column) + } + + as.character(column) +} + +.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 + ) + } + + callback +} + +.readBridgeDataset <- function(callbackName, what) { + dataset <- .bridgeCallback(callbackName, what)() + if (!is.data.frame(dataset)) { + stop( + "jaspSyntax bridge returned an unexpected ", what, " object", + call. = FALSE + ) + } + + dataset +} + +.prepareBridgeDataset <- function(dataset, decode = TRUE, normalize = TRUE) { + decode <- .validateFlag(decode, "decode") + normalize <- .validateFlag(normalize, "normalize") + + if (decode && ncol(dataset) > 0L) { + names(dataset) <- decodeColumnNames(names(dataset), strict = TRUE) + } + + if (normalize && ncol(dataset) > 0L) { + dataset[] <- lapply(dataset, .normalizeBridgeColumn) + } + + 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 an encoded bridge name cannot be decoded. +#' Raw/non-encoded names are returned unchanged. +#' +#' @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) + } + + 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) { + stop( + "jaspSyntax bridge did not expose `.decodeColNamesStrict`", + call. = FALSE + ) + } + return(columnNames) + } + + decodedNames <- columnNames + decodedNames[encoded] <- vapply(columnNames[encoded], 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) + decodedNames +} + +.isEncodedBridgeColumnName <- function(columnNames) { + grepl("^JaspColumn_[[:alnum:]_]+_Encoded$", columnNames) | + grepl("^jaspColumn[0-9]+$", columnNames) +} + +#' @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) + } + + 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) + + .loadDatasetForAnalysis(dataset) + runtimeOptions <- readAnalysisOptionsFromQml( + modulePath = modulePath, + analysisName = analysisName, + options = options, + fresh = TRUE, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = FALSE + ) + + 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) + dataset <- .runReadDatasetSubprocess( + args$jaspFilePath, + args$dataSetIndex, + decode = TRUE, + normalize = TRUE + ) + .attachJaspDatasetSource(dataset, args$jaspFilePath, args$dataSetIndex) +} 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..e369796 --- /dev/null +++ b/R/zzz.R @@ -0,0 +1,91 @@ +.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)) + msvcRoots <- qtRoots[grepl("/msvc", qtRoots, ignore.case = TRUE)] + siblingMsvcRoots <- unique(as.character(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"))] + + unique(c(explicitRoots, msvcRoots, siblingMsvcRoots, qtRoots)) +} + +.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) + 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) + } +} + +.onUnload <- function(libpath) { + try(shutdownNative(), silent = TRUE) +} 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 35ca21f..bbe2533 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,129 @@ 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_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" +SOURCE_BUNDLE_PROVENANCE_ORIGIN="" +SOURCE_BUNDLE_JASP_DESKTOP_REF="" +SOURCE_BUNDLE_JASP_DESKTOP_SHA="" +SOURCE_BUNDLE_JASP_SYNTAX_SHA="" +SOURCE_BUNDLE_QT_VERSION="" -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 sourceBundleProvenanceValue() { + local FILE_PATH="$1" + local KEY="$2" + + if [ -f "${FILE_PATH}" ]; then + grep -E "^${KEY}=" "${FILE_PATH}" | head -n 1 | sed -E 's/^[^=]+=//' + fi +} + + +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 +} + +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 + + if [ -f "${EXTRACT_DIR}/BUILD_PROVENANCE" ]; then + SOURCE_BUNDLE_PROVENANCE_ORIGIN="${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}:BUILD_PROVENANCE" + SOURCE_BUNDLE_JASP_DESKTOP_REF=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_desktop_ref") + SOURCE_BUNDLE_JASP_DESKTOP_SHA=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_desktop_sha") + SOURCE_BUNDLE_JASP_SYNTAX_SHA=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_syntax_sha") + SOURCE_BUNDLE_QT_VERSION=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "qt_version") + fi + + 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)" +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 @@ -36,55 +136,176 @@ 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:-}" +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}" +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} +release_repo=${GITHUB_RELEASE_REPO} +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}") +source_bundle_provenance_origin=${SOURCE_BUNDLE_PROVENANCE_ORIGIN} +source_bundle_jasp_desktop_ref=${SOURCE_BUNDLE_JASP_DESKTOP_REF} +source_bundle_jasp_desktop_sha=${SOURCE_BUNDLE_JASP_DESKTOP_SHA} +source_bundle_jasp_syntax_sha=${SOURCE_BUNDLE_JASP_SYNTAX_SHA} +source_bundle_qt_version=${SOURCE_BUNDLE_QT_VERSION} +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 ---------- + +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\"" -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" - fi + 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}" + done +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" 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 +else + loadReleaseSourceBundle fi +# ---------- Download pre-built library if needed ---------- -DLL_NAME="libSyntaxInterface.dylib" - -if [[ "${JASP_BUILD_DIR}" ]]; then +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 -elif [ ! -f "src/${DLL_NAME}" ]; then - if [ "${JASP_FILE_SERVER}" = "" ]; then - JASP_FILE_SERVER="https://static.jasp-stats.org/jaspSyntax/0.96.1/" - fi + SYNTAXINTERFACE_BINARY_ORIGIN="${JASP_BUILD_DIR}/SyntaxInterface/${DLL_NAME}" + cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${DLL_NAME}" +else - 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 + 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/${DLL_NAME}" "inst/libs/${DLL_NAME}" +cp "src/SyntaxInterface.provenance" "inst/libs/SyntaxInterface.provenance" 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..8f730fe 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,581 @@ set -e +# ---------- GitHub Release configuration ---------- +GITHUB_RELEASE_TAG="${JASPSYNTAX_RELEASE_TAG:-syntaxinterface-libs}" +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" +SOURCE_BUNDLE_PROVENANCE_ORIGIN="" +SOURCE_BUNDLE_JASP_DESKTOP_REF="" +SOURCE_BUNDLE_JASP_DESKTOP_SHA="" +SOURCE_BUNDLE_JASP_SYNTAX_SHA="" +SOURCE_BUNDLE_QT_VERSION="" -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 sourceBundleProvenanceValue() { + local FILE_PATH="$1" + local KEY="$2" + + if [ -f "${FILE_PATH}" ]; then + grep -E "^${KEY}=" "${FILE_PATH}" | head -n 1 | sed -E 's/^[^=]+=//' + fi +} + + +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.vars: options(configure.vars = c(jaspSyntax = \"JASP_SOURCE_DIR=''\"))\n" "$2" + exit 1 + 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 - printf "$2 loaded from $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 + + if [ -f "${EXTRACT_DIR}/BUILD_PROVENANCE" ]; then + SOURCE_BUNDLE_PROVENANCE_ORIGIN="${GITHUB_RELEASE_URL}/${SOURCE_BUNDLE_ASSET}:BUILD_PROVENANCE" + SOURCE_BUNDLE_JASP_DESKTOP_REF=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_desktop_ref") + SOURCE_BUNDLE_JASP_DESKTOP_SHA=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_desktop_sha") + SOURCE_BUNDLE_JASP_SYNTAX_SHA=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "jasp_syntax_sha") + SOURCE_BUNDLE_QT_VERSION=$(sourceBundleProvenanceValue "${EXTRACT_DIR}/BUILD_PROVENANCE" "qt_version") + fi + + 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. + 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 +} + +function findRuntimeCandidate() { + local FILE_NAME="$1" + shift + local SOURCE_DIR + local CANDIDATE + + for SOURCE_DIR in "$@"; do + 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 + + 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 + + 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 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|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 + + 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 + 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 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:" + for SEARCH_DIR in "$@"; do + if [ -n "${SEARCH_DIR}" ]; then + echo " ${SEARCH_DIR}" + fi + done + fi + + exit 1 +} + +function findQtPlatformPlugin() { + local PLUGIN_NAME="$1" + local SEARCH_DIR + local CANDIDATE + + for SEARCH_DIR in "${RUNTIME_SEARCH_DIRS[@]}"; do + if [ -z "${SEARCH_DIR}" ]; then + continue + fi + + CANDIDATE="${SEARCH_DIR}/../plugins/platforms/${PLUGIN_NAME}" + if [ -f "${CANDIDATE}" ]; then + echo "${CANDIDATE}" + return 0 + fi + + CANDIDATE="${SEARCH_DIR}/plugins/platforms/${PLUGIN_NAME}" + if [ -f "${CANDIDATE}" ]; then + echo "${CANDIDATE}" + return 0 + fi + done + + return 1 +} + +function requireQtPlatformPlugin() { + local PLUGIN_NAME="$1" + local PLUGIN_PATH + + mkdir -p "src/platforms" + if [ -f "src/platforms/${PLUGIN_NAME}" ]; then + return 0 + fi + + PLUGIN_PATH=$(findQtPlatformPlugin "${PLUGIN_NAME}" || true) + if [ -n "${PLUGIN_PATH}" ]; then + echo "Copying Qt platform plugin ${PLUGIN_NAME} from ${PLUGIN_PATH%/*}" + cp "${PLUGIN_PATH}" "src/platforms/${PLUGIN_NAME}" + return 0 + fi + + printf "Installing jaspSyntax failed because the Qt platform plugin %s could not be located.\n\ +For dynamic Qt builds set JASPSYNTAX_QT_DIR to the Qt bin directory or its parent, for example:\n\ +options(configure.vars = c(jaspSyntax = \"JASPSYNTAX_QT_DIR='/bin'\"))\n" "${PLUGIN_NAME}" + + exit 1 } if [ "${R_HOME}" ]; then @@ -37,67 +590,220 @@ 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:-}" +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=() +OBJDUMP_TOOL="" +SYNTAXINTERFACE_HEADER_PATH="src/syntaxbridge_interface.h" +SYNTAXINTERFACE_HEADER_ORIGIN="" +SYNTAXINTERFACE_BINARY_PATH="src/SyntaxInterface.dll" +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=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} +binary_origin=${SYNTAXINTERFACE_BINARY_ORIGIN} +header_sha256=$(fileSha256 "${SYNTAXINTERFACE_HEADER_PATH}") +binary_sha256=$(fileSha256 "${SYNTAXINTERFACE_BINARY_PATH}") +source_bundle_provenance_origin=${SOURCE_BUNDLE_PROVENANCE_ORIGIN} +source_bundle_jasp_desktop_ref=${SOURCE_BUNDLE_JASP_DESKTOP_REF} +source_bundle_jasp_desktop_sha=${SOURCE_BUNDLE_JASP_DESKTOP_SHA} +source_bundle_jasp_syntax_sha=${SOURCE_BUNDLE_JASP_SYNTAX_SHA} +source_bundle_qt_version=${SOURCE_BUNDLE_QT_VERSION} +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 ---------- + +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\"" -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" - fi + 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}" + done +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" 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 +else + loadReleaseSourceBundle fi +# ---------- Download SyntaxInterface.dll ---------- + SYNTAXINTERFACE_DLL="SyntaxInterface.dll" -if [[ "${JASP_BUILD_DIR}" ]]; then +SYNTAXINTERFACE_ASSET="SyntaxInterface-windows-x86_64.dll" + +if [[ "${JASPSYNTAX_LIB_DIR}" ]]; then + echo "Using JASPSYNTAX_LIB_DIR: ${JASPSYNTAX_LIB_DIR}" + 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 -elif [ ! -f "src/${SYNTAXINTERFACE_DLL}" ]; then - if [ "${JASP_FILE_SERVER}" = "" ]; then - JASP_FILE_SERVER="https://static.jasp-stats.org/jaspSyntax/1.0/" + 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 - - loadFile "${JASP_FILE_SERVER}" "${SYNTAXINTERFACE_DLL}" - - if [[ ! $(sha256sum src/${SYNTAXINTERFACE_DLL}) =~ "c9bcbd1e7a2f498e5934b1aa43919f4bc3cac5941992b259b3f338d6fadf8913" ]]; then - echo "Wrong checksum!" - rm src/${SYNTAXINTERFACE_DLL} + cp "${SYNTAXINTERFACE_BINARY_ORIGIN}" "src/${SYNTAXINTERFACE_DLL}" +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}" + echo " URL: ${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" exit 1 fi + verifyChecksum "src/${SYNTAXINTERFACE_DLL}" "${SYNTAXINTERFACE_ASSET}" + SYNTAXINTERFACE_BINARY_ORIGIN="${GITHUB_RELEASE_URL}/${SYNTAXINTERFACE_ASSET}" fi +# ---------- Download libR-InterfaceNoRInside.dll ---------- + RINTERFACE_DLL="libR-InterfaceNoRInside.dll" -if [[ "${JASP_BUILD_DIR}" ]]; then - 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 +RINTERFACE_ASSET="libR-InterfaceNoRInside-windows-x86_64.dll" - loadFile "${JASP_FILE_SERVER}" ${RINTERFACE_DLL} - if [[ ! $(sha256sum src/${RINTERFACE_DLL}) =~ "84917cd81836aaa894c6a98f70ab471eb98ff12c70173616a7ecc87f768ee341" ]]; then - echo "Wrong checksum!" - rm src/${RINTERFACE_DLL} +if [[ "${JASPSYNTAX_LIB_DIR}" ]] && [ -f "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" ]; then + cp "${JASPSYNTAX_LIB_DIR}/${RINTERFACE_DLL}" src/ +elif [[ "${JASP_BUILD_DIR}" ]]; then + 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/ +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}" + echo " URL: ${GITHUB_RELEASE_URL}/${RINTERFACE_ASSET}" + exit 1 + fi + verifyChecksum "src/${RINTERFACE_DLL}" "${RINTERFACE_ASSET}" +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}/R-Interface" +addRuntimeSearchDir "${JASP_BUILD_DIR}" +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 -#PKG_LIBS=${PKG_LIBS}\ "${R_HOME}/bin/x64/R.dll" +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 -PKG_LIBS="-lSyntaxInterface -llibR-InterfaceNoRInside" +PKG_LIBS="-l:${SYNTAXINTERFACE_DLL} -l:${RINTERFACE_DLL}" -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/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..4b0f3db --- /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 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.} +} +\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..9308ff2 --- /dev/null +++ b/man/parseQmlOptions.Rd @@ -0,0 +1,53 @@ +\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, + isolated = 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.} + +\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"}. +} +\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..195c908 --- /dev/null +++ b/man/readAnalysisOptionsFromQml.Rd @@ -0,0 +1,54 @@ +\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, + isolated = TRUE +) + +analysisOptionsFromQml( + modulePath, + analysisName, + options = NULL, + version = NULL, + preloadData = NULL, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = 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.} + +\item{isolated}{Whether to run native QML parsing in a separate R process.} +} +\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 new file mode 100644 index 0000000..9672640 --- /dev/null +++ b/man/readDatasetFromJaspFile.Rd @@ -0,0 +1,31 @@ +\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. + +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{ +dataset <- readDatasetFromJaspFile("path/to/analysis.jasp") +str(dataset) +} +} diff --git a/man/readDefaultAnalysisOptions.Rd b/man/readDefaultAnalysisOptions.Rd new file mode 100644 index 0000000..c2fca9e --- /dev/null +++ b/man/readDefaultAnalysisOptions.Rd @@ -0,0 +1,32 @@ +\name{readDefaultAnalysisOptions} +\alias{readDefaultAnalysisOptions} +\title{Read Default Analysis Options} +\usage{ +readDefaultAnalysisOptions( + modulePath, + analysisName, + fresh = TRUE, + includeMeta = TRUE, + includeTypeOptions = TRUE, + isolated = 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.} + +\item{isolated}{Whether to run native QML parsing in a separate R process.} +} +\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..fbc642f --- /dev/null +++ b/man/readModuleDescription.Rd @@ -0,0 +1,22 @@ +\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} 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/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/Makevars.in b/src/Makevars.in index 853cbfc..921df0e 100644 --- a/src/Makevars.in +++ b/src/Makevars.in @@ -1,6 +1,11 @@ -PKG_CPPFLAGS = @cppflags@ +PKG_CPPFLAGS = -I. @cppflags@ CXX_STD = CXX20 -PKG_LIBS += -L. @libflags@ +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; 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 diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index 3b6dd3c..f22fd56 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -19,6 +19,42 @@ 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() { +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 +162,10 @@ 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}, {"_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 new file mode 100644 index 0000000..fc1e341 --- /dev/null +++ b/src/install.libs.R @@ -0,0 +1,26 @@ +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)) +} + +shared_libraries <- Sys.glob(c("*.dll", "*.so", "*.dylib")) +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)) +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.") +} + +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 e942926..faa4d26 100644 --- a/src/syntaxfunctions.cpp +++ b/src/syntaxfunctions.cpp @@ -27,10 +27,77 @@ 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() { - syntaxBridgeCleanup(); + callBridgeOrStop("syntaxBridgeClearNativeState", []() { + syntaxBridgeClearNativeState(); + }); + callBridgeOrStop("syntaxBridgeCleanup", []() { + syntaxBridgeCleanup(); + }); +} + +// [[Rcpp::export]] +void shutdownNative() +{ + callBridgeOrStop("syntaxBridgeShutdown", []() { + syntaxBridgeShutdown(); + }); +} + +// [[Rcpp::export]] +void clearQmlFormsNative() +{ + callBridgeOrStop("syntaxBridgeClearQmlState", []() { + syntaxBridgeClearQmlState(); + }); +} + +// [[Rcpp::export]] +void clearDatasetStateNative() +{ + callBridgeOrStop("syntaxBridgeClearDataSetState", []() { + syntaxBridgeClearDataSetState(); + }); +} + +// [[Rcpp::export]] +void clearNativeStateNative() +{ + callBridgeOrStop("syntaxBridgeClearNativeState", []() { + syntaxBridgeClearNativeState(); + }); } // [[Rcpp::export]] @@ -62,7 +129,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); + }); } @@ -76,7 +145,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]] @@ -84,7 +155,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]] @@ -92,10 +165,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; @@ -111,10 +186,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(); @@ -122,7 +199,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; @@ -135,69 +212,100 @@ void loadDataSetFromJaspFile(String jaspFilePath) { std::string jaspFilePathStr = jaspFilePath.get_cstring(); - syntaxBridgeLoadDataSetFromJaspFile(jaspFilePathStr.c_str(), global_param_dbInMemory); + 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(); - std::string rawOptions = syntaxBridgeAnalysisOptionsFromJaspFile(jaspFilePathStr.c_str(), analysisNr); + Json::Value status = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeAnalysisOptionsFromJaspFileStatus", [&]() { + return syntaxBridgeAnalysisOptionsFromJaspFileStatus(jaspFilePathStr.c_str(), analysisNr); + }), + "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 + ); + } - Json::Value parsedOptions; - Json::Reader().parse(rawOptions, parsedOptions); + 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); } @@ -208,15 +316,20 @@ 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]] 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) 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 0000000..966c50b Binary files /dev/null and b/tests/testthat/fixtures/jasp-files/descriptives-sleep.jasp differ 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..062d62e --- /dev/null +++ b/tests/testthat/test-dataset-helpers.R @@ -0,0 +1,813 @@ +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("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( + ".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, isolated) { + replayArgs <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) + 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_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")) + 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 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("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) + + 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)) + loaderBody <- paste(deparse(body(jaspSyntax:::.bridgeSubprocessPackageLoader())), collapse = "\n") + expect_match( + loaderBody, + "pkgload::load_all", + fixed = TRUE + ) + expect_match( + loaderBody, + "file.path(buildDir, \"R-Interface\")", + fixed = TRUE + ) + expect_match( + loaderBody, + "jaspSyntax subprocess PATH head", + 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, "Imports"], "callr", fixed = TRUE) + 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_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) + 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..cbe17b6 --- /dev/null +++ b/tests/testthat/test-jasp-file-options.R @@ -0,0 +1,636 @@ +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, isolated) { + replayArgs <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) + 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_false(replayArgs$isolated) + 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, isolated) { + parseArgs <<- list( + qmlFile = qmlFile, + options = options, + moduleName = moduleName, + analysisName = analysisName, + version = version, + preloadData = preloadData, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) + 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_false(parseArgs$isolated) + 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, isolated) { + replayCalls[[length(replayCalls) + 1L]] <<- list( + modulePath = modulePath, + analysisName = analysisName, + options = options, + version = version, + fresh = fresh, + includeMeta = includeMeta, + includeTypeOptions = includeTypeOptions, + isolated = isolated + ) + 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_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")) +}) + +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..e2363c6 --- /dev/null +++ b/tests/testthat/test-module-options.R @@ -0,0 +1,203 @@ +context("module options") + +fixtureModule <- testthat::test_path("fixtures", "minimalModule") + +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.0") + 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("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) + + 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, + isolated = 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..cd514ef --- /dev/null +++ b/tools/check-syntaxinterface-symbols.sh @@ -0,0 +1,217 @@ +#!/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 + ;; + *.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 + ;; + 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}"