Repository: extendr/rextendr
Pull request #441: feat: add `use_vscode()`
Author: login: albersonmiranda
Created: 2025-05-01T16:18:44Z | Merged: 2025-05-12T14:55:39Z

This PR:

1. Adds internal `is_vscode()` to detect if user is on VSCode;
2. Adds `settings.json` template with Rust-Analyzer configuration;
3. Adds `use_vscode()` that copies the template if `settings.json` does't exists or appends the Rust-Analyzer configuration if not present in the existent settings.json;

Next steps:

- check if `is_vscode()` works for all major OS:
  - [x] Debian based (tested on Ubuntu 24.04)
  - [x] Red-Hat (tested on Fedora Workstation 42)
  - [x] macOS (tested on Sequoia)
  - [x] Windows (tested on Windows 11)
- [x] add to `use_extendr()`?
- [x] add tests


Diff:
diff --git a/NAMESPACE b/NAMESPACE
index 06644682..b4af061f 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -24,6 +24,8 @@ export(to_toml)
 export(use_crate)
 export(use_extendr)
 export(use_msrv)
+export(use_positron)
+export(use_vscode)
 export(vendor_pkgs)
 export(write_license_note)
 importFrom(dplyr,mutate)
diff --git a/NEWS.md b/NEWS.md
index 8c1ee0d1..cb851478 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,8 @@
 # rextendr (development version)
 
+* Added `use_vscode()` and its alias `use_positron()` to create `.vscode/settings.json`, enhancing the `rextendr` experience in VSCode/Positron.
+  Additionally, `use_extendr()` now automatically calls `use_vscode()` when VSCode or Positron is detected as the IDE (#441).
+
 # rextendr 0.4.0
 
 * Adds [WebR](https://docs.r-wasm.org/webr/latest/) support out of the box for all extendr packages.
diff --git a/R/use_extendr.R b/R/use_extendr.R
index 46a5588e..44fb5c8b 100644
--- a/R/use_extendr.R
+++ b/R/use_extendr.R
@@ -209,6 +209,9 @@ use_extendr <- function(
     data = list(lib_name = lib_name)
   )
 
+  # create settings.json file
+  if (is_vscode() || is_positron()) use_vscode()
+
   # configure needs to be made executable
   # ignore for Windows
   if (.Platform[["OS.type"]] == "unix") {
diff --git a/R/use_vscode.R b/R/use_vscode.R
new file mode 100644
index 00000000..339c8446
--- /dev/null
+++ b/R/use_vscode.R
@@ -0,0 +1,92 @@
+#' Set up VS Code configuration for an rextendr project
+#'
+#' @description This creates a `.vscode` folder (if needed) and populates it with a
+#' `settings.json` template. If already exists, it will be updated to include
+#' the `rust-analyzer.linkedProjects` setting.
+#'
+#' @param quiet If `TRUE`, suppress messages.
+#' @param overwrite If `TRUE`, overwrite existing files.
+#' @details Rust-Analyzer VSCode extension looks for a `Cargo.toml` file in the
+#'   workspace root by default. This function creates a `.vscode` folder and
+#'   populates it with a `settings.json` file that sets the workspace root to
+#'   the `src` directory of the package. This allows you to open the package
+#'   directory in VSCode and have the Rust-Analyzer extension work correctly.
+#' @return `TRUE` (invisibly) if the settings file was created or updated.
+#' @export
+use_vscode <- function(quiet = FALSE, overwrite = FALSE) {
+  if (!dir.exists(".vscode")) {
+    dir.create(".vscode")
+  }
+
+  usethis::use_build_ignore(file.path(".vscode"))
+
+  settings_path <- file.path(".vscode", "settings.json")
+  rust_analyzer_path <- "${workspaceFolder}/src/rust/Cargo.toml"
+  files_associations <- list(
+    "Makevars.in" = "makefile",
+    "Makevars.win" = "makefile",
+    "configure" = "shellscript",
+    "configure.win" = "shellscript",
+    "cleanup" = "shellscript",
+    "cleanup.win" = "shellscript"
+  )
+
+  if (file.exists(settings_path) && !overwrite) {
+    if (!quiet) message("Updating existing .vscode/settings.json")
+
+    # settings.json accepts trailing commas before braces and brackets and {jsonlite} doesn't dig that
+    tryCatch({
+      settings <- jsonlite::read_json(settings_path)
+    }, error = function(e) {
+      if (grepl("parse error", e$message)) {
+        stop(
+          "Could not parse .vscode/settings.json. Do you have a trailing comma before braces or brackets?\n",
+          "Original error: : ", e$message
+        )
+      } else {
+        stop(e$message)
+      }
+    })
+
+    # checking and updating cargo.toml path for Rust-Analyzer
+    if (!"rust-analyzer.linkedProjects" %in% names(settings)) {
+      settings[["rust-analyzer.linkedProjects"]] <- list(rust_analyzer_path)
+    } else if (!rust_analyzer_path %in% settings[["rust-analyzer.linkedProjects"]]) {
+      settings[["rust-analyzer.linkedProjects"]] <- c(
+        settings[["rust-analyzer.linkedProjects"]],
+        rust_analyzer_path
+      )
+    }
+
+    # checking and updating files associations
+    if (!"files.associations" %in% names(settings)) {
+      settings[["files.associations"]] <- files_associations
+    } else {
+      current_assoc <- settings[["files.associations"]]
+      for (name in names(files_associations)) {
+        current_assoc[[name]] <- files_associations[[name]]
+      }
+      settings[["files.associations"]] <- current_assoc
+    }
+
+    jsonlite::write_json(
+      settings,
+      settings_path,
+      auto_unbox = TRUE,
+      pretty = TRUE
+    )
+  } else {
+    use_rextendr_template(
+      "settings.json",
+      save_as = settings_path,
+      quiet = quiet,
+      overwrite = overwrite
+    )
+  }
+
+  invisible(TRUE)
+}
+
+#' @rdname use_vscode
+#' @export
+use_positron <- use_vscode
diff --git a/R/utils.R b/R/utils.R
index 29c77659..309f5ad5 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -85,3 +85,19 @@ is_osx <- function() {
   }
   grepl("^darwin", R.version$os, ignore.case = TRUE)
 }
+
+is_vscode <- function() {
+  e <- Sys.getenv(c("VSCODE_PID", "VSCODE_CWD", "VSCODE_IPC_HOOK_CLI", "TERM_PROGRAM"))
+  if (nzchar(e["VSCODE_PID"]) || nzchar(e["VSCODE_CWD"]) || nzchar(e["VSCODE_IPC_HOOK_CLI"]) || tolower(e["TERM_PROGRAM"]) == "vscode") { # nolint
+    return(TRUE)
+  }
+  FALSE
+}
+
+is_positron <- function() {
+  e <- Sys.getenv(c("POSITRON", "POSITRON_LONG_VERSION", "POSITRON_MODE", "POSITRON_VERSION"))
+  if (nzchar(e["POSITRON"]) || nzchar(e["POSITRON_LONG_VERSION"]) || nzchar(e["POSITRON_MODE"]) || nzchar(e["POSITRON_VERSION"])) { # nolint
+    return(TRUE)
+  }
+  FALSE
+}
diff --git a/_pkgdown.yml b/_pkgdown.yml
index 8fc013b3..20d4c4e1 100644
--- a/_pkgdown.yml
+++ b/_pkgdown.yml
@@ -25,6 +25,7 @@ reference:
   - cran
   - vendor_pkgs
   - use_msrv
+  - use_vscode
 
 - title: Various utility functions
   contents:
diff --git a/inst/templates/settings.json b/inst/templates/settings.json
new file mode 100644
index 00000000..9b0fd0dc
--- /dev/null
+++ b/inst/templates/settings.json
@@ -0,0 +1,13 @@
+{
+  "rust-analyzer.linkedProjects": [
+    "${workspaceFolder}/src/rust/Cargo.toml"
+  ],
+  "files.associations": {
+    "Makevars.in": "makefile",
+    "Makevars.win": "makefile",
+    "configure": "shellscript",
+    "configure.win": "shellscript",
+    "cleanup": "shellscript",
+    "cleanup.win": "shellscript"
+  }
+}
\ No newline at end of file
diff --git a/man/use_vscode.Rd b/man/use_vscode.Rd
new file mode 100644
index 00000000..7ca53317
--- /dev/null
+++ b/man/use_vscode.Rd
@@ -0,0 +1,31 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/use_vscode.R
+\name{use_vscode}
+\alias{use_vscode}
+\alias{use_positron}
+\title{Set up VS Code configuration for an rextendr project}
+\usage{
+use_vscode(quiet = FALSE, overwrite = FALSE)
+
+use_positron(quiet = FALSE, overwrite = FALSE)
+}
+\arguments{
+\item{quiet}{If \code{TRUE}, suppress messages.}
+
+\item{overwrite}{If \code{TRUE}, overwrite existing files.}
+}
+\value{
+\code{TRUE} (invisibly) if the settings file was created or updated.
+}
+\description{
+This creates a \code{.vscode} folder (if needed) and populates it with a
+\code{settings.json} template. If already exists, it will be updated to include
+the \code{rust-analyzer.linkedProjects} setting.
+}
+\details{
+Rust-Analyzer VSCode extension looks for a \code{Cargo.toml} file in the
+workspace root by default. This function creates a \code{.vscode} folder and
+populates it with a \code{settings.json} file that sets the workspace root to
+the \code{src} directory of the package. This allows you to open the package
+directory in VSCode and have the Rust-Analyzer extension work correctly.
+}
diff --git a/tests/testthat/test-use_vscode.R b/tests/testthat/test-use_vscode.R
new file mode 100644
index 00000000..5cf75be1
--- /dev/null
+++ b/tests/testthat/test-use_vscode.R
@@ -0,0 +1,47 @@
+test_that("use_vscode creates .vscode and writes settings.json", {
+  skip_if_not_installed("usethis")
+
+  path <- local_package("testpkg")
+  withr::local_options(usethis.quiet = FALSE)
+
+  use_vscode(quiet = TRUE, overwrite = TRUE)
+
+  expect_true(dir.exists(".vscode"))
+  settings <- jsonlite::read_json(file.path(".vscode", "settings.json"))
+  expect_equal(
+    settings[["rust-analyzer.linkedProjects"]],
+    list("${workspaceFolder}/src/rust/Cargo.toml")
+  )
+})
+
+test_that("use_vscode is idempotent and does not duplicate entries", {
+  skip_if_not_installed("usethis")
+
+  path <- local_package("testpkg")
+  withr::local_options(usethis.quiet = FALSE)
+
+  use_vscode(quiet = TRUE, overwrite = TRUE)
+  use_vscode(quiet = TRUE, overwrite = FALSE)
+
+  settings <- jsonlite::read_json(file.path(".vscode", "settings.json"))
+  expect_equal(length(settings[["rust-analyzer.linkedProjects"]]), 1)
+})
+
+test_that("overwrite = TRUE replaces existing settings.json", {
+  skip_if_not_installed("usethis")
+
+  path <- local_package("testpkg")
+  withr::local_options(usethis.quiet = FALSE)
+
+  use_vscode(quiet = TRUE, overwrite = TRUE)
+  # corrupt the file
+  jsonlite::write_json(list(foo = "bar"), file.path(".vscode", "settings.json"), auto_unbox = TRUE)
+  use_vscode(quiet = TRUE, overwrite = TRUE)
+
+  settings2 <- jsonlite::read_json(file.path(".vscode", "settings.json"))
+  expect_null(settings2$foo)
+  expect_equal(
+    settings2[["rust-analyzer.linkedProjects"]],
+    list("${workspaceFolder}/src/rust/Cargo.toml")
+  )
+})
diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R
index 62b2ded1..0bcb80f7 100644
--- a/tests/testthat/test-utils.R
+++ b/tests/testthat/test-utils.R
@@ -32,3 +32,31 @@ test_that("`replace_na()` replaces with the correct value", {
   x[2] <- NA_integer_
   expect_identical(replace_na(x, -99L), c(1L, -99L, 3L, 4L, 5L))
 })
+
+test_that("is_vscode() returns FALSE when VSCode environment variables are not set", {
+  withr::with_envvar(
+    c(
+      VSCODE_PID = "",
+      VSCODE_CWD = "",
+      VSCODE_IPC_HOOK_CLI = "",
+      TERM_PROGRAM = ""
+    ),
+    {
+      expect_false(is_vscode())
+    }
+  )
+})
+
+test_that("is_vscode() returns TRUE when VSCode environment variables are set", {
+  withr::with_envvar(
+    c(
+      VSCODE_PID = "",
+      VSCODE_CWD = "",
+      VSCODE_IPC_HOOK_CLI = "",
+      TERM_PROGRAM = "vscode"
+    ),
+    {
+      expect_true(is_vscode())
+    }
+  )
+})


Reviews:
user: login: JosiahParry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:18:47Z
user: login: JosiahParry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:19:18Z
user: login: JosiahParry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:20:39Z
user: login: JosiahParry
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:21:45Z

Review comments:
diff_hunk: @@ -0,0 +1,92 @@
+#' Set up VS Code configuration for a rextendr project
path: R/use_vscode.R
user: login: JosiahParry
body: ```suggestion
#' Set up VS Code configuration for an rextendr project
```
created_at: 2025-05-12T14:18:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,92 @@
+#' Set up VS Code configuration for a rextendr project
+#'
+#' @description This creates a `.vscode` folder (if needed) and populates it with a
+#' `settings.json` template. If already exists, it will be updated to include
+#' the `rust-analyzer.linkedProjects` setting.
+#'
+#' @param quiet If `TRUE`, suppress messages.
+#' @param overwrite If `TRUE`, overwrite existing files.
+#' @details Rust-Analyzer VSCode extension looks for a cargo.toml file in the
path: R/use_vscode.R
user: login: JosiahParry
body: ```suggestion
#' @details Rust-Analyzer VSCode extension looks for a `Cargo.toml` file in the
```
created_at: 2025-05-12T14:19:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,3 +85,19 @@ is_osx <- function() {
   }
   grepl("^darwin", R.version$os, ignore.case = TRUE)
 }
+
+is_vscode <- function() {
+  e <- Sys.getenv(c("VSCODE_PID", "VSCODE_CWD", "VSCODE_IPC_HOOK_CLI", "TERM_PROGRAM"))
path: R/utils.R
user: login: JosiahParry
body: TIL!
created_at: 2025-05-12T14:20:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: CGMossa
created_at: 2025-05-01T17:34:09Z
body: I suggest adding an alias called use positron. 
author_association: MEMBER
user: login: CGMossa
created_at: 2025-05-02T08:57:35Z
body: this is already a very high quality PR, and could be merged as-is. But let me throw in a few suggestions:

- Use these to check `is_positron`:
```sh
POSITRON                                              1
POSITRON_LONG_VERSION                                 2025.05.0+103
POSITRON_MODE                                         desktop
POSITRON_VERSION                                      2025.05.0
```

- We need to `.Rbuildignore` `^\.vscode$` i.e.
```r
> usethis::use_build_ignore(".vscode")
✔ Adding "^\\.vscode$" to .Rbuildignore
```

There are certain associations that are unique to R and its obsession with _portable make_, that vscode/positron doesn't necessarily understand. For syntax highlighting (and extension compatibility) one may add
```json
  "files.associations": {
    "Makevars.in": "makefile",
    "Makevars.win": "makefile",
    "configure": "shellscript",
    "configure.win": "shellscript",
    "cleanup": "shellscript",
    "cleanup.win": "shellscript"
  },
```

Let me know what you think. 
author_association: MEMBER
user: login: albersonmiranda
created_at: 2025-05-12T01:25:39Z
body: > this is already a very high quality PR, and could be merged as-is. But let me throw in a few suggestions:
> 
> * Use these to check `is_positron`:
> 
> ```shell
> POSITRON                                              1
> POSITRON_LONG_VERSION                                 2025.05.0+103
> POSITRON_MODE                                         desktop
> POSITRON_VERSION                                      2025.05.0
> ```
> 
>  * We need to `.Rbuildignore` `^\.vscode$` i.e.
> 
> ```r
> > usethis::use_build_ignore(".vscode")
> ✔ Adding "^\\.vscode$" to .Rbuildignore
> ```
> 
> There are certain associations that are unique to R and its obsession with _portable make_, that vscode/positron doesn't necessarily understand. For syntax highlighting (and extension compatibility) one may add
> 
> ```json
>   "files.associations": {
>     "Makevars.in": "makefile",
>     "Makevars.win": "makefile",
>     "configure": "shellscript",
>     "configure.win": "shellscript",
>     "cleanup": "shellscript",
>     "cleanup.win": "shellscript"
>   },
> ```
> 
> Let me know what you think.

Great additions, @CGMossa !
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-12T14:56:19Z
body: ## [Codecov](https://app.codecov.io/gh/extendr/rextendr/pull/441?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) Report
Attention: Patch coverage is `75.80645%` with `15 lines` in your changes missing coverage. Please review.
> Project coverage is 82.88%. Comparing base [(`12711a3`)](https://app.codecov.io/gh/extendr/rextendr/commit/12711a318215bad259e8c1061552202b014fe908?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) to head [(`4ad1902`)](https://app.codecov.io/gh/extendr/rextendr/commit/4ad1902eef5e6555e296b98032f77e7beb09525c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/extendr/rextendr/pull/441?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) | Patch % | Lines |
|---|---|---|
| [R/use\_vscode.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Fuse_vscode.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91c2VfdnNjb2RlLlI=) | 75.47% | [13 Missing :warning: ](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) |
| [R/use\_extendr.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Fuse_extendr.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91c2VfZXh0ZW5kci5S) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) |
| [R/utils.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Futils.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91dGlscy5S) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) |

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/extendr/rextendr/pull/441/graphs/tree.svg?width=650&height=150&src=pr&token=5H6ID0LAO7&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr)](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr)

| [Files with missing lines](https://app.codecov.io/gh/extendr/rextendr/pull/441?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr) | Coverage Δ | |
|---|---|---|
| [R/use\_extendr.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Fuse_extendr.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91c2VfZXh0ZW5kci5S) | `98.63% <0.00%> (-0.46%)` | :arrow_down: |
| [R/utils.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Futils.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91dGlscy5S) | `93.93% <87.50%> (-2.07%)` | :arrow_down: |
| [R/use\_vscode.R](https://app.codecov.io/gh/extendr/rextendr/pull/441?src=pr&el=tree&filepath=R%2Fuse_vscode.R&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=extendr#diff-Ui91c2VfdnNjb2RlLlI=) | `75.47% <75.47%> (ø)` | |

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE