Skip to content

Commit

Permalink
Allow empty exercise code chunks (#712)
Browse files Browse the repository at this point in the history
  • Loading branch information
gadenbuie committed Jul 7, 2022
1 parent 4757865 commit f378796
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 7 deletions.
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@

- Fixed exercise progress spinner being prematurely cleared (#384).

- Empty exercise chunks are now allowed. Please use caution: in very rare cases, knitr and learnr may not notice duplicate chunk labels when an exercise uses a duplicated label. Allowing empty exercise chunks improves the ergonomics when using [knitr's chunk option comments](https://yihui.org/en/2022/01/knitr-news/) (#712).

### Exercise Evaluation

- **Breaking Change:** If a `-code-check` chunk returns feedback for an exercise submission, the result of the exercise is no longer displayed for a correct answer (only the feedback is displayed). If both the result and feedback should be displayed, all checking should be performed in a `-check` chunk (i.e., don’t provide a `-code-check` chunk) (#403).
Expand Down
57 changes: 50 additions & 7 deletions R/knitr-hooks.R
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,59 @@ tutorial_knitr_options <- function() {
isTRUE(options[["exercise"]])
}

is_chunk_empty_or_mismatched_exercise <- function(options) {
label <- options$label

if (is.null(get_knitr_chunk(label))) {
return(TRUE)
}

chunk_opts <- attr(get_knitr_chunk(label), "chunk_opts")
if (!identical(options$exercise, chunk_opts$exercise)) {
# this looks like an exercise chunk, but knitr knows about a different
# chunk that isn't an exercise here. so there must be a problem (i.e. this
# is an empty chunk that didn't trigger knitr's duplicate chunk error).
# Note that we can't rely on knit_code$get() or options$code since they
# both report the code for the non-exercise chunk.
msg <- sprintf("Cannot create exercise '%s': duplicate chunk label", label)
rlang::abort(msg)
}

FALSE
}

# helper to find chunks that name a chunk as their setup chunk
exercise_chunks_for_setup_chunk <- function(label) {
label_query <- paste0("knitr::all_labels(exercise.setup == '", label, "')")
eval(parse(text = label_query))
}

ensure_knit_code_exists <- function(
current = knitr::opts_current$get(),
all = knitr::opts_chunk$get()
) {
label <- current$label

# Recreate chunk options: unique to chunk or different from default.
# Typically, we'd use `knit_code$get()` to find the chunk options defined
# directly on the chunk, but that returns `NULL` for empty chunks and
# doesn't include the chunk options. If we call this function in the options
# hooks, we have an opportunity to infer the chunk options.
chunk_opts <- current[setdiff(names(current), names(all))]
for (opt in names(all)) {
if (!identical(all[[opt]], current[[opt]])) {
chunk_opts[[opt]] <- current[[opt]]
}
}

n_lines <- current[["exercise.lines"]] %||% all[["exercise.lines"]] %||% 3L
code <- rep_len("", n_lines)

# https://github.com/yihui/knitr/blob/0f0c9c26/R/parser.R#L118
chunk <- setNames(list(structure(code, chunk_opts = chunk_opts)), label)
knitr::knit_code$set(chunk)
}

# helper to check for an exercise support chunk
is_exercise_support_chunk <- function(
options,
Expand Down Expand Up @@ -204,13 +251,9 @@ tutorial_knitr_options <- function() {
call. = FALSE)
}

# validate that the exercise chunk is 'defined'
if (exercise_chunk && is.null(get_knitr_chunk(options$label))) {
stop(
"The exercise chunk '", options$label, "' doesn't have anything inside of it. ",
"Try adding empty line(s) inside the code chunk.",
call. = FALSE
)
# validate or ensure that the exercise chunk is 'defined'
if (exercise_chunk && is_chunk_empty_or_mismatched_exercise(options)) {
ensure_knit_code_exists(options)
}

# if this is an exercise chunk then set various options
Expand Down
24 changes: 24 additions & 0 deletions tests/testthat/test-knitr-hooks.R
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,27 @@ test_that("Detection of chained setup cycle works", {
fixed = TRUE
)
})

test_that("Empty exercise code still creates an exercise", {
local_edition(3)

# empty and full exercises are the same, except that "full" has empty lines
# in the exercise chunk. They should result in identical exercises.
rmd_empty <- test_path("tutorials", "knitr-hooks_empty-exercise", "empty-exercise.Rmd")
rmd_full <- test_path("tutorials", "knitr-hooks_empty-exercise", "full-exercise.Rmd")

ex_empty <- get_tutorial_exercises(rmd_empty)
ex_full <- get_tutorial_exercises(rmd_full)

# One small difference that doesn't matter at all...
ex_full$empty$options$code <- NULL

expect_equal(ex_empty, ex_full)
})

test_that("Empty exercises with duplicate labels throw an error", {
local_edition(3)

rmd <- test_path("tutorials", "knitr-hooks_empty-exercise", "duplicate-label.Rmd")
expect_error(expect_message(get_tutorial_exercises(rmd), "duplicate"))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: Empty Exercise Code with Duplicate
output: learnr::tutorial
runtime: shinyrmd
---

```{r setup, include = FALSE}
library(learnr)
tutorial_options(exercise.lines = 4L)
knitr::opts_chunk$set(
echo = FALSE,
custom_chunk_opt = "default",
fig.path = "figures",
cache.path = "cache"
)
```

## Test

### First empty

This tutorial should fail to parse.

```{r empty}
```

### Empty

```{r empty, exercise = TRUE, custom_chunk_opt = "custom"}
```

```{r empty-hint}
# hint code
```

```{r empty-solution}
mtcars
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Empty Exercise Code
output: learnr::tutorial
runtime: shinyrmd
---

```{r setup, include = FALSE}
library(learnr)
tutorial_options(exercise.lines = 4L)
knitr::opts_chunk$set(
echo = FALSE,
custom_chunk_opt = "default",
fig.path = "figures",
cache.path = "cache"
)
```

## Test

### Empty

```{r empty, exercise = TRUE, custom_chunk_opt = "custom"}
```

```{r empty-hint}
# hint code
```

```{r empty-solution}
mtcars
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: Empty Exercise Code
output: learnr::tutorial
runtime: shinyrmd
---

```{r setup, include = FALSE}
library(learnr)
tutorial_options(exercise.lines = 4L)
knitr::opts_chunk$set(
echo = FALSE,
custom_chunk_opt = "default",
fig.path = "figures",
cache.path = "cache"
)
```

## Test

### Empty

```{r empty, exercise = TRUE, custom_chunk_opt = "custom"}
```

```{r empty-hint}
# hint code
```

```{r empty-solution}
mtcars
```

0 comments on commit f378796

Please sign in to comment.