diff --git a/DESCRIPTION b/DESCRIPTION index 3fd70fb4..0b602a24 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: quarto Title: R Interface to 'Quarto' Markdown Publishing System -Version: 1.4.4.9024 +Version: 1.4.4.9025 Authors@R: c( person("JJ", "Allaire", , "jj@posit.co", role = "aut", comment = c(ORCID = "0000-0003-0174-9868")), @@ -23,6 +23,7 @@ Imports: htmltools, jsonlite, later, + lifecycle, processx, rlang, rmarkdown, diff --git a/NAMESPACE b/NAMESPACE index c26973a7..d96ee908 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,8 +2,11 @@ export(add_spin_preamble) export(check_newer_version) +export(find_project_root) +export(get_running_project_root) export(is_using_quarto) export(new_blog_post) +export(project_path) export(qmd_to_r_script) export(quarto_add_extension) export(quarto_available) @@ -47,6 +50,7 @@ importFrom(htmltools,div) importFrom(htmltools,span) importFrom(jsonlite,fromJSON) importFrom(later,later) +importFrom(lifecycle,deprecated) importFrom(processx,process) importFrom(processx,run) importFrom(rlang,caller_env) @@ -58,5 +62,6 @@ importFrom(tools,vignetteEngine) importFrom(utils,browseURL) importFrom(xfun,base64_encode) importFrom(xfun,env_option) +importFrom(xfun,normalize_path) importFrom(yaml,as.yaml) importFrom(yaml,write_yaml) diff --git a/NEWS.md b/NEWS.md index 75a535a3..ce5110ab 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # quarto (development version) +- Added `project_path()`, `get_running_project_root()`, and `find_project_root()` functions for Quarto-aware project path construction. These functions provide a consistent way to reference files relative to the project root, working both during Quarto rendering (using `QUARTO_PROJECT_ROOT` environment variables) and in interactive sessions (using intelligent project detection). The `project_path()` function is particularly useful in Quarto document cells where you need to reference data files or scripts from the project root regardless of the document's location in subdirectories (#180). + - `quarto_preview()` now explicitly returns the preview server URL (invisibly) and documents this behavior. This enables programmatic workflows such as taking screenshots with **webshot2** or passing the URL to other automation tools (thanks, @cwickham, #233). - Added NA value detection in YAML processing to prevent silent failures when passing R's `NA` values to Quarto CLI. Functions `as_yaml()` and `write_yaml()` now validate for NA values and provide clear error messages with actionable suggestions. This addresses issues where R's `NA` values get converted to YAML strings (like `.na.real`) that Quarto doesn't recognize as missing values, because they are not supported in YAML 1.2 spec. This is to help users handle missing data appropriately before passing to Quarto (#168). diff --git a/R/quarto-package.R b/R/quarto-package.R index 660855c6..bcf487c8 100644 --- a/R/quarto-package.R +++ b/R/quarto-package.R @@ -6,9 +6,11 @@ #' @importFrom cli cli_inform #' @importFrom htmltools div #' @importFrom htmltools span +#' @importFrom lifecycle deprecated #' @importFrom rlang caller_env #' @importFrom tools vignetteEngine #' @importFrom xfun base64_encode #' @importFrom xfun env_option +#' @importFrom xfun normalize_path ## usethis namespace: end NULL diff --git a/R/quarto.R b/R/quarto.R index a4ee8816..c0e7b8f5 100644 --- a/R/quarto.R +++ b/R/quarto.R @@ -21,7 +21,7 @@ quarto_path <- function(normalize = TRUE) { if (!normalize) { return(quarto_path) } - normalizePath(quarto_path, winslash = "/", mustWork = FALSE) + xfun::normalize_path(quarto_path) } get_quarto_path_env <- function() { @@ -257,7 +257,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) { return(FALSE) } - quarto_found <- normalizePath(quarto_found, mustWork = FALSE) + quarto_found <- xfun::normalize_path(quarto_found) same_config <- TRUE if (debug) { @@ -271,8 +271,8 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) { )) } - quarto_r_env <- normalizePath(get_quarto_path_env(), mustWork = FALSE) - quarto_system <- normalizePath(unname(Sys.which("quarto")), mustWork = FALSE) + quarto_r_env <- xfun::normalize_path(get_quarto_path_env()) + quarto_system <- xfun::normalize_path(unname(Sys.which("quarto"))) # quarto R package will use QUARTO_PATH env var with higher priority than latest version on path $PATH # and RStudio IDE does not use this environment variable if (!is.na(quarto_r_env) && identical(quarto_r_env, quarto_found)) { @@ -296,7 +296,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) { # RStudio IDE > Render button will use RSTUDIO_QUARTO env var with higher priority than latest version on path $PATH rstudio_env <- Sys.getenv("RSTUDIO_QUARTO", unset = NA) if (!is.na(rstudio_env)) { - rstudio_env <- normalizePath(rstudio_env, mustWork = FALSE) + rstudio_env <- xfun::normalize_path(rstudio_env) if (!identical(rstudio_env, quarto_found)) { same_config <- FALSE if (verbose) { diff --git a/R/use.R b/R/use.R index 6cb98637..fc4f882d 100644 --- a/R/use.R +++ b/R/use.R @@ -36,6 +36,10 @@ quarto_use_template <- function( ) { rlang::check_required(template) + if (!fs::dir_exists(dir)) { + fs::dir_create(dir) + } + if (!is_empty_dir(dir) && quarto_available("1.5.15")) { cli::cli_abort(c( "{.arg dir} must be an empty directory.", diff --git a/R/utils-projects.R b/R/utils-projects.R new file mode 100644 index 00000000..6449599a --- /dev/null +++ b/R/utils-projects.R @@ -0,0 +1,245 @@ +#' Get path relative to project root (Quarto-aware) +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' This function constructs file paths relative to the project root when +#' running in a Quarto context (using `QUARTO_PROJECT_ROOT` or `QUARTO_PROJECT_DIR` +#' environment variables), or falls back to intelligent project root detection +#' when not in a Quarto context. +#' +#' It is experimental and subject to change in future releases. The automatic +#' project root detection may not work reliably in all contexts, especially when +#' projects have complex directory structures or when running in non-standard +#' environments. For a more explicit and potentially more robust approach, +#' consider using [here::i_am()] to declare your project structure, +#' followed by [here::here()] for path construction. See examples for comparison. +#' +#' @details +#' The function uses the following fallback hierarchy to determine the project root: +#' +#' - Quarto environment variables set during Quarto commands (e.g., `quarto render`): +#' - `QUARTO_PROJECT_ROOT` environment variable (set by Quarto commands) +#' - `QUARTO_PROJECT_DIR` environment variable (alternative Quarto variable) +#' +#' - Fallback to intelligent project root detection using [xfun::proj_root()] for interactive sessions: +#' - `_quarto.yml` or `_quarto.yaml` (Quarto project files) +#' - `DESCRIPTION` file with `Package:` field (R package or Project) +#' - `.Rproj` files with `Version:` field (RStudio projects) +#' +#' Last fallback is the current working directory if no project root can be determined. +#' A warning is issued to alert users that behavior may differ between interactive use and Quarto rendering, +#' as in this case the computed path may be wrong. +#' +#' @section Use in Quarto document cells: +#' +#' This function is particularly useful in Quarto document cells where you want to +#' use a path relative to the project root dynamically during rendering. +#' +#' ````markdown +#' ```{r}`r ''` +#' # Get a csv path from data directory in the Quarto project root +#' data <- project_path("data", "my_data.csv") +#' ``` +#' ```` +#' +#' @param ... Character vectors of path components to be joined +#' @param root Project root directory. If `NULL` (default), automatic detection +#' is used following the hierarchy described above +#' @return A character vector of the normalized file path relative to the project root. +#' +#' @examples +#' \dontrun{ +#' # Create a dummy Quarto project structure for example +#' tmpdir <- tempfile("quarto_project") +#' dir.create(tmpdir) +#' quarto::quarto_create_project( +#' 'test project', type = 'blog', +#' dir = tmpdir, no_prompt = TRUE, quiet = TRUE +#' ) +#' project_dir <- file.path(tmpdir, "test project") +#' +#' # Simulate working within a blog post +#' xfun::in_dir( +#' dir = file.path(project_dir, "posts", "welcome"), { +#' +#' # Reference a data file from project root +#' # ../../data/my_data.csv +#' quarto::project_path("data", "my_data.csv") +#' +#' # Reference a script from project root +#' # ../../R/analysis.R +#' quarto::project_path("R", "analysis.R") +#' +#' # Explicitly specify root (overrides automatic detection) +#' # ../../data/file.csv +#' quarto::project_path("data", "file.csv", root = "../..") +#' +#' # Alternative approach using here::i_am() (potentially more robust) +#' # This approach requires you to declare where you are in the project: +#' if (requireNamespace("here", quietly = TRUE)) { +#' # Declare that this document is in the project root or subdirectory +#' here::i_am("posts/welcome/index.qmd") +#' +#' # Now here::here() will work reliably from the project root +#' here::here("data", "my_data.csv") +#' here::here("R", "analysis.R") +#' } +#' }) +#' +#' } +#' +#' @seealso +#' * [here::here()] and [here::i_am()] for a similar function that works with R projects +#' * [find_project_root()] to search for Quarto Project configuration in parents directories +#' * [get_running_project_root()] for detecting the project root in Quarto commands +#' * [xfun::from_root()] for the underlying path construction +#' * [xfun::proj_root()] for project root detection logic +#' +#' @export +project_path <- function(..., root = NULL) { + if (is.null(root)) { + # Try Quarto project environment variables first + quarto_root <- get_running_project_root() + + root <- if (!is.null(quarto_root) && nzchar(quarto_root)) { + quarto_root + } else { + # Try to find project root using xfun::proj_root() with extended rules + tryCatch( + { + # Create extended rules that include Quarto and VS Code project files + extended_rules <- rbind( + # this should be the same as Quarto environment variables + # which are only set when running Quarto commands + c("_quarto.yml", ""), # Quarto project config + c("_quarto.yaml", ""), # Alternative Quarto config + xfun::root_rules # Default rules (DESCRIPTION, .Rproj) + ) + + proj_root <- xfun::proj_root(rules = extended_rules) + if (!is.null(proj_root)) { + proj_root + } else { + cli::cli_warn(c( + "Failed to determine project root using {.fun xfun::proj_root}. Using current working directory.", + ">" = "This may lead to different behavior interactively vs running Quarto commands." + )) + getwd() + } + }, + error = function(e) { + # Fall back to working directory if proj_root() fails + cli::cli_warn(c( + "Failed to determine project root: {e$message}. Using current working directory as a fallback.", + ">" = "This may lead to different behavior interactively vs running Quarto commands." + )) + getwd() # Return the working directory + } + ) + } + } + + # Normalize the root path + root <- xfun::normalize_path(root) + # Use xfun::from_root for better path handling + path <- rlang::try_fetch( + xfun::from_root(..., root = root, error = TRUE), + error = function(e) { + rlang::abort( + c( + "Failed to construct project path", + ">" = "Ensure you are using valid path components." + ), + parent = e, + call = rlang::caller_env() + ) + } + ) + path +} + +#' Get the root of the currently running Quarto project +#' +#' @description +#' This function is to be used inside cells and will return the project root +#' when doing [quarto_render()] by detecting Quarto project environment variables. +#' +#' @details +#' Quarto sets `QUARTO_PROJECT_ROOT` and `QUARTO_PROJECT_DIR` environment +#' variables when executing commands within a Quarto project context (e.g., +#' `quarto render`, `quarto preview`). This function detects their presence. +#' +#' Note that this function will return `NULL` when running code interactively +#' in an IDE (even within a Quarto project directory), as these specific +#' environment variables are only set during Quarto command execution. +#' +#' @section Use in Quarto document cells: +#' +#' This function is particularly useful in Quarto document cells where you want to +#' get the project root path dynamically during rendering. Cell example: +#' +#' ````markdown +#' ```{r}`r ''` +#' # Get the project root path +#' project_root <- get_running_project_root() +#' ``` +#' ```` +#' +#' @return Character Quarto project root path from set environment variables. +#' +#' @seealso +#' * [find_project_root()] for finding the Quarto project root directory +#' * [project_path()] for constructing paths relative to the project root +#' @examples +#' \dontrun{ +#' get_running_project_root() +#' } +#' @export +get_running_project_root <- function() { + root <- Sys.getenv("QUARTO_PROJECT_ROOT", Sys.getenv("QUARTO_PROJECT_DIR")) + if (!nzchar(root)) { + return() + } + root +} + +#' Find the root of a Quarto project +#' +#' @description +#' This function checks if the current working directory is within a Quarto +#' project by looking for Quarto project files (`_quarto.yml` or `_quarto.yaml`). +#' Unlike [get_running_project_root()], this works both during rendering and +#' interactive sessions. +#' +#' @param path Character. Path to check for Quarto project files. Defaults to +#' current working directory. +#' +#' @return Character Path of the project root directory if found, or `NULL` +#' +#' @examplesIf quarto_available() +#' tmpdir <- tempfile() +#' dir.create(tmpdir) +#' find_project_root(tmpdir) +#' quarto_create_project("test-proj", type = "blog", dir = tmpdir, no_prompt = TRUE, quiet = TRUE) +#' blog_post_dir <- file.path(tmpdir, "test-proj", "posts", "welcome") +#' find_project_root(blog_post_dir) +#' +#' xfun::in_dir(blog_post_dir, { +#' # Check if current directory is a Quarto project or in one +#' !is.null(find_project_root()) +#' }) +#' +#' # clean up +#' unlink(tmpdir, recursive = TRUE) +#' +#' +#' @seealso [get_running_project_root()] for detecting active Quarto rendering +#' @export +find_project_root <- function(path = ".") { + quarto_rules <- rbind( + c("_quarto.yml", ""), + c("_quarto.yaml", "") + ) + xfun::proj_root(path = path, rules = quarto_rules) +} diff --git a/R/utils.R b/R/utils.R index 66643837..5f8abe85 100644 --- a/R/utils.R +++ b/R/utils.R @@ -106,7 +106,10 @@ has_internet <- function(host = "https://www.google.com") { is_empty_dir <- function(dir) { if (!dir.exists(dir)) { - return(FALSE) + rlang::warn( + "Directory {.path {dir}} does not exist. Assuming it is empty." + ) + return(TRUE) } files <- list.files(dir, all.files = TRUE, no.. = TRUE) length(files) == 0 diff --git a/_pkgdown.yml b/_pkgdown.yml index 3de383ea..5d26cb18 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -35,6 +35,9 @@ reference: contents: - quarto_create_project - new_blog_post + - project_path + - find_project_root + - get_running_project_root - title: "Configuration" desc: > diff --git a/man/figures/lifecycle-deprecated.svg b/man/figures/lifecycle-deprecated.svg new file mode 100644 index 00000000..b61c57c3 --- /dev/null +++ b/man/figures/lifecycle-deprecated.svg @@ -0,0 +1,21 @@ + + lifecycle: deprecated + + + + + + + + + + + + + + + lifecycle + + deprecated + + diff --git a/man/figures/lifecycle-experimental.svg b/man/figures/lifecycle-experimental.svg new file mode 100644 index 00000000..5d88fc2c --- /dev/null +++ b/man/figures/lifecycle-experimental.svg @@ -0,0 +1,21 @@ + + lifecycle: experimental + + + + + + + + + + + + + + + lifecycle + + experimental + + diff --git a/man/figures/lifecycle-stable.svg b/man/figures/lifecycle-stable.svg new file mode 100644 index 00000000..9bf21e76 --- /dev/null +++ b/man/figures/lifecycle-stable.svg @@ -0,0 +1,29 @@ + + lifecycle: stable + + + + + + + + + + + + + + + + lifecycle + + + + stable + + + diff --git a/man/figures/lifecycle-superseded.svg b/man/figures/lifecycle-superseded.svg new file mode 100644 index 00000000..db8d757f --- /dev/null +++ b/man/figures/lifecycle-superseded.svg @@ -0,0 +1,21 @@ + + lifecycle: superseded + + + + + + + + + + + + + + + lifecycle + + superseded + + diff --git a/man/find_project_root.Rd b/man/find_project_root.Rd new file mode 100644 index 00000000..d5df6e1c --- /dev/null +++ b/man/find_project_root.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-projects.R +\name{find_project_root} +\alias{find_project_root} +\title{Find the root of a Quarto project} +\usage{ +find_project_root(path = ".") +} +\arguments{ +\item{path}{Character. Path to check for Quarto project files. Defaults to +current working directory.} +} +\value{ +Character Path of the project root directory if found, or \code{NULL} +} +\description{ +This function checks if the current working directory is within a Quarto +project by looking for Quarto project files (\verb{_quarto.yml} or \verb{_quarto.yaml}). +Unlike \code{\link[=get_running_project_root]{get_running_project_root()}}, this works both during rendering and +interactive sessions. +} +\examples{ +\dontshow{if (quarto_available()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +tmpdir <- tempfile() +dir.create(tmpdir) +find_project_root(tmpdir) +quarto_create_project("test-proj", type = "blog", dir = tmpdir, no_prompt = TRUE, quiet = TRUE) +blog_post_dir <- file.path(tmpdir, "test-proj", "posts", "welcome") +find_project_root(blog_post_dir) + +xfun::in_dir(blog_post_dir, { + # Check if current directory is a Quarto project or in one + !is.null(find_project_root()) +}) + +# clean up +unlink(tmpdir, recursive = TRUE) + +\dontshow{\}) # examplesIf} +} +\seealso{ +\code{\link[=get_running_project_root]{get_running_project_root()}} for detecting active Quarto rendering +} diff --git a/man/get_running_project_root.Rd b/man/get_running_project_root.Rd new file mode 100644 index 00000000..486191e6 --- /dev/null +++ b/man/get_running_project_root.Rd @@ -0,0 +1,48 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-projects.R +\name{get_running_project_root} +\alias{get_running_project_root} +\title{Get the root of the currently running Quarto project} +\usage{ +get_running_project_root() +} +\value{ +Character Quarto project root path from set environment variables. +} +\description{ +This function is to be used inside cells and will return the project root +when doing \code{\link[=quarto_render]{quarto_render()}} by detecting Quarto project environment variables. +} +\details{ +Quarto sets \code{QUARTO_PROJECT_ROOT} and \code{QUARTO_PROJECT_DIR} environment +variables when executing commands within a Quarto project context (e.g., +\verb{quarto render}, \verb{quarto preview}). This function detects their presence. + +Note that this function will return \code{NULL} when running code interactively +in an IDE (even within a Quarto project directory), as these specific +environment variables are only set during Quarto command execution. +} +\section{Use in Quarto document cells}{ + + +This function is particularly useful in Quarto document cells where you want to +get the project root path dynamically during rendering. Cell example: + +\if{html}{\out{
}}\preformatted{```\{r\}`r ''` + # Get the project root path + project_root <- get_running_project_root() +``` +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +get_running_project_root() +} +} +\seealso{ +\itemize{ +\item \code{\link[=find_project_root]{find_project_root()}} for finding the Quarto project root directory +\item \code{\link[=project_path]{project_path()}} for constructing paths relative to the project root +} +} diff --git a/man/project_path.Rd b/man/project_path.Rd new file mode 100644 index 00000000..c6376bb7 --- /dev/null +++ b/man/project_path.Rd @@ -0,0 +1,116 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-projects.R +\name{project_path} +\alias{project_path} +\title{Get path relative to project root (Quarto-aware)} +\usage{ +project_path(..., root = NULL) +} +\arguments{ +\item{...}{Character vectors of path components to be joined} + +\item{root}{Project root directory. If \code{NULL} (default), automatic detection +is used following the hierarchy described above} +} +\value{ +A character vector of the normalized file path relative to the project root. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +This function constructs file paths relative to the project root when +running in a Quarto context (using \code{QUARTO_PROJECT_ROOT} or \code{QUARTO_PROJECT_DIR} +environment variables), or falls back to intelligent project root detection +when not in a Quarto context. + +It is experimental and subject to change in future releases. The automatic +project root detection may not work reliably in all contexts, especially when +projects have complex directory structures or when running in non-standard +environments. For a more explicit and potentially more robust approach, +consider using \code{\link[here:i_am]{here::i_am()}} to declare your project structure, +followed by \code{\link[here:here]{here::here()}} for path construction. See examples for comparison. +} +\details{ +The function uses the following fallback hierarchy to determine the project root: +\itemize{ +\item Quarto environment variables set during Quarto commands (e.g., \verb{quarto render}): +\itemize{ +\item \code{QUARTO_PROJECT_ROOT} environment variable (set by Quarto commands) +\item \code{QUARTO_PROJECT_DIR} environment variable (alternative Quarto variable) +} +\item Fallback to intelligent project root detection using \code{\link[xfun:proj_root]{xfun::proj_root()}} for interactive sessions: +\itemize{ +\item \verb{_quarto.yml} or \verb{_quarto.yaml} (Quarto project files) +\item \code{DESCRIPTION} file with \verb{Package:} field (R package or Project) +\item \code{.Rproj} files with \verb{Version:} field (RStudio projects) +} +} + +Last fallback is the current working directory if no project root can be determined. +A warning is issued to alert users that behavior may differ between interactive use and Quarto rendering, +as in this case the computed path may be wrong. +} +\section{Use in Quarto document cells}{ + + +This function is particularly useful in Quarto document cells where you want to +use a path relative to the project root dynamically during rendering. + +\if{html}{\out{
}}\preformatted{```\{r\}`r ''` + # Get a csv path from data directory in the Quarto project root + data <- project_path("data", "my_data.csv") +``` +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +# Create a dummy Quarto project structure for example +tmpdir <- tempfile("quarto_project") +dir.create(tmpdir) +quarto::quarto_create_project( + 'test project', type = 'blog', + dir = tmpdir, no_prompt = TRUE, quiet = TRUE +) +project_dir <- file.path(tmpdir, "test project") + +# Simulate working within a blog post +xfun::in_dir( + dir = file.path(project_dir, "posts", "welcome"), { + + # Reference a data file from project root + # ../../data/my_data.csv + quarto::project_path("data", "my_data.csv") + + # Reference a script from project root + # ../../R/analysis.R + quarto::project_path("R", "analysis.R") + + # Explicitly specify root (overrides automatic detection) + # ../../data/file.csv + quarto::project_path("data", "file.csv", root = "../..") + + # Alternative approach using here::i_am() (potentially more robust) + # This approach requires you to declare where you are in the project: + if (requireNamespace("here", quietly = TRUE)) { + # Declare that this document is in the project root or subdirectory + here::i_am("posts/welcome/index.qmd") + + # Now here::here() will work reliably from the project root + here::here("data", "my_data.csv") + here::here("R", "analysis.R") + } +}) + +} + +} +\seealso{ +\itemize{ +\item \code{\link[here:here]{here::here()}} and \code{\link[here:i_am]{here::i_am()}} for a similar function that works with R projects +\item \code{\link[=find_project_root]{find_project_root()}} to search for Quarto Project configuration in parents directories +\item \code{\link[=get_running_project_root]{get_running_project_root()}} for detecting the project root in Quarto commands +\item \code{\link[xfun:from_root]{xfun::from_root()}} for the underlying path construction +\item \code{\link[xfun:proj_root]{xfun::proj_root()}} for project root detection logic +} +} diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index bed71e2c..46bcb090 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -211,7 +211,7 @@ transform_quarto_cli_in_output <- function( if (dir_only) { quarto_found <- dirname(quarto_found) } - quarto_found_normalized <- normalizePath(quarto_found, mustWork = FALSE) + quarto_found_normalized <- xfun::normalize_path(quarto_found) # look for non-normalized path lines <- hide_path(lines, quarto_found) # look for normalized path diff --git a/tests/testthat/test-quarto.R b/tests/testthat/test-quarto.R index a2ad9a88..e4d7aab7 100644 --- a/tests/testthat/test-quarto.R +++ b/tests/testthat/test-quarto.R @@ -64,7 +64,7 @@ test_that("quarto CLI sitrep", { skip_if_no_quarto() skip_on_cran() local_reproducible_output(width = 1000) - dummy_quarto_path <- normalizePath("dummy", mustWork = FALSE) + dummy_quarto_path <- xfun::normalize_path("dummy") withr::with_envvar( list(QUARTO_PATH = dummy_quarto_path, RSTUDIO_QUARTO = NA), expect_snapshot( diff --git a/tests/testthat/test-utils-projects.R b/tests/testthat/test-utils-projects.R new file mode 100644 index 00000000..2bb01cbe --- /dev/null +++ b/tests/testthat/test-utils-projects.R @@ -0,0 +1,239 @@ +test_that("project_path() works with explicit root", { + temp_dir <- withr::local_tempdir() + withr::local_dir(temp_dir) + expect_identical( + project_path("data", "file.csv", root = temp_dir), + file.path("data", "file.csv") + ) + expect_identical( + project_path("outputs", "figures", "plot.png", root = temp_dir), + file.path("outputs", "figures", "plot.png") + ) + expect_identical(project_path(root = temp_dir), ".") +}) + +test_that("project_path() uses Quarto environment variables", { + temp_dir <- withr::local_tempdir() + withr::local_dir(temp_dir) + dir.create("project") + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = xfun::normalize_path(file.path( + temp_dir, + "project" + )), + QUARTO_PROJECT_DIR = NA + ), + expect_identical( + project_path("data", "file.csv"), + file.path("project", "data", "file.csv") + ) + ) + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = NA, + QUARTO_PROJECT_DIR = xfun::normalize_path(file.path(temp_dir, "project")) + ), + { + expect_identical( + get_running_project_root(), + xfun::normalize_path(file.path(temp_dir, "project")) + ) + + expect_identical( + project_path("data", "file.csv"), + file.path("project", "data", "file.csv") + ) + } + ) +}) + +test_that("project_path() detects Quarto project files", { + skip_if_no_quarto() + + project_dir <- local_quarto_project(type = "blog") + # simulate running from a post directory withing .qmd + post_dir <- file.path(project_dir, "posts", "welcome") + withr::local_dir(post_dir) + # data is at root of the project and path should be relative to that + expect_identical( + project_path("data", "file.csv"), + "../../data/file.csv" + ) +}) + +test_that("project_path() detects R package DESCRIPTION", { + temp_dir <- withr::local_tempdir() + desc_file <- file.path(temp_dir, "DESCRIPTION") + writeLines(c("Package: testpkg", "Version: 1.0.0"), desc_file) + withr::local_dir(temp_dir) + dir.create("reports") + withr::local_dir("reports") + expect_identical( + project_path("R", "functions.R"), + "../R/functions.R" + ) +}) + +test_that("project_path() detects .Rproj files", { + temp_dir <- withr::local_tempdir() + rproj_file <- file.path(temp_dir, "test.Rproj") + writeLines("Version: 1.0", rproj_file) + withr::local_dir(temp_dir) + dir.create("reports") + withr::local_dir("reports") + expect_identical( + project_path("analysis", "script.R"), + "../analysis/script.R" + ) +}) + +test_that("project_path() falls back to working directory with warning", { + temp_dir <- withr::local_tempdir() + withr::local_dir(temp_dir) + withr::local_envvar( + QUARTO_PROJECT_ROOT = "", + QUARTO_PROJECT_DIR = "" + ) + + expect_warning( + expect_identical( + project_path("data", "file.csv"), + file.path("data", "file.csv") + ), + "Failed to determine project root" + ) +}) + +test_that("project_path() handles xfun::proj_root() errors gracefully", { + temp_dir <- withr::local_tempdir() + + # Mock xfun::proj_root to throw an error + local_mocked_bindings( + proj_root = function(path = ".", rules = xfun::root_rules) { + stop("Test error") + }, + .package = "xfun" + ) + + withr::local_dir(temp_dir) + withr::local_envvar( + QUARTO_PROJECT_ROOT = "", + QUARTO_PROJECT_DIR = "" + ) + + expect_warning( + expect_identical( + project_path("data", "file.csv"), + file.path("data", "file.csv") + ), + "Failed to determine project root: Test error" + ) +}) + +test_that("get_running_project_root() detects environment variables", { + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = NA, + QUARTO_PROJECT_DIR = NA + ), + expect_null(get_running_project_root()) + ) + + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = "/some/path", + QUARTO_PROJECT_DIR = NA + ), + expect_identical(get_running_project_root(), "/some/path") + ) + + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = NA, + QUARTO_PROJECT_DIR = "/some/path" + ), + expect_identical(get_running_project_root(), "/some/path") + ) + + withr::with_envvar( + c( + QUARTO_PROJECT_ROOT = "/path1", + QUARTO_PROJECT_DIR = "/path2" + ), + expect_identical(get_running_project_root(), "/path1") + ) +}) + +test_that("find_project_root() detects Quarto project files", { + skip_if_no_quarto() + + temp_dir <- withr::local_tempdir() + expect_null(find_project_root(temp_dir)) + + project_dir <- local_quarto_project("test-project", type = "default") + expect_match( + find_project_root(project_dir), + "quarto-tests-project-.*/test-project$" + ) + + withr::with_dir( + project_dir, + expect_match( + find_project_root(), + "quarto-tests-project-.*/test-project$" + ) + ) +}) + +test_that("find_project_root() works with _quarto.yaml", { + temp_dir <- withr::local_tempdir() + + quarto_yaml <- file.path(temp_dir, "_quarto.yaml") + writeLines("project:\n type: default", quarto_yaml) + + expect_identical(find_project_root(temp_dir), xfun::normalize_path(temp_dir)) + + withr::local_dir(temp_dir) + expect_identical(find_project_root(), xfun::normalize_path(temp_dir)) + + dir.create("subfolder") + expect_identical( + find_project_root("subfolder"), + xfun::normalize_path(temp_dir) + ) + withr::local_dir("subfolder") + expect_identical(find_project_root(), xfun::normalize_path(temp_dir)) +}) + +test_that("project_path() prioritizes environment variables over file detection", { + skip_if_no_quarto() + + temp1 <- withr::local_tempdir() + temp2 <- withr::local_tempdir() + + quarto_create_project( + name = "test_project", + type = "default", + dir = temp1, + no_prompt = TRUE, + quiet = TRUE + ) + + withr::local_envvar( + QUARTO_PROJECT_ROOT = "", + QUARTO_PROJECT_DIR = "" + ) + project_dir <- file.path(temp1, "test_project") + dir.create(file.path(project_dir, "subfolder")) + withr::local_dir(file.path(project_dir, "subfolder")) + + expect_identical(project_path("test.txt"), "../test.txt") + + # With env var, should use env var instead + withr::local_envvar(QUARTO_PROJECT_ROOT = file.path(project_dir, "subfolder")) + expect_identical( + project_path("test.txt"), + "test.txt" + ) +})