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

Detect blanks with exercise.blanks opt, and add parse checking #547

Merged
merged 67 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
efa9777
Add handler for underscores to render_exercise()
rossellhayes Jun 25, 2021
8f1323c
Import and modify code from `gradethis` that serves a similar function
rossellhayes Jun 26, 2021
8ed3ae4
Add tests with unparsable code
rossellhayes Jun 27, 2021
0c8a229
Add test for *parsable* code with ___
rossellhayes Jun 29, 2021
b09a6d4
Fix typo
rossellhayes Jun 29, 2021
05d5e57
Fix formatting
rossellhayes Jun 29, 2021
36c838a
Run parse check before global setup
rossellhayes Jun 29, 2021
0ebebfe
Add chunk option `exercise.parse.error.check` which disables parsabil…
rossellhayes Jun 29, 2021
e0c3fe1
Implement parse checking and blank checking as exercise.checker funct…
rossellhayes Jul 2, 2021
3931555
Remove support for `-parse-check` chunk
rossellhayes Jul 2, 2021
e6ccad9
Add isFALSE() as an internal function for R < 3.5
rossellhayes Jul 6, 2021
b40c961
Ensure blank check and parse check options can be set in global setup
rossellhayes Jul 6, 2021
5f5f0fb
Refactor blank checking
rossellhayes Jul 6, 2021
4c023ec
Use `as.character(user_code)` in `check_blanks()` to avoid issue with…
rossellhayes Jul 6, 2021
45ba98a
Merge branch 'master' into 'rossellhayes/catch-underscore'
gadenbuie Jul 9, 2021
0ddd0d2
Revert "Use `as.character(user_code)` in `check_blanks()` to avoid is…
gadenbuie Jul 9, 2021
40685c3
Add support for i18n in blanks error message
rossellhayes Jul 7, 2021
551dfe9
Refactor parse check
rossellhayes Jul 12, 2021
de7198c
Update tests for i18n error messages
rossellhayes Jul 12, 2021
735038b
Merge branch 'catch-underscore' of https://github.com/rossellhayes/le…
rossellhayes Jul 12, 2021
c4ef322
Simplify `try_checker()` calls
rossellhayes Jul 12, 2021
d765b69
Refactor `i18n_combine_words()` to not use `glue`
rossellhayes Jul 12, 2021
ff4c9a0
Fix arguments to `knitr::combine_words()`
rossellhayes Jul 12, 2021
c55d8ab
Include error message in parse check results
rossellhayes Jul 12, 2021
07a57e5
Add support for Oxford commas in `i18n_combine_words()` in languages …
rossellhayes Jul 13, 2021
a8f0895
Namespace `isTruthy()`
rossellhayes Jul 13, 2021
237cbce
Use `sprintf()` in `i18n_combine_words()`
rossellhayes Jul 13, 2021
d2ab09e
Merge branch 'catch-underscore' of https://github.com/rossellhayes/le…
rossellhayes Jul 13, 2021
afe015d
Make `name = "exercise.checker"` default in `try_checker()`
rossellhayes Jul 13, 2021
bde61e5
Restructure flow of blank check results
gadenbuie Jul 15, 2021
a07db75
Use html code style around blanks
gadenbuie Jul 15, 2021
fd4b19a
Return parse error result as regular html output, rather than in an e…
gadenbuie Jul 15, 2021
65829c0
Test if `blank_feedback` exists directly before `exercise$check`; if …
rossellhayes Jul 16, 2021
1fadf91
Update `check_blanks` test
rossellhayes Jul 16, 2021
d73b52b
Restyle error_message_html() as literal code by default
gadenbuie Jul 16, 2021
bcf328d
Include error message output in error checker feedback
gadenbuie Jul 16, 2021
44a6853
Move `checker_feedback <- NULL` immediately before it is possibly set…
rossellhayes Jul 16, 2021
84efe93
Merge branch 'catch-underscore' of https://github.com/rossellhayes/le…
rossellhayes Jul 16, 2021
1dd4f11
Use alert style for timeout error
rossellhayes Jul 16, 2021
9c5e618
If `exercise.blanks = TRUE`, use underscores
rossellhayes Jul 16, 2021
fde70e8
`i18n_set_language_option()` sets env var to translate R messages
rossellhayes Jul 20, 2021
d5a2881
Merge commit 'f260faa2292f4342da741714fef4a8bcb9776938'
rossellhayes Jul 28, 2021
53d6f3f
Merge master into catch-underscore
rossellhayes Aug 4, 2021
39c0fe4
Refactor and add internal documention about exercise blanks checking
gadenbuie Aug 12, 2021
517ac18
Merge branch 'master' into 'rossellhayes/catch-underscore'
gadenbuie Aug 12, 2021
73e4d71
Remove temporary translations
rossellhayes Aug 12, 2021
bdf29c6
refactor: `exercise_should_check_parsability()`
gadenbuie Aug 19, 2021
2117659
Merged upstream/master into rossellhayes-catch-underscore
gadenbuie Aug 19, 2021
4766531
Use HTML instead of markdown in text.unparsable translation
gadenbuie Aug 19, 2021
1f2d393
Don't escape HTML in the translation JSON dependency
gadenbuie Aug 19, 2021
5014bec
minor style adjustment
gadenbuie Aug 19, 2021
dbd02ce
Always check R code parsability prior to evaluation
gadenbuie Aug 19, 2021
5814717
Rename exercise checking functions
gadenbuie Aug 19, 2021
1148e33
Update `exercise_check_code_*()` functions to use the `exercise` obje…
gadenbuie Aug 20, 2021
60fbd45
Test `exercise_check_code_*()` functions directly in tests
gadenbuie Aug 20, 2021
fa6c547
Add exercise.blanks argument to `tutorial_options()`
gadenbuie Aug 20, 2021
1c79d27
Document `exercise.blanks` in learnr webpage
gadenbuie Aug 20, 2021
bfbf7bb
Rebuild docs/
gadenbuie Aug 20, 2021
d3c4972
Build docs (GitHub Actions)
gadenbuie Aug 20, 2021
183f07d
Fix typo
gadenbuie Aug 23, 2021
5d60fed
`version` must be length-1 or NULL in `mock_exercise()`
gadenbuie Aug 23, 2021
4ff1d59
Return nothing if not exercise result
gadenbuie Aug 24, 2021
908b0cb
Return early after code eval if blanks were detected
gadenbuie Aug 24, 2021
36712fa
Move `exercise_get_blanks_pattern()` closer to `exercise_check_code_f…
gadenbuie Aug 24, 2021
f382a18
Require knitr >= 1.31
gadenbuie Aug 24, 2021
45f4c94
Add NEWS for #547
gadenbuie Aug 24, 2021
3b0466e
Merge branch 'master' into 'rossellhayes/catch-underscore'
gadenbuie Aug 24, 2021
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: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Imports:
htmltools (>= 0.3.5),
htmlwidgets,
jsonlite,
knitr (>= 1.14),
knitr (>= 1.31),
markdown,
parallel,
promises,
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ learnr (development version)
* Messages generated by R during exercises are now translated to match the tutorial language, if translations are available. ([#558](https://github.com/rstudio/learnr/pull/558))
* Tutorial authors can now access the current state of the user's progress in a tutorial with `get_tutorial_state()` or get information about the current tutorial with `get_tutorial_info()`. ([#562](https://github.com/rstudio/learnr/pull/562))
* Tutorial state is now returned by `get_tutorial_state()` in order of appearance in the tutorial. The full list of exercises and questions is included as `items` in the list returned by `get_tutorial_info()`. ([#570](https://github.com/rstudio/learnr/issues/570), [#571](https://github.com/rstudio/learnr/pull/571))
* Users are now warned if their submission contains blanks they are expected to fill in. The default blank pattern is three or more underscores, e.g. `____`. The pattern for blanks can be set with the `exercise.blanks` chunk or tutorial option. ([#547](https://github.com/rstudio/learnr/pull/547))

## Minor new features and improvements

Expand Down Expand Up @@ -51,6 +52,7 @@ learnr (development version)
* Correct/incorrect question markers are now configurable via CSS. You can change or style these markers using the `.tutorial-question .question-final .correct::before` and `.tutorial-qusetion .question-final .incorrect::before` selectors. A new helper function, `finalize_question()`, can be used to apply the `.question-final` class to custom learnr questions. ([#531](https://github.com/rstudio/learnr/pull/531))
* `options()` and environment variables are now reset after rendering exercises so changes made by user input or checking code cannot affect other exercises. ([#542](https://github.com/rstudio/learnr/pull/542))
* Exercise checking is now conducted in the same temporary directory where exercises are evaluated. ([#544](https://github.com/rstudio/learnr/pull/544/))
* User submissions for R code exercises are now checked for parsing errors prior to any other checks. If the submitted code is unparsable, a friendly error feedback message is returned and no further evaluation or checking is performed. ([#547](https://github.com/rstudio/learnr/pull/547))

## Bug fixes

Expand Down
199 changes: 151 additions & 48 deletions R/exercise.R
Original file line number Diff line number Diff line change
Expand Up @@ -333,34 +333,60 @@ evaluate_exercise <- function(

i18n_set_language_option(exercise$tutorial$language)

# return immediately and clear visible results
# do not consider this an exercise submission
if (!nzchar(exercise$code)) {
# " " since html_output needs to pass a req()
# return immediately and clear visible results - do not consider this an
# exercise submission but return " " since html_output needs to pass a req()
return(exercise_result(html_output = " "))
}

if (evaluate_global_setup) {
eval(parse(text = exercise$global_setup), envir = envir)
}

checker_feedback <- NULL
# Check the code pre-evaluation, if code_check is provided
# Check if user code has unfilled blanks ----------------------------------
# If blanks are detected we store the feedback for use at the standard
# feedback-returning exit points, but still try to render the user code since
# the output may still be valid even if the user needs to fill in some blanks.
# Importantly, `blank_feedback` is `NULL` if no blanks are detected.
blank_feedback <- exercise_check_code_for_blanks(exercise)

here <- rlang::current_env()
return_if_exercise_result <- function(res) {
# early return if we've received an exercise result, but also replace the
# feedback with the blank feedback if any blanks were found
if (!is_exercise_result(res)) {
return()
}

if (!is.null(blank_feedback$feedback)) {
res$feedback <- blank_feedback$feedback
}

rlang::return_from(here, res)
}

# Check that user R code is parsable -------------------------------------
if (identical(tolower(exercise$engine), "r")) {
return_if_exercise_result(
exercise_check_code_is_parsable(exercise)
)
}

# Code check, pre-evaluation ---------------------------------------------
if (nzchar(exercise$code_check)) {
checker_feedback <- try_checker(
exercise, "exercise.checker",
check_code = exercise$code_check,
envir_result = NULL,
evaluate_result = NULL,
envir_prep = duplicate_env(envir),
last_value = NULL,
engine = exercise$engine
# treat the blank check like a code check, if blanks were detected
return_if_exercise_result(blank_feedback)

return_if_exercise_result(
try_checker(
exercise,
check_code = exercise$code_check,
envir_prep = duplicate_env(envir)
)
)
if (is_exercise_result(checker_feedback)) {
return(checker_feedback)
}
}

# Render user code --------------------------------------------------------
# Setup a temporary directory for rendering the exercise
exercise_dir <- withr::local_tempdir(pattern = "lrn-ex")

Expand All @@ -374,64 +400,72 @@ evaluate_exercise <- function(
rmd_results <- tryCatch(
render_exercise(exercise, envir),
error = function(err_render) {
error_feedback <- NULL
if (nzchar(exercise$error_check)) {
# Error check -------------------------------------------------------
# Check the error thrown by the submitted code when there's error
# checking: the exercise could be to throw an error!
checker_feedback <- try_checker(
exercise, "exercise.checker",
error_feedback <- try_checker(
exercise,
check_code = exercise$error_check,
envir_result = err_render$envir_result,
evaluate_result = err_render$evaluate_result,
envir_prep = err_render$envir_prep,
last_value = err_render,
engine = exercise$engine
last_value = err_render
)
if (is_exercise_result(checker_feedback)) {
return(checker_feedback)
}
}
exercise_result_error(err_render$error_message)
exercise_result_error(err_render$error_message, error_feedback$feedback)
}
)

if (is_exercise_result(rmd_results)) {
return(rmd_results)
}
return_if_exercise_result(rmd_results)

# Run the checker post-evaluation (for checking code results)
if (nzchar(exercise$check)) {
checker_feedback <- try_checker(
exercise, "exercise.checker",
check_code = exercise$check,
envir_result = rmd_results$envir_result,
evaluate_result = rmd_results$evaluate_result,
envir_prep = rmd_results$envir_prep,
last_value = rmd_results$last_value,
engine = exercise$engine
if (!is.null(blank_feedback)) {
# No further checking required if we detected blanks
return(
exercise_result(
feedback = blank_feedback$feedback,
html_output = rmd_results$html_output
)
)
}

# Return checker feedback (if any) with the exercise results
# Check -------------------------------------------------------------------
# Run the checker post-evaluation (for checking results of evaluated code)
checker_feedback <-
if (nzchar(exercise$check)) {
try_checker(
exercise,
check_code = exercise$check,
envir_result = rmd_results$envir_result,
evaluate_result = rmd_results$evaluate_result,
envir_prep = rmd_results$envir_prep,
last_value = rmd_results$last_value
)
}

exercise_result(
feedback = checker_feedback$feedback,
html_output = rmd_results$html_output
)
}


try_checker <- function(exercise, name, check_code, envir_result,
evaluate_result, envir_prep, last_value,
engine) {
try_checker <- function(
exercise, name = "exercise.checker", check_code = NULL, envir_result = NULL,
evaluate_result = NULL, envir_prep, last_value = NULL,
engine = exercise$engine
) {
checker_func <- tryCatch(
get_checker_func(exercise, name, envir_prep),
error = function(e) {
message("Error occurred while retrieving 'exercise.checker'. Error:\n", e)
message("Error occurred while retrieving '", name, "'. Error:\n", e)
exercise_result_error(e$message)
}
)
# If retrieving checker_func fails, return an error result
if (is_error_result(checker_func)) {
return(checker_func)
rlang::return_from(rlang::caller_env(), checker_func)
}
checker_args <- names(formals(checker_func))
args <- list(
Expand All @@ -453,7 +487,7 @@ try_checker <- function(exercise, name, check_code, envir_result,
name, paste(missing_args, collapse = "', '")
)
message(msg)
return(exercise_result_error(msg))
rlang::return_from(rlang::caller_env(), exercise_result_error(msg))
}

# Call the check function
Expand All @@ -467,7 +501,7 @@ try_checker <- function(exercise, name, check_code, envir_result,
)
# If checker code fails, return an error result
if (is_error_result(feedback)) {
return(feedback)
rlang::return_from(rlang::caller_env(), feedback)
}
# If checker doesn't return anything, there's no exercise result to return
if (length(feedback)) {
Expand Down Expand Up @@ -733,21 +767,90 @@ exercise_code_chunks <- function(chunks) {
}, character(1))
}

exercise_get_blanks_pattern <- function(exercise) {
exercise_blanks_opt <-
exercise$options$exercise.blanks %||%
knitr::opts_chunk$get("exercise.blanks") %||%
TRUE

if (isTRUE(exercise_blanks_opt)) {
# TRUE is a stand-in for the default ___+
return("_{3,}")
}

exercise_blanks_opt
}

exercise_check_code_for_blanks <- function(exercise) {
blank_regex <- exercise_get_blanks_pattern(exercise)

if (!shiny::isTruthy(blank_regex)) {
return(NULL)
}

blank_regex <- paste(blank_regex, collapse = "|")

user_code <- exercise$code
blanks <- str_match_all(user_code, blank_regex)

if (!length(blanks)) {
return(NULL)
}

msg <- paste(
i18n_span(
"text.exercisecontainsblank",
opts = list(count = length(blanks))
),
i18n_span(
"text.pleasereplaceblank",
opts = list(
count = length(blanks),
blank = i18n_combine_words(unique(blanks), before = "<code>", after = "</code>"),
interpolation = list(escapeValue = FALSE)
)
)
)

exercise_result(
list(message = HTML(msg), correct = FALSE, location = "prepend", type = "error")
)
}

exercise_check_code_is_parsable <- function(exercise) {
error <- rlang::catch_cnd(parse(text = exercise$code), "error")
if (is.null(error)) {
return(NULL)
}

exercise_result(
list(
message = HTML(i18n_span("text.unparsable")),
correct = FALSE,
location = "append",
type = "error"
),
html_output = error_message_html(error$message),
error_message = error$message
)
}

exercise_result_timeout <- function() {
exercise_result_error(
"Error: Your code ran longer than the permitted timelimit for this exercise.",
timeout_exceeded = TRUE
timeout_exceeded = TRUE,
style = "alert"
)
}

# @param timeout_exceeded represents whether or not the error was triggered
# because the exercise exceeded the timeout. Use NA if unknown
exercise_result_error <- function(error_message, feedback = NULL, timeout_exceeded = NA) {
exercise_result_error <- function(error_message, feedback = NULL, timeout_exceeded = NA, style = "code") {
exercise_result(
feedback = feedback,
timeout_exceeded = timeout_exceeded,
error_message = error_message,
html_output = error_message_html(error_message)
html_output = error_message_html(error_message, style = style)
)
}

Expand Down
12 changes: 10 additions & 2 deletions R/feedback.R
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ feedback_as_html <- function(feedback) {
}

# helper function to create tags for error message
error_message_html <- function(message) {
div(class = "alert alert-danger", role = "alert", message)
error_message_html <- function(message, style = "code") {
switch(
style,
alert = div(class = "alert alert-danger", role = "alert", message),
code = ,
pre(
code(class = "text-danger", message, .noWS = c("before", "after")),
.noWS = c("before", "after")
)
)
}
8 changes: 5 additions & 3 deletions R/html-dependencies.R
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ tutorial_i18n_html_dependency <- function(language = NULL) {
head = format(htmltools::tags$script(
id = "i18n-cstm-trns",
type = "application/json",
jsonlite::toJSON(
i18n_process_language_options(language),
auto_unbox = TRUE
htmltools::HTML(
jsonlite::toJSON(
i18n_process_language_options(language),
auto_unbox = TRUE
)
)
))
)
Expand Down
19 changes: 19 additions & 0 deletions R/i18n.R
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ i18n_span <- function(key, ..., opts = NULL) {
htmltools::HTML(format(x))
}

i18n_combine_words <- function(
words, and = c("and", "or"), before = "", after = before, oxford_comma = TRUE
) {
and <- match.arg(and)
and <- sprintf(" $t(text.%s) ", and)
words <- paste0(before, words, after)

n <- length(words)
if (oxford_comma && n > 2) {
words[n - 1] <- paste0(words[n - 1], "$t(text.oxfordcomma)")
}

knitr::combine_words(
words,
sep = "$t(text.listcomma) ",
and = and, oxford_comma = FALSE
)
}

i18n_translations <- function() {
readRDS(system.file("internals", "i18n_translations.rds", package = "learnr"))
}
Expand Down
1 change: 1 addition & 0 deletions R/mock_exercise.R
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ mock_exercise <- function(
version = version
)

stopifnot(is.null(version) || length(version) == 1)
if (!is.null(version) && version %in% c("2", "3")) {
ex$tutorial <- list(
id = "mock_tutorial_id",
Expand Down
Loading