|
| 1 | +#' Block unnecessary quoting in calls |
| 2 | +#' |
| 3 | +#' Any valid symbol can be used as a keyword argument to an R function call. |
| 4 | +#' Sometimes, it is necessary to quote (or backtick) an argument that is |
| 5 | +#' not an otherwise valid symbol (e.g. creating a vector whose names have |
| 6 | +#' spaces); besides this edge case, quoting should not be done. |
| 7 | +#' |
| 8 | +#' The most common source of violation for this is creating named vectors, |
| 9 | +#' lists, or data.frame-alikes, but it can be observed in other calls as well. |
| 10 | +#' |
| 11 | +#' Similar reasoning applies to extractions with `$` or `@`. |
| 12 | +#' |
| 13 | +#' @examples |
| 14 | +#' # will produce lints |
| 15 | +#' lint( |
| 16 | +#' text = 'data.frame("a" = 1)', |
| 17 | +#' linters = keyword_quote_linter() |
| 18 | +#' ) |
| 19 | +#' |
| 20 | +#' lint( |
| 21 | +#' text = "data.frame(`a` = 1)", |
| 22 | +#' linters = keyword_quote_linter() |
| 23 | +#' ) |
| 24 | +#' |
| 25 | +#' lint( |
| 26 | +#' text = 'my_list$"key"', |
| 27 | +#' linters = keyword_quote_linter() |
| 28 | +#' ) |
| 29 | +#' |
| 30 | +#' lint( |
| 31 | +#' text = 's4obj@"key"', |
| 32 | +#' linters = keyword_quote_linter() |
| 33 | +#' ) |
| 34 | +#' |
| 35 | +#' # okay |
| 36 | +#' lint( |
| 37 | +#' text = "data.frame(`a b` = 1)", |
| 38 | +#' linters = keyword_quote_linter() |
| 39 | +#' ) |
| 40 | +#' |
| 41 | +#' lint( |
| 42 | +#' text = 'my_list$`a b`', |
| 43 | +#' linters = keyword_quote_linter() |
| 44 | +#' ) |
| 45 | +#' |
| 46 | +#' @evalRd rd_tags("keyword_quote_linter") |
| 47 | +#' @seealso [linters] for a complete list of linters available in lintr. |
| 48 | +#' @export |
| 49 | +# TODO(michaelchirico): offer a stricter version of this that |
| 50 | +# requires backticks to be used for non-syntactic names (i.e., not quotes). |
| 51 | +# Here are the relevant xpaths: |
| 52 | +# //expr[expr[SYMBOL_FUNCTION_CALL]]/SYMBOL_SUB[starts-with(text(), '`')] |
| 53 | +# //expr[expr[SYMBOL_FUNCTION_CALL]]/STR_CONST[{is_quoted(text())}] |
| 54 | +keyword_quote_linter <- function() { |
| 55 | + # NB: xml2 uses xpath 1.0 which doesn't support matches() for regex, so we |
| 56 | + # have to jump out of xpath to complete this lint. |
| 57 | + # It's also a bit tough to get the escaping through R and then xpath to |
| 58 | + # work as intended, hence the rather verbose declaration here. |
| 59 | + quote_cond <- xp_or( |
| 60 | + "starts-with(text(), '\"')", |
| 61 | + "starts-with(text(), '`')", |
| 62 | + 'starts-with(text(), "\'")' |
| 63 | + ) |
| 64 | + # SYMBOL_SUB for backticks, STR_CONST for quoted names |
| 65 | + call_arg_xpath <- glue(" |
| 66 | + //SYMBOL_FUNCTION_CALL |
| 67 | + /parent::expr |
| 68 | + /parent::expr |
| 69 | + /*[(self::SYMBOL_SUB or self::STR_CONST) and {quote_cond}] |
| 70 | + ") |
| 71 | + |
| 72 | + # also exclude $ or @, which are handled below |
| 73 | + assignment_candidate_cond <- " |
| 74 | + not(OP-DOLLAR or OP-AT) |
| 75 | + and (STR_CONST or SYMBOL[starts-with(text(), '`')]) |
| 76 | + " |
| 77 | + assignment_xpath <- glue(" |
| 78 | + (//EQ_ASSIGN | //LEFT_ASSIGN[text() != ':=']) |
| 79 | + /preceding-sibling::expr[{ assignment_candidate_cond }] |
| 80 | + | //RIGHT_ASSIGN/following-sibling::expr[{ assignment_candidate_cond }] |
| 81 | + ") |
| 82 | + |
| 83 | + extraction_xpath <- " |
| 84 | + (//OP-DOLLAR | //OP-AT)/following-sibling::STR_CONST |
| 85 | + | //OP-DOLLAR/following-sibling::SYMBOL[starts-with(text(), '`')] |
| 86 | + | //OP-AT/following-sibling::SLOT[starts-with(text(), '`')] |
| 87 | + " |
| 88 | + |
| 89 | + no_quote_msg <- "Use backticks to create non-syntactic names, not quotes." |
| 90 | + clarification <- "i.e., if the name is not a valid R symbol (see ?make.names)." |
| 91 | + |
| 92 | + Linter(function(source_expression) { |
| 93 | + if (!is_lint_level(source_expression, "expression")) { |
| 94 | + return(list()) |
| 95 | + } |
| 96 | + |
| 97 | + xml <- source_expression$xml_parsed_content |
| 98 | + |
| 99 | + call_arg_expr <- xml_find_all(xml, call_arg_xpath) |
| 100 | + |
| 101 | + invalid_call_quoting <- is_valid_r_name(get_r_string(call_arg_expr)) |
| 102 | + |
| 103 | + call_arg_lints <- xml_nodes_to_lints( |
| 104 | + call_arg_expr[invalid_call_quoting], |
| 105 | + source_expression = source_expression, |
| 106 | + lint_message = paste("Only quote named arguments to functions if necessary,", clarification), |
| 107 | + type = "warning" |
| 108 | + ) |
| 109 | + |
| 110 | + assignment_expr <- xml_find_all(xml, assignment_xpath) |
| 111 | + |
| 112 | + invalid_assignment_quoting <- is_valid_r_name(get_r_string(assignment_expr)) |
| 113 | + # NB: XPath is such that there is exactly 1 node per match, making xml_children() ideal. |
| 114 | + # xml_child() gets it wrong for 0 (an error) and >1 match. |
| 115 | + assignment_to_string <- xml_name(xml2::xml_children(assignment_expr)) == "STR_CONST" |
| 116 | + |
| 117 | + string_assignment_lints <- xml_nodes_to_lints( |
| 118 | + assignment_expr[assignment_to_string & !invalid_assignment_quoting], |
| 119 | + source_expression = source_expression, |
| 120 | + lint_message = no_quote_msg, |
| 121 | + type = "warning" |
| 122 | + ) |
| 123 | + |
| 124 | + assignment_lints <- xml_nodes_to_lints( |
| 125 | + assignment_expr[invalid_assignment_quoting], |
| 126 | + source_expression = source_expression, |
| 127 | + lint_message = paste("Only quote targets of assignment if necessary,", clarification), |
| 128 | + type = "warning" |
| 129 | + ) |
| 130 | + |
| 131 | + extraction_expr <- xml_find_all(xml, extraction_xpath) |
| 132 | + |
| 133 | + invalid_extraction_quoting <- is_valid_r_name(get_r_string(extraction_expr)) |
| 134 | + extraction_of_string <- xml_name(extraction_expr) == "STR_CONST" |
| 135 | + |
| 136 | + string_extraction_lints <- xml_nodes_to_lints( |
| 137 | + extraction_expr[extraction_of_string & !invalid_extraction_quoting], |
| 138 | + source_expression = source_expression, |
| 139 | + lint_message = no_quote_msg, |
| 140 | + type = "warning" |
| 141 | + ) |
| 142 | + |
| 143 | + extraction_expr <- extraction_expr[invalid_extraction_quoting] |
| 144 | + extractor <- xml_find_chr(extraction_expr, "string(preceding-sibling::*[1])") |
| 145 | + gen_extractor <- ifelse(extractor == "$", "[[", "slot()") |
| 146 | + |
| 147 | + extraction_lints <- xml_nodes_to_lints( |
| 148 | + extraction_expr, |
| 149 | + source_expression = source_expression, |
| 150 | + lint_message = paste( |
| 151 | + "Only quote targets of extraction with", extractor, "if necessary,", clarification, |
| 152 | + "Use backticks to create non-syntactic names, or use", gen_extractor, "to extract by string." |
| 153 | + ), |
| 154 | + type = "warning" |
| 155 | + ) |
| 156 | + |
| 157 | + c(call_arg_lints, string_assignment_lints, assignment_lints, string_extraction_lints, extraction_lints) |
| 158 | + }) |
| 159 | +} |
| 160 | + |
| 161 | +#' Check if a string could be assigned as an R variable. |
| 162 | +#' |
| 163 | +#' See [make.names()] for the description of syntactically valid names in R. |
| 164 | +#' |
| 165 | +#' @noRd |
| 166 | +is_valid_r_name <- function(x) make.names(x) == x |
0 commit comments