Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow empty exercise code chunks #712

Merged
merged 4 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
```