diff --git a/R/document.R b/R/document.R index a44b58d1..9b509a1d 100644 --- a/R/document.R +++ b/R/document.R @@ -851,6 +851,22 @@ parse_callback <- function(self, uri, version, parse_data, source_content = NULL } else if (is.null(doc$requested_packages)) { doc$requested_packages <- requested_packages } + } else if (length(parse_data$member_data$packages)) { + # A newly pasted namespace receiver can end at $ or @ before any + # valid parse has requested its metadata. Retain the last valid imports + # while resolving only syntax-confirmed namespace hints from recovery. + requested_packages <- previous_packages + if (is.null(requested_packages)) requested_packages <- list( + packages = normalize_package_request(doc$loaded_packages), namespace_packages = character()) + requested_packages$namespace_packages <- normalize_package_request(union( + requested_packages$namespace_packages, parse_data$member_data$packages)) + if (!identical(doc$requested_packages, requested_packages) || isTRUE(doc$member_refresh)) { + doc$member_refresh <- FALSE + doc$requested_packages <- requested_packages + self$resolve_task_manager$add_task(uri, + resolve_task(self, uri, doc, requested_packages$packages, + namespace_packages = requested_packages$namespace_packages)) + } } if (is.function(self$queue_pending_replies)) { @@ -882,6 +898,8 @@ drain_pending_replies <- function(self, uri, version, max_replies = Inf, first = } if (index_context_waiting(self, self$get_workspace(uri), document, point, hover = identical(method, "textDocument/hover"), call = call)) return(FALSE) + if (identical(method, "textDocument/completion") && + member_metadata_waiting(self, uri, document, point)) return(FALSE) if (s3_metadata_waiting(self, uri, document, point, hover = identical(method, "textDocument/hover"), call = call)) return(FALSE) } @@ -1044,6 +1062,7 @@ resolve_callback <- function(self, uri, version, packages) { protect <- unique(unlist(lapply(open_documents, function(document) { c(document$requested_packages$packages, document$requested_packages$namespace_packages) }), use.names = FALSE)) + protect <- union(protect, workspace$startup_member_packages) for (package in names(packages$members)) { snapshot <- packages$members[[package]] if (inherits(workspace$member_metadata, "MemberMetadataCache")) { @@ -1089,7 +1108,10 @@ resolve_member_task <- function(self, uri, document, packages, namespace_package callback = function(result) { if (current()) resolve_callback(self, uri, version, result) }, - error = function(e) logger$info("member metadata task:", e), + error = function(e) { + on.exit(s3_queue_ready(self, uri), add = TRUE) + logger$info("member metadata task:", e) + }, profiles = FALSE ) } diff --git a/R/handlers-langfeatures.R b/R/handlers-langfeatures.R index 04d6770a..c4288b60 100644 --- a/R/handlers-langfeatures.R +++ b/R/handlers-langfeatures.R @@ -50,6 +50,66 @@ index_context_waiting <- function(self, workspace, document, point, hover = FALS TRUE } +# Namespace member models run independently of S3 catalogs. Wait only for +# packages used by this receiver, never unrelated source-member requests. +member_metadata_waiting <- function(self, uri, document, point) { + if (!isTRUE(lsp_settings$get("member_completion"))) return(FALSE) + manager <- if (is.null(self$member_task_manager)) self$resolve_task_manager else self$member_task_manager + if (is.null(manager) || !is.function(manager$has) || !manager$has(uri)) return(FALSE) + cursor <- member_cursor(document, point) + if (is.null(cursor)) return(FALSE) + recovered <- tryCatch(member_recover_scope(document, point, cursor, document$parse_data$member_data), + error = function(e) NULL) + if (is.null(recovered)) return(FALSE) + referenced <- member_source_names(recovered$parsed, recovered$source, recovered$path, + accessor = cursor$accessor) + expressions <- list(recovered$parsed) + seen <- character() + for (pass in seq_len(16L)) { + pending <- setdiff(referenced, seen) + if (!length(pending) || length(seen) > 256L) break + seen <- c(seen, pending) + for (name in pending) { + history <- document$parse_data$member_data$bindings[[name]] + position <- member_history_position(history, recovered$start) + if (position) { + expression <- history[[position]]$expr + referenced <- union(referenced, member_syntax_names(expression)) + expressions[[length(expressions) + 1L]] <- expression + } + } + } + packages <- intersect(document$requested_packages$namespace_packages, referenced) + if (!length(packages)) return(FALSE) + metadata <- self$get_workspace(uri)$member_metadata + if (is.null(metadata)) return(TRUE) + requests <- new.env(parent = emptyenv()) + requests$ready <- FALSE + requests$objects <- list() + requests$private <- character() + any(vapply(packages, function(package) { + catalog <- if (is.function(metadata$catalog)) metadata$catalog(package) else metadata$get(package, NULL) + if (is.null(catalog) || !is.null(catalog$error)) return(TRUE) + if (!isTRUE(catalog$dataset_only) || isTRUE(catalog$namespace_attempted)) return(FALSE) + # Startup catalogs contain only public datasets. A dataset already in + # that catalog is usable, but regular or private objects must wait for + # the pending namespace upgrade. Failed bounded upgrades are final. + if (!requests$ready) { + for (expression in expressions) member_walk(expression, function(node) { + if (length(node) != 3L || !(member_head(node, "::") || member_head(node, ":::"))) return() + name <- member_name(node[[2L]]) + object <- member_name(node[[3L]]) + if (is.null(name) || is.null(object)) return() + requests$objects[[name]] <- union(requests$objects[[name]], object) + if (member_head(node, ":::")) requests$private <- union(requests$private, name) + }) + requests$ready <- TRUE + } + objects <- requests$objects[[package]] + package %in% requests$private || !length(objects) || !all(objects %in% names(catalog$roots)) + }, logical(1L))) +} + #' `textDocument/completion` request handler #' #' Handler to the `textDocument/completion` [Request]. @@ -71,9 +131,11 @@ text_document_completion <- function(self, id, params) { } if (identical(document$version, document$parse_data$version) && (index_context_waiting(self, workspace, document, point) || + member_metadata_waiting(self, uri, document, point) || s3_metadata_waiting(self, uri, document, point, hover = FALSE))) { enqueue_latest_reply(self, uri, "textDocument/completion", list(id = id, version = document$version, params = params)) if (is.function(self$resolve_task_manager$promote)) self$resolve_task_manager$promote(uri) + if (is.function(self$member_task_manager$promote)) self$member_task_manager$promote(uri) return(invisible(NULL)) } # didChange schedules a debounced background parse. Scope and member diff --git a/R/handlers-workspace.R b/R/handlers-workspace.R index b03da84e..830ec36f 100644 --- a/R/handlers-workspace.R +++ b/R/handlers-workspace.R @@ -93,8 +93,15 @@ workspace_did_change_configuration <- function(self, params) { )) previous_backends <- backend_settings_identity() previous_s3 <- isTRUE(lsp_settings$get("s3_dispatch")) + previous_members <- isTRUE(lsp_settings$get("member_completion")) lsp_settings$update_from_workspace(settings) + if (!previous_members && isTRUE(lsp_settings$get("member_completion"))) { + for (workspace in self$workspaces$values()) { + if (is.function(workspace$load_startup_members)) workspace$load_startup_members() + } + } + if (!previous_s3 && isTRUE(lsp_settings$get("s3_dispatch")) && !is.null(self$resolve_task_manager)) { for (workspace in self$workspaces$values()) { diff --git a/R/languageclient.R b/R/languageclient.R index 7f2dd73d..6e01a778 100644 --- a/R/languageclient.R +++ b/R/languageclient.R @@ -28,7 +28,7 @@ LanguageClient <- R6::R6Class("LanguageClient", initialize = function(cmd = NULL, args = NULL) { if (!is.null(cmd)) { self$process <- processx::process$new(cmd, args, - stdin = "|", stdout = "|") + stdin = "|", stdout = "|", stderr = "|") } self$raw_input_available <- "conn_read_bytes" %in% getNamespaceExports("processx") self$diagnostics <- collections::dict() diff --git a/R/member-completion.R b/R/member-completion.R index 4c216539..8a58bda9 100644 --- a/R/member-completion.R +++ b/R/member-completion.R @@ -1,7 +1,30 @@ # Document indexes contain syntax and positions, never user values. Recovery # parses a bounded current expression; earlier statements come from the index. +member_incomplete_packages <- function(content) { + sentinel <- ".__languageserver_namespace_hint__" + if (length(content) > 129L || sum(nchar(content, type = "bytes")) > 65536L || + any(grepl(sentinel, content, fixed = TRUE))) return(character()) + repaired <- c(content, sentinel) + parsed <- tryCatch(str2expression(paste0(paste(repaired, collapse = "\n"), + missing_closing_delimiters(repaired))), error = function(e) NULL) + packages <- character() + member_walk(parsed, function(node) { + if (length(node) == 3L && (member_head(node, "$") || member_head(node, "@")) && + identical(member_name(node[[3L]]), sentinel)) { + member_walk(node[[2L]], function(receiver) { + if (length(receiver) == 3L && (member_head(receiver, "::") || member_head(receiver, ":::"))) { + package <- member_name(receiver[[2L]]) + if (!is.null(package)) packages <<- union(packages, package) + } + }) + } + }) + packages +} + member_document_index <- function(content, parsed = NULL, syntax = NULL) { complete <- !is.null(parsed) + recovered_packages <- character() items <- list() append <- function(expr, start, end) { items[[length(items) + 1L]] <<- list(expr = expr, start = start, end = end) @@ -58,6 +81,9 @@ member_document_index <- function(content, parsed = NULL, syntax = NULL) { NULL }) if (is.null(parsed)) { + if (grepl("unexpected end of input", message)) { + recovered_packages <- union(recovered_packages, member_incomplete_packages(text)) + } # Discard an invalid single line but retain incomplete calls. if (!grepl("unexpected end of input|INCOMPLETE_STRING", message)) start <- end + 1L end <- end + 1L @@ -72,7 +98,7 @@ member_document_index <- function(content, parsed = NULL, syntax = NULL) { s4 <- list() imports <- list() effects <- list() - packages <- character() + packages <- recovered_packages class_scope <- FALSE symbol_scope <- FALSE parts <- if (complete) member_function_parts(if (is.null(syntax)) utils::getParseData(parsed) else syntax) else NULL @@ -412,6 +438,17 @@ member_context_index <- function(workspace, uri, document, at, parsed = NULL, if (is.null(data)) { return(index) } + # The startup vector is in attachment order (the search path reversed), + # so later packages overwrite earlier ones just as R masks their names. + # A cached namespace alone must not make its bare data names visible. + for (package in workspace$startup_packages) { + package_index <- member_lookup(index$namespace_indices, package) + if (is.null(package_index)) next + index$attached_roots[names(package_index$roots)] <- package_index$roots + if (!is.null(package_index$s4_classes)) { + index$attached_s4[names(package_index$s4_classes)] <- package_index$s4_classes + } + } for (item in data$imports) { if (!member_before(item$end, at)) next expr <- item$expr @@ -419,10 +456,11 @@ member_context_index <- function(workspace, uri, document, at, parsed = NULL, if (member_head(expr, "library") || member_head(expr, "require")) { package <- member_name(expr[[2L]]) if (!is.null(package) && package %in% names(index$namespace_indices)) { - index$attached_roots <- utils::modifyList(index$attached_roots, index$namespace_indices[[package]]$roots) + roots <- index$namespace_indices[[package]]$roots + index$attached_roots[names(roots)] <- roots package_index <- index$namespace_indices[[package]] if (!is.null(package_index$s4_classes)) { - index$attached_s4 <- utils::modifyList(index$attached_s4, package_index$s4_classes) + index$attached_s4[names(package_index$s4_classes)] <- package_index$s4_classes } functions <- if (isTRUE(package_index$.metadata_lazy)) { package_index$functions @@ -513,6 +551,18 @@ member_cursor_value <- function(parsed, sentinel, index, bindings, budget, name if (member_head(node, accessor) && identical(member_name(node[[3L]]), sentinel)) { if (is.null(name)) { result <<- member_infer(node[[2L]], index, env, budget = budget) + if (identical(accessor, "$") && isTRUE(result$default_dollar)) { + context <- member_package_context(index, result$member_package) + if (is.null(context) || !member_default_dollar(result, context, env)) { + if (member_dataframe_columns(result)) { + result$column_names_only <<- TRUE + result$default_dollar <<- NULL + result$reason <<- "unresolved_dollar_dispatch" + } else { + result <<- member_value(reason = "unresolved_dollar_dispatch") + } + } + } } else { receiver <- member_infer(node[[2L]], index, env, budget = budget) result <<- if (identical(accessor, "@")) { @@ -572,7 +622,8 @@ member_cursor_value <- function(parsed, sentinel, index, bindings, budget, name if ((member_head(child, "<-") || member_head(child, "=")) && is.symbol(child[[2L]])) { assigned <- as.character(child[[2L]]) declaration <- any(member_syntax_names(child[[3L]]) %in% c("setClass", "setClassUnion")) - if (!assigned %in% needed && !declaration) { + dispatch <- startsWith(assigned, "$.") || startsWith(assigned, "[[.") + if (!assigned %in% needed && !declaration && !dispatch) { keep[[i]] <- FALSE } else { needed <- union(setdiff(needed, assigned), member_syntax_names(child[[3L]])) @@ -887,10 +938,11 @@ member_completion <- function(uri, workspace, document, point, snippet_support, return(NULL) } value <- resolved$value - index <- resolved$index + index <- member_package_context(resolved$index, value$member_package) bindings <- resolved$bindings budget <- resolved$budget - if (is.null(value) || !length(value$type)) { + if (identical(value$reason, "unresolved_dollar_dispatch") && !member_dataframe_columns(value)) return(list()) + if (is.null(index) || is.null(value) || !length(value$type)) { return(NULL) } members <- member_members(value, index, bindings, cursor$accessor) diff --git a/R/member-extraction.R b/R/member-extraction.R index eb554b13..a8e584ca 100644 --- a/R/member-extraction.R +++ b/R/member-extraction.R @@ -44,6 +44,7 @@ member_constructor <- function(fn) { return(NULL) } statements <- as.list(body)[-1L] + if (!length(statements)) return(NULL) last <- statements[[length(statements)]] if (member_head(last, "return") && length(last) == 2L) last <- last[[2L]] target <- member_name(last) @@ -215,6 +216,8 @@ member_package_index <- function(input) { index$definitions <- input$definitions index$locations <- input$locations index$exports <- input$exports + index$subset_registry <- input$subset_registry + index$subset_registry_complete <- isTRUE(input$subset_registry_complete) index$registries <- if (!is.null(input$registries)) input$registries else member_source_registries(input) index$intrinsics <- if (!is.null(input$intrinsics)) input$intrinsics else character() index$nonreturning_functions <- intersect(c("stop", "abort"), c(member_base_intrinsics, index$intrinsics)) @@ -309,21 +312,36 @@ member_package_index <- function(input) { } } } - # Every ordinary environment surface is discovered, including static bundles + # Every static object surface is discovered, including named lists, bundles # and exported roots. Active names are retained but getters are only analyzed. for (name in names(input$snapshots)) { snapshot <- input$snapshots[[name]] - shape <- member_snapshot_shape(snapshot) - shape$type <- name + shape <- member_snapshot_shape(snapshot, input$package) + if (identical(snapshot$kind, "environment")) shape$type <- name for (field in names(index$registries[[name]])) { if (is.null(shape$fields[[field]]$s7_generator)) { - shape$fields[field] <- list(member_value(function_key = index$registries[[name]][[field]])) + stored <- shape$fields[[field]] + method <- if (identical(snapshot$kind, "r6_instance") || !is.null(stored$s4_class) || + length(stored$closure)) stored else member_value() + method$function_key <- index$registries[[name]][[field]] + method$metadata <- input$package + shape$fields[field] <- list(method) + } + } + for (field in names(input$links[[name]])) { + linked <- shape$fields[[field]] + if (identical(linked$type, "environment") && is.null(linked$r6_role)) { + shape$fields[field] <- list(member_value(type = input$links[[name]][[field]], + classes = linked$classes, default_dollar = linked$default_dollar, + member_package = input$package)) } } - for (field in names(input$links[[name]])) shape$fields[field] <- list(member_value(type = input$links[[name]][[field]])) index$package_roots[name] <- list(shape) - for (field in names(snapshot$fields)) { + for (field in names(shape$fields)) { if (!field %in% names(index$members[[name]])) index$members[[name]][field] <- NA_character_ + if (identical(snapshot$kind, "environment") && is.null(shape$fields[[field]]$function_key)) { + index$properties[[name]][field] <- list(shape$fields[[field]]) + } } } # Registry lookup branches describe membership, precedence, lexical receiver @@ -553,9 +571,11 @@ member_package_index <- function(input) { for (name in names(input$s7_roots)) index$package_roots[name] <- input$s7_roots[name] for (name in names(input$s4_objects)) { class <- input$s4_objects[[name]] - index$package_roots[name] <- list(member_s4_shape(class$name, index, list(), class$package)) + if (is.null(index$package_roots[[name]]$s4_class)) { + index$package_roots[name] <- list(member_s4_shape(class$name, index, list(), class$package)) + } } - index$roots <- index$package_roots[intersect(names(index$package_roots), index$exports)] + index$roots <- member_public_roots(index, input) for (name in names(index$roots)) index$namespace_roots[paste(input$package, name, sep = "::")] <- list(index$roots[[name]]) member_prepare_returns(index) index$cache <- new.env(parent = emptyenv()) diff --git a/R/member-inference.R b/R/member-inference.R index fa72b759..cc6aa382 100644 --- a/R/member-inference.R +++ b/R/member-inference.R @@ -72,6 +72,7 @@ member_lookup <- function(x, key) { member_namespace_index <- function(index, package) { value <- member_lookup(index$namespace_indices, package) + if (is.null(value) && identical(package, index$package)) return(index) if (isTRUE(value$.metadata_lazy)) { value <- index$namespace_metadata$get(package) index$namespace_indices[package] <- list(value) @@ -79,6 +80,54 @@ member_namespace_index <- function(index, package) { value } +# Member tables belong to the receiver's package, while lexical shadowing and +# attached namespaces still come from the document containing the request. +member_package_context <- function(index, package) { + if (is.null(package) || identical(package, index$package)) return(index) + owner <- member_namespace_index(index, package) + if (is.null(owner)) return(NULL) + context <- list2env(as.list(owner), parent = emptyenv()) + for (name in c("namespace_indices", "namespace_metadata", "document_bindings", "document_s4", + "attached_roots", "attached_s4", "methods_attached")) context[[name]] <- index[[name]] + context +} + +member_subset_method <- function(value, operator, index, bindings, budget, depth, trail) { + owner <- member_package_context(index, value$member_package) + if (is.null(owner)) return(NULL) + if (identical(value$reason, "ambiguous_class")) return(member_value(reason = "unresolved_subset_dispatch")) + if (!length(value$classes) && !length(member_lookup(owner$classes, value$type))) return(NULL) + classes <- member_classes(value, owner) + if (!length(classes)) return(NULL) + registries <- c(list(owner$subset_registry), lapply(index$namespace_indices, function(candidate) candidate$subset_registry)) + for (key in paste0(operator, ".", c(classes, "default"))) { + history <- member_lookup(index$document_bindings, key) + at <- bindings$.__member_position__ + if (key %in% names(bindings) || (length(history) && (is.null(at) || member_history_position(history, at)))) { + return(member_infer(as.name(key), index, bindings, depth = depth + 1L, trail = trail, budget = budget)) + } + registered <- unique(unlist(lapply(registries, function(registry) member_lookup(registry, key)), use.names = FALSE)) + definition <- member_definition(owner$definitions, key) + if (length(registered)) { + # Registrations can come from another namespace, or replace a + # method after this receiver's package snapshot was prepared. + if (identical(registered, "base")) return(NULL) + expected <- if (member_head(definition, "function")) { + paste0("namespace:", owner$package, ":", digest::digest(definition, algo = "xxhash64")) + } + if (length(registered) != 1L || is.null(expected) || !identical(registered, expected)) { + return(member_value(reason = "unresolved_subset_dispatch")) + } + return(member_value(function_key = key, metadata = owner$package)) + } + if (!is.null(definition)) return(member_value(function_key = key, metadata = owner$package)) + } + if (!is.null(value$member_package) && !isTRUE(owner$subset_registry_complete)) { + return(member_value(reason = "unresolved_subset_dispatch")) + } + NULL +} + member_value <- function( type = NULL, function_key = NULL, receiver = NULL, fields = NULL, function_expr = NULL, closure = NULL, receiver_name = NULL, @@ -87,9 +136,10 @@ member_value <- function( open = FALSE, result_shape = NULL, binding_expr = NULL, binding_env = NULL, metadata = NULL, slots = NULL, slot_types = NULL, s4_class = NULL, s4_generator = NULL, r6_private = NULL, r6_super = NULL, r6_self = NULL, - r6_class = NULL, r6_role = NULL + r6_class = NULL, r6_role = NULL, default_dollar = FALSE, named_elements = FALSE, + member_package = NULL, column_names_only = FALSE ) { - list( + value <- list( type = type, function_key = function_key, receiver = receiver, fields = fields, function_expr = function_expr, closure = closure, receiver_name = receiver_name, receiver_value = receiver_value, @@ -102,6 +152,11 @@ member_value <- function( r6_super = r6_super, r6_self = r6_self, r6_class = r6_class, r6_role = r6_role ) + if (isTRUE(default_dollar)) value$default_dollar <- TRUE + if (isTRUE(named_elements)) value$named_elements <- TRUE + if (!is.null(member_package)) value$member_package <- member_package + if (isTRUE(column_names_only)) value$column_names_only <- TRUE + value } member_literal <- function(x) { @@ -116,7 +171,9 @@ member_join <- function(a, b) { if (identical(b$type, ".never")) { return(a) } - if (identical(a, b)) { + # Compacted snapshots and source values can omit different optional NULLs. + if (identical(a, b) || (isTRUE(a$known_literal) && isTRUE(b$known_literal) && + identical(a[!vapply(a, is.null, logical(1L))], b[!vapply(b, is.null, logical(1L))]))) { return(a) } if (!length(a$type) || !length(b$type)) { @@ -145,8 +202,22 @@ member_join <- function(a, b) { slots = slots, slot_types = slot_types, s4_class = if (identical(a$s4_class, b$s4_class)) a$s4_class else NULL, element_shape = if (!is.null(a$element_shape) && !is.null(b$element_shape)) member_join(a$element_shape, b$element_shape) else NULL, - classes = if (identical(a$classes, b$classes)) a$classes else NULL + classes = if (identical(a$classes, b$classes)) a$classes else if ( + isTRUE(a$default_dollar) || isTRUE(b$default_dollar) + ) unique(c(a$classes, b$classes)) else NULL, + default_dollar = isTRUE(a$default_dollar) && isTRUE(b$default_dollar), + named_elements = isTRUE(a$named_elements) && isTRUE(b$named_elements) && identical(names(a$fields), names(b$fields)), + member_package = if (identical(a$member_package, b$member_package)) a$member_package else NULL, + column_names_only = isTRUE(a$column_names_only) || isTRUE(b$column_names_only) ) + if (!identical(a$classes, b$classes) && (length(a$classes) || length(b$classes))) value$reason <- "ambiguous_class" + if (!identical(a$member_package, b$member_package) && + (!is.null(a$member_package) || !is.null(b$member_package)) && !is.null(fields)) { + # Object names are local to each namespace; a joined receiver can only + # retain its common structural fields, not either package's registry. + value$type <- if (all(c(a$type, b$type) %in% c("list", "pairlist")) || + (isTRUE(a$named_elements) && isTRUE(b$named_elements))) "list" else "environment" + } if (isTRUE(a$s7) && isTRUE(b$s7)) value$s7 <- TRUE if (identical(a$r6_class, b$r6_class)) value$r6_class <- a$r6_class if (identical(a$r6_role, b$r6_role)) value$r6_role <- a$r6_role @@ -156,7 +227,7 @@ member_join <- function(a, b) { if (!is.null(a$r6_bindings) || !is.null(b$r6_bindings)) { bindings <- c(a$r6_bindings, b$r6_bindings) # A binding must be writable on every branch to infer replacement. - bindings <- c(bindings[bindings %in% c("method", "active")], bindings) + bindings <- c(bindings[bindings %in% c("method", "active", "locked")], bindings) value$r6_bindings <- bindings[!duplicated(names(bindings))] } if (identical(a$r6_identity, b$r6_identity)) value$r6_identity <- a$r6_identity @@ -174,6 +245,32 @@ member_classes <- function(value, index) { if (is.null(classes)) value$type else classes } +# A worker snapshot can verify ordinary environment access. A method declared +# in the document or current lexical scope invalidates that package-time fact. +member_default_dollar <- function(value, index, bindings) { + if (!isTRUE(value$default_dollar)) return(FALSE) + if (!length(value$classes)) return(TRUE) + methods <- paste0("$.", c(value$classes, "default")) + if (any(methods %in% names(bindings))) return(FALSE) + for (method in methods) { + if (!is.null(member_lookup(index$definitions, method))) return(FALSE) + history <- member_lookup(index$document_bindings, method) + at <- bindings$.__member_position__ + if (length(history) && (is.null(at) || member_history_position(history, at))) return(FALSE) + } + TRUE +} + +member_elements <- function(value) { + if (!is.null(value$elements)) return(value$elements) + if (isTRUE(value$named_elements) && !isTRUE(value$open)) return(value$fields) + NULL +} + +member_dataframe_columns <- function(value) { + "data.frame" %in% value$classes && !is.null(value$fields) +} + member_members <- function(value, index, bindings = list(), accessor = "$") { if (!length(value$type) || any(value$type %in% c(".never", ".missing"))) { return(character()) @@ -182,8 +279,13 @@ member_members <- function(value, index, bindings = list(), accessor = "$") { slots <- names(value$slot_types) return(stats::setNames(rep(NA_character_, length(slots)), slots)) } - if (length(value$classes) && !all(value$classes %in% c("data.frame", "list", "environment", "R6")) && - !any(value$classes %in% names(index$members))) { + index <- member_package_context(index, value$member_package) + if (is.null(index)) return(character()) + columns <- member_dataframe_columns(value) + if (!columns && isTRUE(value$default_dollar) && !member_default_dollar(value, index, bindings)) return(character()) + if (!isTRUE(value$default_dollar) && length(value$classes) && + !all(value$classes %in% c("data.frame", "list", "environment", "R6")) && + !any(value$classes %in% names(index$members)) && !columns) { return(character()) } sets <- lapply(value$type, function(type) { @@ -214,7 +316,11 @@ member_shape_key <- function(value, depth = 0L) { return("...") } list( - type = value$type, classes = value$classes, literal = if (isTRUE(value$known_literal) && + type = value$type, classes = value$classes, default_dollar = value$default_dollar, + named_elements = value$named_elements, member_package = value$member_package, + column_names_only = isTRUE(value$column_names_only), + class_ambiguous = identical(value$reason, "ambiguous_class"), + literal = if (isTRUE(value$known_literal) && length(value$literal) <= 8L) { value$literal } else { @@ -233,6 +339,8 @@ member_shape_key <- function(value, depth = 0L) { r6_identity = value$r6_identity, r6_class = value$r6_class, r6_role = value$r6_role, r6_owner = value$r6_owner, r6_member_kind = value$r6_member_kind, + r6_static_bound = isTRUE(value$r6_static_bound), + r6_private_self = isTRUE(value$r6_private_self), elements = lapply(value$elements, member_shape_key, depth + 1L), element_shape = if (!is.null(value$element_shape)) member_shape_key(value$element_shape, depth + 1L) else NULL ) @@ -289,6 +397,14 @@ member_slot <- function(value, name, index, bindings, budget = NULL) { member_access <- function(lhs, name, index, bindings) { unknown <- member_value(reason = "unsupported") + if (isTRUE(lhs$column_names_only) || identical(lhs$reason, "unresolved_dollar_dispatch")) { + return(member_value(reason = "unresolved_dollar_dispatch")) + } + index <- member_package_context(index, lhs$member_package) + if (is.null(index)) return(unknown) + if (isTRUE(lhs$default_dollar) && !member_default_dollar(lhs, index, bindings)) { + return(member_value(reason = "unresolved_dollar_dispatch")) + } dispatch_classes <- c( names(index$members), names(index$classes), unlist(lapply(index$namespace_indices, function(x) names(x$classes))) @@ -296,16 +412,14 @@ member_access <- function(lhs, name, index, bindings) { if (!is.null(lhs$fields) && any(lhs$classes %in% index$record_dispatch_classes)) { dispatch_classes <- c(dispatch_classes, lhs$classes) } - if (length(lhs$classes) && !all(lhs$classes %in% c("list", "environment", "data.frame", "R6")) && + if (!isTRUE(lhs$default_dollar) && length(lhs$classes) && + !all(lhs$classes %in% c("list", "environment", "data.frame", "R6")) && !any(lhs$classes %in% dispatch_classes)) { return(member_value(reason = "unresolved_dollar_dispatch")) } if (is.null(name)) { return(unknown) } - if (length(lhs$type) > 1L) { - return(Reduce(member_join, lapply(lhs$type, function(type) member_access(member_value(type = type), name, index, bindings)))) - } dynamic <- member_lookup(member_lookup(member_lookup(bindings, ".__member_properties__"), lhs$type), name) if (!is.null(dynamic)) { return(dynamic) @@ -315,8 +429,12 @@ member_access <- function(lhs, name, index, bindings) { value <- member_r6_member_info(value, lhs) if (!is.null(value$receiver_name)) { value$receiver_value <- if (is.null(lhs$r6_self)) lhs else lhs$r6_self - if (!is.null(value$receiver_value$r6_private)) { + if (isTRUE(value$r6_private_self)) { + value$closure$private <- lhs + } else if (!isTRUE(value$r6_static_bound) && !is.null(value$receiver_value$r6_private)) { value$closure$private <- value$receiver_value$r6_private + } + if (!is.null(value$closure$private)) { value$closure$private$r6_self <- value$receiver_value } if (!is.null(value$closure$super)) value$closure$super$r6_self <- value$receiver_value @@ -327,6 +445,9 @@ member_access <- function(lhs, name, index, bindings) { return(value) } } + if (length(lhs$type) > 1L) { + return(Reduce(member_join, lapply(lhs$type, function(type) member_access(member_value(type = type), name, index, bindings)))) + } property <- member_lookup(member_lookup(index$properties, lhs$type), name) if (!is.null(property)) { return(if (is.character(property)) member_value(type = property) else property) @@ -337,7 +458,7 @@ member_access <- function(lhs, name, index, bindings) { } members <- member_lookup(index$members, lhs$type) if (name %in% names(members) && !is.na(members[[name]])) { - return(member_value(function_key = members[[name]], receiver_value = lhs)) + return(member_value(function_key = members[[name]], receiver_value = lhs, metadata = lhs$member_package)) } unknown } @@ -566,6 +687,7 @@ member_infer <- function( if (!is.null(object) && length(classes)) { object$type <- classes[[1L]] object$classes <- classes + object$default_dollar <- FALSE env[name] <- list(object) } else if (!is.null(name)) env[name] <- list(unknown) } @@ -746,7 +868,8 @@ member_infer <- function( } else { body <- fn[[3L]] ctx <- list(key = key, actuals = actuals, formals = names(fn[[2L]]), dispatch = dispatch, - r6_initialize = r6_initialize) + r6_initialize = r6_initialize, + namespace_code = !is.null(index$package) && is.null(index$document_bindings)) result <- member_infer(body, summary_index, env, depth = depth + 1L, trail = if (is.null(key)) trail else c(trail, key), budget = budget, context = ctx @@ -839,7 +962,8 @@ member_infer <- function( return(if (length(results)) Reduce(member_join, results) else member_literal(NULL)) } if (member_head(expr, "function")) { - return(member_value(function_expr = expr, closure = member_function_closure(expr, bindings))) + package <- if (isTRUE(context$namespace_code)) index$package else NULL + return(member_value(function_expr = expr, closure = member_function_closure(expr, bindings), metadata = package)) } if (member_is_r6_call(expr, index, bindings)) { return(member_r6_shape(expr, index, bindings, budget, depth, trail)) @@ -851,22 +975,28 @@ member_infer <- function( s7 <- member_s7_call(expr, index, bindings, budget) if (!is.null(s7)) return(s7) } - if (member_head(expr, "::")) { + if (member_head(expr, "::") || member_head(expr, ":::")) { package <- member_name(expr[[2L]]) name <- member_name(expr[[3L]]) - root <- member_lookup(index$namespace_roots, paste(package, name, sep = "::")) + private <- member_head(expr, ":::") + root <- if (!private) member_lookup(index$namespace_roots, paste(package, name, sep = "::")) if (!is.null(root)) { return(root) } package_index <- member_namespace_index(index, package) - if (!is.null(package_index) && name %in% package_index$exports && + if (private) { + root <- member_lookup(package_index$package_roots, name) + if (is.null(root) && identical(package, index$package)) root <- member_lookup(index$package_roots, name) + if (!is.null(root)) return(root) + } + if (!is.null(package_index) && (private || name %in% package_index$exports) && name %in% names(package_index$definitions)) { return(member_value( function_key = name, metadata = package )) } if (identical(package, index$package) && name %in% names(index$definitions) && - (is.null(index$exports) || name %in% index$exports)) { + (private || is.null(index$exports) || name %in% index$exports)) { return(member_value(function_key = name)) } return(unknown) @@ -886,22 +1016,33 @@ member_infer <- function( } if (head %in% c("[[", "[")) { lhs <- infer(expr[[2L]]) + elements <- member_elements(lhs) selector <- if (length(expr) >= 3L && !identical(expr[[3L]], quote(expr = ))) infer(expr[[3L]]) else unknown + method <- member_subset_method(lhs, head, index, bindings, budget, depth, trail) + if (!is.null(method)) { + env <- bindings + env$.__member_subset_method__ <- method + env$.__member_subset_receiver__ <- lhs + call <- as.call(c(list(as.name(".__member_subset_method__"), as.name(".__member_subset_receiver__")), + as.list(expr)[-c(1L, 2L)])) + return(member_infer(call, + index, env, depth = depth + 1L, trail = trail, budget = budget)) + } if (isTRUE(selector$known_literal) && length(selector$literal) == 1L) { name <- selector$literal if (is.character(name)) { return(member_access(lhs, name, index, bindings)) } - if (head == "[[" && !is.null(lhs$elements) && is.numeric(name) && - name >= 1L && name <= length(lhs$elements)) { - return(lhs$elements[[name]]) + if (head == "[[" && !is.null(elements) && is.numeric(name) && + name >= 1L && name <= length(elements)) { + return(elements[[name]]) } } if (head == "[" && length(lhs$type) == 1L && lhs$type %in% c("double", "integer", "character", "logical", "list", "data.frame")) { return(lhs) } - if (!is.null(lhs$elements) && length(lhs$elements) && head == "[[") { - return(Reduce(member_join, lhs$elements)) + if (!is.null(elements) && length(elements) && head == "[[") { + return(Reduce(member_join, elements)) } if (!is.null(lhs$element_shape) && head == "[[") { return(lhs$element_shape) @@ -1065,9 +1206,29 @@ member_infer <- function( if (head == "structure") { out <- arg(1L) classes <- actuals[["class"]] - if (!is.null(classes) && classes$known_literal) { + if (!is.null(classes)) { + class_expr <- member_lookup(args, "class") + empty_character <- FALSE + if (is.call(class_expr) && length(class_expr) == 1L) { + empty_character <- member_head(class_expr, "character") && intrinsic("character") + if (member_head(class_expr[[1L]], "::")) { + empty_character <- identical(member_name(class_expr[[1L]][[2L]]), "base") && + identical(member_name(class_expr[[1L]][[3L]]), "character") + } + } + if (empty_character) classes <- member_literal(character()) + if (!isTRUE(classes$known_literal)) return(unknown) + if (is.null(classes$literal) || (is.character(classes$literal) && !length(classes$literal))) { + out$classes <- NULL + out$default_dollar <- NULL + out$column_names_only <- NULL + return(out) + } + if (!is.character(classes$literal) || anyNA(classes$literal)) return(unknown) out$type <- classes$literal[[1L]] out$classes <- classes$literal + out$default_dollar <- FALSE + if (!"data.frame" %in% out$classes) out$column_names_only <- NULL } return(out) } @@ -1076,8 +1237,9 @@ member_infer <- function( } if (head == "length") { x <- arg(1L) - if (!is.null(x$elements)) { - return(member_literal(length(x$elements))) + elements <- member_elements(x) + if (!is.null(elements)) { + return(member_literal(length(elements))) } if (x$known_literal) { return(member_literal(length(x$literal))) @@ -1137,7 +1299,7 @@ member_infer <- function( return(if (length(yes) == 1L && !is.na(yes)) member_literal(yes) else member_value(type = "logical")) } if (head == "lapply") { - xs <- arg(1L)$elements + xs <- member_elements(arg(1L)) fn <- arg(2L) if (is.null(xs)) { input <- if (is.null(arg(1L)$element_shape)) unknown else arg(1L)$element_shape @@ -1162,7 +1324,7 @@ member_infer <- function( } if (head == "Reduce") { fn <- arg(1L) - xs <- arg(2L)$elements + xs <- member_elements(arg(2L)) init <- member_lookup(actuals, "init") if (is.null(init) && length(actuals) >= 3L) init <- arg(3L) if (is.null(xs)) { @@ -1268,7 +1430,8 @@ member_infer <- function( return(unknown) } env <- package_index$package_roots - env$.__member_callee__ <- member_value(function_key = callee$function_key) + callee$metadata <- NULL + env$.__member_callee__ <- callee call <- as.call(c( list(as.name(".__member_callee__")), stats::setNames(lapply(seq_along(actuals), function(i) as.name(paste0(".__member_arg", i))), names(actuals)) diff --git a/R/member-metadata.R b/R/member-metadata.R index 7fbe1939..baed9e83 100644 --- a/R/member-metadata.R +++ b/R/member-metadata.R @@ -22,9 +22,95 @@ member_function_syntax <- function(fn) { as.call(list(as.name("function"), formals(fn), body(fn))) } -member_snapshot <- function(env, max_depth = 4L, max_bindings = 5000L, s7_modes = list()) { +member_list_storage <- function(value) { + storage <- unclass(value) + nms <- attr(value, "names", exact = TRUE) + # Dimensions supply implicit matrix/array classes even after unclass(). + # Keep only names so lapply() cannot dispatch a coercion method. + attributes(storage) <- if (is.null(nms)) NULL else list(names = nms) + storage +} + +# Internal subset generics use the registered method table in base. Read +# identities only in the worker; request handlers never look up runtime methods. +member_subset_registry <- function(ns, package) { + record <- member_binding(asNamespace("base"), ".__S3MethodsTable__.") + if (!identical(record[[1L]], "value") || !is.environment(record[[2L]])) { + return(list(methods = character(), complete = FALSE)) + } + table <- record[[2L]] + keys <- ls(table, all.names = TRUE) + keys <- keys[startsWith(keys, "[.") | startsWith(keys, "[[.")] + if (length(keys) > 5000L) return(list(methods = character(), complete = FALSE)) + methods <- stats::setNames(vapply(keys, function(key) { + registered <- member_binding(table, key) + if (!identical(registered[[1L]], "value") || typeof(registered[[2L]]) != "closure") return("opaque") + fn <- registered[[2L]] + if (key %in% c("[.data.frame", "[[.data.frame")) { + standard <- member_binding(asNamespace("base"), key) + if (identical(standard[[1L]], "value") && identical(standard[[2L]], fn)) return("base") + } + local <- member_binding(ns, key) + if (identical(local[[1L]], "value") && identical(local[[2L]], fn)) { + provider <- package + } else { + scope <- environment(fn) + while (is.environment(scope) && !isNamespace(scope) && !identical(scope, emptyenv())) scope <- parent.env(scope) + if (!isNamespace(scope)) return("opaque") + provider <- unname(getNamespaceName(scope)) + original <- member_binding(scope, key) + if (!identical(original[[1L]], "value") || !identical(original[[2L]], fn)) return("opaque") + } + paste0("namespace:", provider, ":", digest::digest(member_function_syntax(fn), algo = "xxhash64")) + }, character(1L)), keys) + list(methods = methods, complete = TRUE) +} + +member_snapshot <- function(env, max_depth = 4L, max_bindings = 5000L, s7_modes = list(), + lookup_scope = parent.frame()) { seen <- list() remaining <- max_bindings + method_scopes <- list() + scope <- lookup_scope + while (is.environment(scope) && !identical(scope, emptyenv())) { + method_scopes[[length(method_scopes) + 1L]] <- list(env = scope, names = ls(scope, all.names = TRUE)) + scope <- parent.env(scope) + } + # $ is an internal S3 generic whose registered methods live in base. + # Inspect binding kinds rather than calling getS3method(), which can force + # deferred method bindings or invoke active bindings during lookup. + table <- member_binding(asNamespace("base"), ".__S3MethodsTable__.") + registered <- if (identical(table[[1L]], "value") && is.environment(table[[2L]])) table[[2L]] else NULL + if (!is.null(registered)) { + method_scopes[[length(method_scopes) + 1L]] <- list( + env = registered, names = ls(registered, all.names = TRUE) + ) + } + dollar_methods <- new.env(parent = emptyenv()) + default_dollar <- function(current) { + if (isS4(current) || is.null(registered)) return(FALSE) + classes <- attr(current, "class", exact = TRUE) + if (!is.null(classes) && (!is.character(classes) || anyNA(classes))) return(FALSE) + if (length(classes)) classes <- c(classes, "default") + for (class in classes) { + method <- paste0("$.", class) + present <- get0(method, dollar_methods, inherits = FALSE) + if (is.null(present)) { + present <- FALSE + for (scope in method_scopes) { + if (!method %in% scope$names) next + record <- member_binding(scope$env, method) + if (!identical(record[[1L]], "value") || is.function(record[[2L]])) { + present <- TRUE + break + } + } + assign(method, present, dollar_methods) + } + if (present) return(FALSE) + } + TRUE + } captures <- function(fn) { scope <- environment(fn) if (is.null(scope) || isNamespace(scope) || identical(scope, globalenv()) || @@ -46,7 +132,47 @@ member_snapshot <- function(env, max_depth = 4L, max_bindings = 5000L, s7_modes } values } - visit <- function(current, depth) { + r6_cache <- new.env(parent = emptyenv()) + r6_cache$receivers <- r6_cache$scopes <- list() + r6_method_scopes <- function(receiver) { + cached <- which(vapply(r6_cache$receivers, identical, logical(1L), receiver)) + if (length(cached)) return(r6_cache$scopes[[cached[[1L]]]]) + record <- member_binding(receiver, ".__enclos_env__") + queue <- if (identical(record[[1L]], "value") && is.environment(record[[2L]])) list(record[[2L]]) else list() + scopes <- list() + checked <- 0L + next_scope <- 1L + while (next_scope <= length(queue) && length(scopes) < 32L) { + scope <- queue[[next_scope]] + next_scope <- next_scope + 1L + if (any(vapply(scopes, identical, logical(1L), scope))) next + self <- member_binding(scope, "self") + if (!identical(self[[1L]], "value") || !identical(self[[2L]], receiver)) next + scopes[[length(scopes) + 1L]] <- scope + if (checked >= 5000L) next + super <- member_binding(scope, "super") + if (!identical(super[[1L]], "value") || !is.environment(super[[2L]])) next + for (name in utils::head(ls(super[[2L]], all.names = TRUE), 5000L - checked)) { + checked <- checked + 1L + method <- member_binding(super[[2L]], name) + if (identical(method[[1L]], "value") && typeof(method[[2L]]) == "closure") { + enclosing <- environment(method[[2L]]) + if (!any(vapply(queue, identical, logical(1L), enclosing))) { + queue[[length(queue) + 1L]] <- enclosing + } + } + } + } + key <- length(r6_cache$receivers) + 1L + r6_cache$receivers[key] <- list(receiver) + r6_cache$scopes[key] <- list(scopes) + scopes + } + visit <- function(current, depth, r6_receiver = NULL) { + classes <- attr(current, "class", exact = TRUE) + r6_instance <- "R6" %in% classes && !"R6ClassGenerator" %in% classes + if (r6_instance) r6_receiver <- current + kind <- if (r6_instance) "r6_instance" else "environment" old <- which(vapply(seen, identical, logical(1L), current)) if (length(old)) { return(list(kind = "reference", id = old[[1L]])) @@ -54,33 +180,39 @@ member_snapshot <- function(env, max_depth = 4L, max_bindings = 5000L, s7_modes id <- length(seen) + 1L seen[[id]] <<- current if (depth > max_depth) { - return(list(kind = "environment", id = id, complete = FALSE)) + return(list(kind = kind, id = id, complete = FALSE)) } nms <- ls(current, all.names = TRUE) if (length(nms) > remaining) { - return(list(kind = "environment", id = id, complete = FALSE)) + return(list(kind = kind, id = id, complete = FALSE)) } remaining <<- remaining - length(nms) fields <- stats::setNames(lapply(nms, function(name) { record <- member_binding(current, name) value <- record[[2L]] - if (identical(record[[1L]], "active")) { + field <- if (identical(record[[1L]], "active")) { list( kind = "active", syntax = if (typeof(value) == "closure") member_function_syntax(value), captures = if (typeof(value) == "closure") captures(value) else list() ) } else if (identical(record[[1L]], "value")) { - describe(value, depth + 1L) + receiver <- if (typeof(value) == "closure" || + (is.environment(value) && name %in% c(".__enclos_env__", "private", "super"))) r6_receiver + describe(value, depth + 1L, receiver) } else { list(kind = "unknown", reason = "promise_not_forced") } + if (!is.null(r6_receiver)) field$locked <- bindingIsLocked(name, current) + field }), nms) list( - kind = "environment", id = id, complete = TRUE, - classes = attr(current, "class", exact = TRUE), fields = fields + kind = kind, id = id, complete = TRUE, classes = classes, fields = fields, + default_dollar = default_dollar(current), + r6_locked = if (!is.null(r6_receiver)) environmentIsLocked(current) else NULL ) } - describe <- function(value, depth) { + describe <- function(value, depth, r6_receiver = NULL) { + if (missing(value) || identical(value, quote(expr = ))) return(list(kind = "unknown", reason = "missing_value")) descriptor <- member_s7_instance_descriptor(value, s7_modes) if (!is.null(descriptor)) { if (depth > max_depth) return(list(kind = "unknown", reason = "snapshot_depth")) @@ -99,37 +231,72 @@ member_snapshot <- function(env, max_depth = 4L, max_bindings = 5000L, s7_modes } descriptor <- member_s7_runtime_descriptor(value, modes = s7_modes) if (!is.null(descriptor)) return(list(kind = "s7", descriptor = descriptor)) + snapshot <- member_s4_instance_snapshot(value, describe, depth, max_depth, lookup_scope, + reserve = function(n) { + if (n > remaining) return(FALSE) + remaining <<- remaining - n + TRUE + }) + if (!is.null(snapshot)) return(snapshot) if (typeof(value) == "closure") { - list(kind = "function", syntax = member_function_syntax(value), captures = captures(value)) + syntax <- member_function_syntax(value) + scope <- environment(value) + self <- if (!is.null(r6_receiver) && is.environment(scope)) member_binding(scope, "self") + bound <- identical(self[[1L]], "value") && identical(self[[2L]], r6_receiver) && + any(vapply(r6_method_scopes(r6_receiver), identical, logical(1L), scope)) + contexts <- list() + if (bound) { + for (name in intersect(c("private", "super"), member_syntax_names(syntax))) { + record <- member_binding(scope, name) + if (identical(record[[1L]], "value")) { + contexts[name] <- list(describe(record[[2L]], depth + 1L, r6_receiver)) + } + } + } + list(kind = "function", syntax = syntax, captures = captures(value), + r6_bound = bound, r6_contexts = contexts, + r6_classes = if (bound) attr(r6_receiver, "class", exact = TRUE)) } else if (is.environment(value)) { - visit(value, depth) + # Namespace registries can refer back to whole package namespaces. + # Apply the root exclusion recursively rather than copying those + # large scopes through every private registry reference. + if (isNamespace(value) || identical(value, globalenv()) || identical(value, baseenv())) { + return(list(kind = "unknown", reason = "namespace_environment")) + } + visit(value, depth, r6_receiver) } else if (!is.object(value) && is.atomic(value) && length(value) <= 100L) { list(kind = "literal", value = value) - } else if (typeof(value) == "list" && !is.object(value) && - depth <= max_depth && length(value) <= remaining) { - remaining <<- remaining - length(value) - list(kind = "list", elements = lapply(value, describe, depth + 1L)) + } else if (typeof(value) %in% c("list", "pairlist") && !isS4(value) && depth <= max_depth) { + storage <- member_list_storage(value) + if (length(storage) > remaining) return(list(kind = "unknown", reason = "snapshot_budget")) + remaining <<- remaining - length(storage) + list( + kind = "list", type = typeof(value), elements = lapply(storage, describe, depth + 1L), + classes = attr(value, "class", exact = TRUE), default_dollar = default_dollar(value) + ) } else { list(kind = "unknown", reason = "unsupported_storage") } } - visit(env, 0L) + describe(env, 0L) } -member_snapshot_shape <- function(snapshot) { +member_snapshot_shape <- function(snapshot, package = "") { records <- list() collect <- function(record) { - if (identical(record$kind, "environment")) { + if (record$kind %in% c("environment", "r6_instance")) { records[[as.character(record$id)]] <<- record for (field in record$fields) collect(field) } else if (identical(record$kind, "list")) { for (field in record$elements) collect(field) - } else if (identical(record$kind, "s7_instance")) { + } else if (record$kind %in% c("s7_instance", "s4_instance")) { for (field in record$slots) collect(field) + } else if (identical(record$kind, "function")) { + for (context in record$r6_contexts) collect(context) } } collect(snapshot) - decode <- function(record, trail = integer()) { + describe_shape <- function(record, trail = integer()) { if (identical(record$kind, "reference")) { if (record$id %in% trail) { return(member_value(reason = "cycle")) @@ -137,22 +304,56 @@ member_snapshot_shape <- function(snapshot) { return(decode(records[[as.character(record$id)]], trail)) } if (identical(record$kind, "function")) { - return(member_value( + value <- member_value( type = "function", function_expr = record$syntax, closure = lapply(record$captures, member_literal) - )) + ) + if (isTRUE(record$r6_bound)) { + classes <- setdiff(record$r6_classes, "R6") + class <- list(name = if (length(classes)) classes[[1L]] else NULL, package = package) + value$receiver_name <- "self" + value$r6_static_bound <- TRUE + private <- member_lookup(record$r6_contexts, "private") + if (identical(private$kind, "reference") && identical(private$id, utils::tail(trail, 1L))) { + value$r6_private_self <- TRUE + } + value$closure$self <- NULL + for (name in names(record$r6_contexts)) { + stored <- record$r6_contexts[[name]] + context <- decode(stored, trail) + if (identical(stored$kind, "reference")) stored <- records[[as.character(stored$id)]] + context$r6_class <- class + context$r6_role <- name + context$r6_locked <- isTRUE(stored$r6_locked) + fields <- stored$fields + context$r6_bindings <- stats::setNames(vapply(fields, function(field) { + if (identical(field$kind, "active")) { + "active" + } else if (isTRUE(field$locked)) { + if (isTRUE(field$r6_bound)) "method" else "locked" + } else { + "data" + } + }, character(1L)), names(fields)) + value$closure[name] <- list(context) + } + } + return(value) } if (identical(record$kind, "literal")) { return(member_literal(record$value)) } - if (identical(record$kind, "environment")) { + if (record$kind %in% c("environment", "r6_instance")) { if (record$id %in% trail || !isTRUE(record$complete)) { return(member_value(reason = "cycle_or_budget")) } - return(member_value( + value <- member_value( type = "environment", classes = record$classes, - fields = lapply(record$fields, decode, c(trail, record$id)) - )) + fields = lapply(record$fields, decode, c(trail, record$id)), + default_dollar = record$default_dollar + ) + return(if (identical(record$kind, "r6_instance")) member_r6_instance_shape(value, record, package) else value) } + if (identical(record$kind, "s4_instance")) return(member_s4_snapshot_shape(record, decode, trail)) if (identical(record$kind, "s7_instance")) { value <- member_s7_shape(record$descriptor) for (name in names(record$slots)) value$slots[name] <- list(decode(record$slots[[name]], trail)) @@ -164,11 +365,28 @@ member_snapshot_shape <- function(snapshot) { } if (identical(record$kind, "list")) { values <- lapply(record$elements, decode, trail) - fields <- if (is.null(names(values))) list() else values[nzchar(names(values))] - return(member_value(type = "list", fields = fields, elements = values)) + fields <- if (is.null(names(values))) list() else values[!is.na(names(values)) & nzchar(names(values))] + named <- length(values) && !is.null(names(values)) && !anyNA(names(values)) && all(nzchar(names(values))) + return(member_value( + type = if (is.null(record$type)) "list" else record$type, fields = fields, + elements = if (named) NULL else values, + classes = record$classes, default_dollar = record$default_dollar, + named_elements = isTRUE(named), + column_names_only = "data.frame" %in% record$classes && !isTRUE(record$default_dollar) + )) } member_value(reason = if (identical(record$kind, "active")) "getter_not_invoked" else record$reason) } + decode <- function(record, trail = integer()) { + value <- describe_shape(record, trail) + if (nzchar(package)) { + if (!is.null(value$function_expr)) value$metadata <- package + if (!is.null(value$fields) || !is.null(value$slot_types)) value$member_package <- package + } + # Namespace values can repeat hundreds of small structures. Optional + # NULL fields and duplicate named elements need no serialized storage. + value[!vapply(value, is.null, logical(1L))] + } decode(snapshot) } @@ -215,12 +433,58 @@ member_source_input <- function(root) { ) } +member_namespace_lazydata <- function(ns) { + if (!isNamespace(ns)) return(NULL) + info <- member_binding(ns, ".__NAMESPACE__.") + if (!identical(info[[1L]], "value") || !is.environment(info[[2L]])) return(NULL) + record <- member_binding(info[[2L]], "lazydata") + if (identical(record[[1L]], "value") && is.environment(record[[2L]])) record[[2L]] else NULL +} + +member_namespace_export_targets <- function(ns, exports) { + targets <- stats::setNames(exports, exports) + if (!isNamespace(ns)) return(targets) + info <- member_binding(ns, ".__NAMESPACE__.") + if (!identical(info[[1L]], "value") || !is.environment(info[[2L]])) return(targets) + record <- member_binding(info[[2L]], "exports") + if (!identical(record[[1L]], "value") || !is.environment(record[[2L]])) return(targets) + for (name in exports) { + binding <- member_binding(record[[2L]], name) + target <- if (identical(binding[[1L]], "value")) member_name(binding[[2L]]) else NULL + if (!is.null(target)) targets[[name]] <- target + } + targets +} + +# Lazy datasets are public through :: without being ordinary namespace +# exports. Keep their surfaces separate so a private binding with the same +# name remains private and an explicit namespace export retains precedence. +member_public_roots <- function(index, input) { + exports <- if (is.null(input$namespace_exports)) index$exports else input$namespace_exports + roots <- index$package_roots[intersect(exports, names(index$package_roots))] + for (name in names(input$namespace_export_targets)) { + target <- input$namespace_export_targets[[name]] + value <- if (name %in% exports) member_lookup(index$package_roots, target) else NULL + if (is.null(value)) value <- member_lookup(input$lazydata_roots, target) + if (!is.null(value)) roots[name] <- list(value) + } + blocked <- if (is.null(input$namespace_lazydata_blocked)) exports else input$namespace_lazydata_blocked + lazy <- input$lazydata_roots[setdiff(names(input$lazydata_roots), union(blocked, names(roots)))] + c(roots, lazy) +} + member_namespace_input <- function( ns, package = unname(getNamespaceName(ns)), - exports = getNamespaceExports(ns) + exports = getNamespaceExports(ns), lazydata = member_namespace_lazydata(ns), + lazydata_only = FALSE ) { - nms <- ls(ns, all.names = TRUE) - if (length(nms) > 10000L) stop("Namespace exceeds metadata budget") + namespace_names <- ls(ns, all.names = TRUE) + targets <- member_namespace_export_targets(ns, exports) + blocked <- names(targets)[targets %in% namespace_names | targets != names(targets)] + nms <- if (isTRUE(lazydata_only)) character() else namespace_names + namespace_exports <- if (isTRUE(lazydata_only)) character() else exports + lazy_names <- if (is.environment(lazydata)) setdiff(ls(lazydata, all.names = TRUE), blocked) else character() + if (length(nms) + length(lazy_names) > 10000L) stop("Namespace exceeds metadata budget") definitions <- snapshots <- registries <- links <- descriptors <- s4_classes <- s4_roots <- s4_objects <- list() s7_roots <- list() s7_modes <- member_s7_capabilities() @@ -241,7 +505,9 @@ member_namespace_input <- function( if (!is.null(descriptor)) { holder <- new.env(parent = emptyenv()) holder$object <- value - s7_roots[name] <- list(member_snapshot_shape(member_snapshot(holder, s7_modes = s7_modes))$fields$object) + s7_roots[name] <- list(member_snapshot_shape(member_snapshot( + holder, s7_modes = s7_modes, lookup_scope = ns + ))$fields$object) } } if (isS4(value) && any(c("classRepresentation", "ClassUnionRepresentation") %in% @@ -250,13 +516,16 @@ member_namespace_input <- function( if (!is.null(descriptor)) s4_classes[descriptor$name] <- list(descriptor) next } - if (isS4(value) && typeof(value) != "closure" && is.null(s7_roots[[name]])) { + s4_instance <- isS4(value) && !"classGeneratorFunction" %in% attr(value, "class", exact = TRUE) && + is.null(s7_roots[[name]]) + if (s4_instance) { classes <- attr(value, "class", exact = TRUE) if (is.character(classes) && length(classes) == 1L) { owner <- attr(classes, "package", exact = TRUE) s4_objects[name] <- list(list(name = as.character(classes), package = if (is.null(owner)) package else owner)) } } + if (s4_instance) values[name] <- list(value) if (typeof(value) == "closure") { if ("classGeneratorFunction" %in% attr(value, "class", exact = TRUE)) { class <- attr(value, "className", exact = TRUE) @@ -276,7 +545,7 @@ member_namespace_input <- function( } definitions[name] <- list(member_function_syntax(value)) functions[name] <- list(value) - } else if (is.environment(value)) { + } else if (is.environment(value) || typeof(value) %in% c("list", "pairlist") || isS4(value)) { values[name] <- list(value) } else if (!is.object(value) && is.atomic(value) && length(value) <= 256L) { definitions[name] <- list(value) @@ -288,20 +557,41 @@ member_namespace_input <- function( remaining_bindings <- 50000L for (name in names(values)) { value <- values[[name]] - if (isNamespace(value) || identical(value, ns) || identical(value, globalenv()) || - identical(value, baseenv())) { + if (is.environment(value) && (isNamespace(value) || identical(value, ns) || identical(value, globalenv()) || + identical(value, baseenv()))) { + next + } + storage <- if (is.environment(value)) value else if (!isS4(value)) member_list_storage(value) else NULL + size <- if (is.environment(value)) { + length(ls(value, all.names = TRUE)) + } else if (isS4(value)) { + length(attributes(value)) + } else { + length(storage) + } + if (size > remaining_bindings) { + if (is.environment(value)) stop("Namespace exceeds registry budget") + snapshots[name] <- list(list(kind = "unknown", reason = "snapshot_budget")) + registries[name] <- list(character()) next } - size <- length(ls(value, all.names = TRUE)) - if (size > remaining_bindings) stop("Namespace exceeds registry budget") remaining_bindings <- remaining_bindings - size - snapshots[name] <- list(member_snapshot(value, max_bindings = remaining_bindings + size, s7_modes = s7_modes)) - definitions[name] <- list(quote(new.env(parent = emptyenv()))) + snapshots[name] <- list(member_snapshot(value, + max_bindings = remaining_bindings + size, s7_modes = s7_modes, lookup_scope = ns)) + definitions[name] <- list(if (is.environment(value)) quote(new.env(parent = emptyenv())) else quote(list())) methods <- character() - for (field in names(snapshots[[name]]$fields)) { - record <- member_binding(value, field) + fields <- if (snapshots[[name]]$kind %in% c("environment", "r6_instance")) { + snapshots[[name]]$fields + } else if (identical(snapshots[[name]]$kind, "list")) { + snapshots[[name]]$elements + } else { + list() + } + for (field in names(fields)[!is.na(names(fields)) & nzchar(names(fields))]) { + if (identical(fields[[field]]$reason, "missing_value")) next + record <- if (is.environment(value)) member_binding(value, field) else list("value", storage[[field]]) fn <- record[[2L]] - if (identical(record[[1L]], "value") && is.environment(fn)) { + if (identical(record[[1L]], "value") && (is.environment(fn) || typeof(fn) == "list")) { matches <- which(vapply(values, identical, logical(1L), fn)) if (length(matches)) links[[name]][field] <- names(values)[[matches[[1L]]]] } @@ -324,7 +614,7 @@ member_namespace_input <- function( "list2", "arg_match0", "arg_match", "try_fetch", "is_character", "is_list", "is_bool", "abort" ), S7 = member_s7_intrinsics, methods = member_methods_intrinsics) - for (provider in names(providers)) { + for (provider in if (isTRUE(lazydata_only)) character() else names(providers)) { if (!provider %in% loadedNamespaces()) next for (name in providers[[provider]]) { if (name %in% names(definitions)) next @@ -336,9 +626,25 @@ member_namespace_input <- function( } } } + lazy_roots <- stats::setNames(lapply(lazy_names, function(name) { + tryCatch({ + record <- member_binding(lazydata, name, materialize = TRUE) + snapshot <- if (identical(record[[1L]], "value")) { + member_snapshot(record[[2L]], max_bindings = 5000L, s7_modes = s7_modes, lookup_scope = ns) + } else { + list(kind = "unknown", reason = "deferred_dataset") + } + member_snapshot_shape(snapshot, package) + }, error = function(e) member_snapshot_shape(list(kind = "unknown", reason = "dataset_metadata_error"), package)) + }), lazy_names) + subsets <- member_subset_registry(ns, package) list( definitions = definitions, locations = list(), expressions = list(), - exports = exports, registries = registries, + exports = union(namespace_exports, union(lazy_names, names(targets)[targets %in% names(lazy_roots)])), + namespace_exports = namespace_exports, namespace_export_targets = targets, + namespace_lazydata_blocked = blocked, + lazydata_roots = lazy_roots, registries = registries, + subset_registry = subsets$methods, subset_registry_complete = subsets$complete, snapshots = snapshots, links = links, descriptors = descriptors, package = package, intrinsics = intrinsics, s4_classes = s4_classes, s4_roots = s4_roots, s4_objects = s4_objects, s7_roots = s7_roots, s7_capabilities = s7_modes, @@ -382,24 +688,58 @@ member_index_thaw <- function(snapshot) { member_prepare_package <- function(package, lib_paths = .libPaths()) { .libPaths(lib_paths) ns <- asNamespace(package) - input <- member_namespace_input(ns) - index <- member_package_index(input) - for (name in names(index$package_roots)) { - record <- member_binding(ns, name) - value <- record[[2L]] - if (identical(record[[1L]], "value") && is.environment(value) && - "R6ClassGenerator" %in% attr(value, "class", exact = TRUE)) { - index$package_roots[name] <- list(member_r6_runtime_shape(value, index)) + prepare <- function(input, preparation_error = NULL) { + index <- member_package_index(input) + for (name in names(index$package_roots)) { + record <- member_binding(ns, name) + value <- record[[2L]] + if (identical(record[[1L]], "value") && is.environment(value) && + "R6ClassGenerator" %in% attr(value, "class", exact = TRUE)) { + index$package_roots[name] <- list(member_r6_runtime_shape(value, index)) + } } + index$roots <- member_public_roots(index, input) + index$r6_attached <- identical(package, "R6") + for (name in names(index$roots)) index$namespace_roots[paste(package, name, sep = "::")] <- list(index$roots[[name]]) + if (!is.null(preparation_error)) { + index$dataset_only <- TRUE + index$namespace_attempted <- TRUE + index$preparation_error <- preparation_error + } + index$identity <- list( + package = package, path = find.package(package), + version = as.character(utils::packageVersion(package)), r = as.character(getRversion()) + ) + member_index_freeze(index) } - index$roots <- index$package_roots[intersect(index$exports, names(index$package_roots))] - index$r6_attached <- identical(package, "R6") - for (name in names(index$roots)) index$namespace_roots[paste(package, name, sep = "::")] <- list(index$roots[[name]]) - index$identity <- list( - package = package, path = find.package(package), - version = as.character(utils::packageVersion(package)), r = as.character(getRversion()) - ) - member_index_freeze(index) + tryCatch(prepare(member_namespace_input(ns)), error = function(e) { + message <- conditionMessage(e) + lazydata <- member_namespace_lazydata(ns) + if (!grepl("^Namespace exceeds .* budget$", message) || !is.environment(lazydata) || + !length(ls(lazydata, all.names = TRUE))) stop(e) + # A large normal namespace must not hide its lightweight public data. + prepare(member_namespace_input(ns, lazydata = lazydata, lazydata_only = TRUE), message) + }) +} + +member_prepare_startup_data <- function(packages, lib_paths = .libPaths()) { + .libPaths(lib_paths) + snapshots <- list() + for (package in unique(packages)) { + snapshot <- tryCatch({ + ns <- asNamespace(package) + lazydata <- member_namespace_lazydata(ns) + if (!is.environment(lazydata) || !length(ls(lazydata, all.names = TRUE))) next + input <- member_namespace_input(ns, package, lazydata = lazydata, lazydata_only = TRUE) + index <- member_package_index(input) + index$dataset_only <- TRUE + index$identity <- list(package = package, path = find.package(package), + version = as.character(utils::packageVersion(package)), r = as.character(getRversion())) + member_index_freeze(index) + }, error = function(e) NULL) + if (!is.null(snapshot)) snapshots[package] <- list(snapshot) + } + snapshots } member_resolve_packages <- function(pkgs, lib_paths, prepare = TRUE, namespace_packages = character(), prepare_s3 = TRUE) { diff --git a/R/member-r6.R b/R/member-r6.R index c2d2243b..49f2d33b 100644 --- a/R/member-r6.R +++ b/R/member-r6.R @@ -145,7 +145,7 @@ member_r6_member_info <- function(value, receiver) { # methods independently of lock_objects; active writes invoke opaque setters. # A writable data field remains writable after receiving a function. member_r6_writable <- function(object, name) { - !any(member_lookup(object$r6_bindings, name) %in% c("method", "active")) && + !any(member_lookup(object$r6_bindings, name) %in% c("method", "active", "locked")) && (!isTRUE(object$r6_locked) || name %in% names(object$fields)) } @@ -316,3 +316,76 @@ member_r6_runtime_shape <- function(generator, index, trail = list()) { shape$fields$new$r6_package <- index$package shape } + +# Stored R6 instances are described from inert snapshots of their current +# public/private environments. Preserve bound receiver syntax without creating +# another instance or invoking an active binding. +member_r6_instance_shape <- function(value, snapshot = NULL, package = "") { + if (!"R6" %in% value$classes) return(value) + classes <- setdiff(value$classes, "R6") + class <- list(name = if (length(classes)) classes[[1L]] else NULL, package = package) + # Package extraction assigns namespace method identities after decoding. + # Updating their owner must not expand already decorated closure scopes. + if (identical(value$r6_role, "instance")) { + value$r6_class <- class + value$r6_private$r6_class <- value$r6_super$r6_class <- class + for (name in names(value$fields)) value$fields[[name]]$r6_owner <- class + return(value) + } + enclosure <- member_lookup(value$fields, ".__enclos_env__") + enclosure_record <- member_lookup(snapshot$fields, ".__enclos_env__") + private <- if (!is.null(value$r6_private)) value$r6_private else member_lookup(enclosure$fields, "private") + super <- if (!is.null(value$r6_super)) value$r6_super else member_lookup(enclosure$fields, "super") + if (is.null(private)) private <- member_lookup(value$fields, "private") + if (is.null(super)) super <- member_lookup(value$fields, "super") + if (is.null(private)) private <- member_value(type = "environment", fields = list()) + if (is.null(super)) super <- member_value(type = "environment", fields = list()) + private$r6_class <- super$r6_class <- class + private$r6_role <- "private" + super$r6_role <- "super" + locked <- isTRUE(snapshot$r6_locked) + decorate <- function(fields, records, section) { + bindings <- character() + for (name in names(fields)) { + field <- fields[[name]] + record <- member_lookup(records, name) + active <- identical(record$kind, "active") || identical(field$reason, "active_property") || + identical(field$reason, "getter_not_invoked") + method <- isTRUE(record$r6_bound) + field$r6_owner <- class + field$r6_member_kind <- if (active) "active binding" else paste(section, if (method) "method" else "field") + if (active) { + field$reason <- "active_property" + } else if (method) { + field$receiver_name <- "self" + field$closure$self <- NULL + } + bindings[name] <- if (active) "active" else if (method && !isFALSE(record$locked)) "method" else + if (isTRUE(record$locked)) "locked" else "data" + fields[name] <- list(field) + } + list(fields = fields, bindings = bindings) + } + private_record <- member_lookup(enclosure_record$fields, "private") + if (is.null(private_record)) private_record <- member_lookup(snapshot$fields, "private") + private_result <- decorate(private$fields, private_record$fields, "private") + super_record <- member_lookup(enclosure_record$fields, "super") + if (is.null(super_record)) super_record <- member_lookup(snapshot$fields, "super") + super_result <- decorate(super$fields, super_record$fields, "public") + super$fields <- super_result$fields + super$r6_bindings <- super_result$bindings + private$fields <- private_result$fields + private$r6_bindings <- private_result$bindings + private$r6_locked <- locked + value$fields[c(".__enclos_env__", "self", "private", "super")] <- NULL + public_result <- decorate(value$fields, snapshot$fields, "public") + value$fields <- public_result$fields + value$type <- "environment" + value$r6_class <- class + value$r6_role <- "instance" + value$r6_private <- private + value$r6_super <- super + value$r6_locked <- locked + value$r6_bindings <- public_result$bindings + value +} diff --git a/R/member-s4.R b/R/member-s4.R index 40daf551..2cddf2e7 100644 --- a/R/member-s4.R +++ b/R/member-s4.R @@ -279,6 +279,75 @@ member_s4_runtime_descriptor <- function(value) { virtual = isTRUE(attr(value, "virtual", exact = TRUE)), complete = TRUE) } +# Static instances store ordinary slots as attributes. Read their already +# loaded class record and storage directly, without initialization or validity +# methods. Only the built-in data-part slot reader handles non-attribute storage. +member_s4_instance_snapshot <- function(value, describe, depth, max_depth, + lookup_scope, reserve = function(n) TRUE) { + if (!isS4(value) || any(c("classGeneratorFunction", "classRepresentation", "ClassUnionRepresentation") %in% + attr(value, "class", exact = TRUE))) { + return(NULL) + } + if (depth > max_depth) return(list(kind = "unknown", reason = "snapshot_depth")) + class <- attr(value, "class", exact = TRUE) + if (!is.character(class) || length(class) != 1L || is.na(class) || !nzchar(class)) { + return(list(kind = "unknown", reason = "unknown_s4_class")) + } + package <- attr(class, "package", exact = TRUE) + if (!is.null(package) && (!is.character(package) || length(package) != 1L || is.na(package))) { + return(list(kind = "unknown", reason = "unknown_s4_class")) + } + key <- paste0(".__C__", class) + descriptor <- NULL + scopes <- list(lookup_scope) + if (!is.null(package) && package %in% loadedNamespaces()) scopes[[length(scopes) + 1L]] <- asNamespace(package) + for (start in scopes) { + scope <- start + while (is.environment(scope) && !identical(scope, emptyenv())) { + if (exists(key, scope, inherits = FALSE)) { + record <- member_binding(scope, key) + candidate <- if (identical(record[[1L]], "value") && isS4(record[[2L]])) { + member_s4_runtime_descriptor(record[[2L]]) + } + if (!is.null(candidate) && identical(candidate$name, as.character(class)) && + (is.null(package) || is.null(candidate$package) || identical(package, candidate$package))) { + descriptor <- candidate + break + } + } + scope <- parent.env(scope) + } + if (!is.null(descriptor)) break + } + if (is.null(descriptor) || isFALSE(descriptor$complete)) { + return(list(kind = "unknown", reason = "unknown_s4_class")) + } + names <- names(descriptor$slots) + if (!isTRUE(reserve(length(names)))) return(list(kind = "unknown", reason = "snapshot_budget")) + stored <- attributes(value) + if (".Data" %in% names && !".Data" %in% names(stored)) { + # slot() is a fixed .Call wrapper, not an S4 generic or user getter. + data <- tryCatch(methods::slot(value, ".Data"), error = function(e) NULL) + if (!is.null(data)) stored[".Data"] <- list(data) + } + names <- intersect(names, names(stored)) + slots <- stats::setNames(lapply(names, function(name) describe(stored[[name]], depth + 1L)), names) + list(kind = "s4_instance", descriptor = descriptor, slots = slots, callable = typeof(value) == "closure") +} + +member_s4_snapshot_shape <- function(record, decode, trail = integer()) { + descriptor <- record$descriptor + value <- member_value(type = descriptor$name, + s4_class = list(name = descriptor$name, package = descriptor$package), + slot_types = descriptor$slots, slots = lapply(record$slots, decode, trail)) + if (isTRUE(record$callable)) { + fn <- member_lookup(value$slots, ".Data") + value$function_expr <- fn$function_expr + value$closure <- fn$closure + } + value +} + member_s4_dependencies <- function(classes, references = list()) { out <- list() queue <- c(classes, list(list(slots = references))) diff --git a/R/member-source.R b/R/member-source.R index 100e9e55..ff51d7ab 100644 --- a/R/member-source.R +++ b/R/member-source.R @@ -51,7 +51,7 @@ member_scope_index <- function(node) { writes <- lapply(seq_along(children), function(i) { if (nzchar(assigned[[i]])) assigned[[i]] else member_assigned_names(children[[i]]) }) - mandatory <- which(!nzchar(assigned) | vapply(reads, function(names) { + mandatory <- which(!nzchar(assigned) | startsWith(assigned, "$.") | startsWith(assigned, "[[.") | vapply(reads, function(names) { any(names %in% c("setClass", "setClassUnion")) }, logical(1L))) histories <- split(which(nzchar(assigned)), assigned[nzchar(assigned)]) diff --git a/R/workspace.R b/R/workspace.R index 901bff9b..7915b4b6 100644 --- a/R/workspace.R +++ b/R/workspace.R @@ -183,7 +183,8 @@ MemberMetadataCache <- R6::R6Class( # Selection, imports and dispatch need only this compact catalog. # Keep it in the snapshot budget without decoding function bodies. fields <- c("package", "generation", "roots", "exports", "namespace_roots", "classes", "class_scope", "symbol_scope", - "s4_classes", "s4_dependencies", "s7_dependencies", "s7_capabilities") + "s4_classes", "s4_dependencies", "s7_dependencies", "s7_capabilities", "dataset_only", "namespace_attempted", + "subset_registry", "subset_registry_complete") entry <- list( compressed = memCompress(serialize(snapshot, NULL), "gzip"), catalog = snapshot[intersect(fields, names(snapshot))] @@ -352,6 +353,7 @@ Workspace <- R6::R6Class("Workspace", namespace_file_mt = NULL, startup_packages = NULL, + startup_member_packages = NULL, loaded_packages = NULL, help_cache = NULL, parse_cache = NULL, # Performance: Cache parse results by content hash @@ -371,6 +373,7 @@ Workspace <- R6::R6Class("Workspace", self$global_env <- GlobalEnv$new(self$documents) self$namespaces <- collections::dict() self$startup_packages <- workspace_startup_packages() + self$startup_member_packages <- character() self$loaded_packages <- self$startup_packages for (pkgname in self$loaded_packages) { self$namespaces$set(pkgname, PackageNamespace$new(pkgname)) @@ -396,6 +399,9 @@ Workspace <- R6::R6Class("Workspace", private$import_globals_cache <- collections::dict() self$type_hierarchy_cache <- collections::dict() self$member_metadata <- MemberMetadataCache$new(32 * 1024^2, max_entries = 16L) + if (isTRUE(lsp_settings$get("member_completion"))) { + self$load_startup_members() + } self$s3_metadata <- S3MetadataCache$new() private$s3_package_cache <- ByteLruCache$new(1024^2, max_entries = 64L) if (isTRUE(lsp_settings$get("s3_dispatch"))) { @@ -403,6 +409,20 @@ Workspace <- R6::R6Class("Workspace", } }, + load_startup_members = function() { + members <- workspace_startup_members() + packages <- intersect(self$startup_packages, names(members)) + self$startup_member_packages <- union(intersect(self$startup_packages, self$startup_member_packages), packages) + for (package in packages) { + # A document may already have upgraded this dataset-only + # seed to a complete namespace model. Keep that valid model. + current <- self$member_metadata$catalog(package) + if (isTRUE(nzchar(current$generation))) next + self$member_metadata$set(package, members[[package]], protect = self$startup_member_packages) + } + invisible(NULL) + }, + load_package = function(pkgname) { if (!(pkgname %in% self$loaded_packages)) { ns <- tryCatch(self$get_namespace(pkgname), error = function(e) NULL) @@ -1117,3 +1137,27 @@ workspace_startup_s3 <- local({ cached } }) + +# Startup datasets have no library() or namespace expression in the document. +# Prepare their bounded member models once in an isolated worker, then share +# only snapshots across workspaces. Failed workers can retry on a later setup. +workspace_startup_members <- local({ + cached <- NULL + previous <- NULL + function() { + requested <- list(packages = workspace_startup_packages(), lib_paths = .libPaths()) + if (!is.null(cached) && identical(requested, previous)) return(cached) + if (!length(requested$packages)) return(list()) + result <- tryCatch(callr::r(package_call(member_prepare_startup_data), args = requested, + system_profile = FALSE, user_profile = FALSE, + timeout = if (identical(Sys.getenv("R_COVR"), "true")) 60 else 8), + error = function(e) { + logger$info("startup member metadata:", e) + NULL + }) + if (is.null(result)) return(list()) + cached <<- result + previous <<- requested + result + } +}) diff --git a/tests/testthat/test-languagebase.R b/tests/testthat/test-languagebase.R index 0237a4c1..0d0462b4 100644 --- a/tests/testthat/test-languagebase.R +++ b/tests/testthat/test-languagebase.R @@ -177,6 +177,16 @@ test_that("clients drain output before reporting EOF without polling a closed st expect_error(client$fetch(blocking = TRUE), "Server is dead.", fixed = TRUE) }) +test_that("clients retain subprocess startup diagnostics after failure", { + client <- LanguageClient$new(file.path(R.home("bin"), "R"), + c("--vanilla", "--slave", "-e", 'stop("client startup marker")')) + withr::defer(if (client$process$is_alive()) client$process$kill()) + client$process$wait(10000) + expect_false(client$process$is_alive()) + expect_error(client$fetch(blocking = TRUE), "Server is dead.", fixed = TRUE) + expect_match(paste(client$process$read_error_lines(), collapse = "\n"), "client startup marker", fixed = TRUE) +}) + test_that("buffered blocking fetch applies its timeout to a partial body", { bytes <- message_buffer_frame("body") base <- BufferedLanguageBase$new(list(utils::head(bytes, -1L))) diff --git a/tests/testthat/test-member-completion.R b/tests/testthat/test-member-completion.R index 24bca80d..ca8f55aa 100644 --- a/tests/testthat/test-member-completion.R +++ b/tests/testthat/test-member-completion.R @@ -372,10 +372,101 @@ test_that("Runtime R6 metadata preserves method and active binding kinds", { expect_false(file.exists(marker)) }) +test_that("Static classed namespace environments preserve their own member surfaces", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$first <- new.env(parent = emptyenv()) + ns$second <- new.env(parent = emptyenv()) + class(ns$first) <- class(ns$second) <- "static_commands" + ns$first$fetch <- function(key, limit = 1L) { + writeLines("method", marker) + list(value = key) + } + ns$second$store <- function(key, value) NULL + nested <- new.env(parent = emptyenv()) + class(nested) <- "nested_commands" + nested$leaf <- list(done = TRUE) + ns$first$nested <- nested + ns$second$nested <- nested + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "staticfixture", exports = c("first", "second") + ))) + snapshots <- list(staticfixture = snapshot) + for (code in c("staticfixture::first$", "library(staticfixture)\nfirst$", + "api <- staticfixture::first\ncopy <- api\ncopy$")) { + expect_identical(member_labels(code, snapshots), c("fetch", "nested"), info = code) + } + expect_identical(member_labels("staticfixture::second$", snapshots), c("nested", "store")) + expect_identical(member_labels("staticfixture::first$nested$", snapshots), "leaf") + expect_identical(member_labels(paste0( + "choose <- function(flag) if (flag) staticfixture::first$nested else staticfixture::second$nested\n", + "choose(unknown)$leaf$" + ), snapshots), "done") + expect_identical(member_labels("staticfixture::first$fetch(stop(\"argument\"))$", snapshots), "value") + item <- member_items("staticfixture::first$fe", snapshots)[[1L]] + expect_identical(item$detail, "fetch(key, limit = 1L)") + expect_identical(item$kind, CompletionItemKind$Method) + expect_identical(item$data$function_id, "first$fetch") + expect_identical(item$data$package, "staticfixture") + expect_false(file.exists(marker)) +}) + +test_that("Static environment snapshots respect custom dollar dispatch and deferred bindings", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$api <- new.env(parent = emptyenv()) + class(ns$api) <- "static_commands" + makeActiveBinding("active", function() { + writeLines("getter", marker) + stop("getter") + }, ns$api) + delayedAssign("deferred", { + writeLines("promise", marker) + stop("promise") + }, assign.env = ns$api) + ns$opaque <- new.env(parent = emptyenv()) + ns$opaque$hidden <- list(done = TRUE) + class(ns$opaque) <- "static_fixture_opaque" + methods <- get(".__S3MethodsTable__.", asNamespace("base")) + registered_method <- "$.static_fixture_opaque" + assign(registered_method, function(x, name) { + writeLines("dispatch", marker) + stop("dispatch") + }, methods) + withr::defer(rm(list = registered_method, envir = methods)) + snapshots <- list(staticfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "staticfixture", exports = c("api", "opaque") + )))) + expect_identical(member_labels("staticfixture::api$", snapshots), c("active", "deferred")) + expect_length(member_labels("staticfixture::api$active$", snapshots), 0L) + expect_length(member_labels("staticfixture::api$deferred$", snapshots), 0L) + expect_length(member_labels("staticfixture::opaque$", snapshots), 0L) + expect_length(member_labels("staticfixture::opaque$hidden$", snapshots), 0L) + expect_length(member_labels('structure(staticfixture::api, class = "unknown_commands")$', snapshots), 0L) + expect_length(member_labels("structure(staticfixture::api, class = unknown)$", snapshots), 0L) + expect_length(member_labels(paste0( + "choose <- function(flag) if (flag) staticfixture::api else staticfixture::opaque\nchoose(unknown)$" + ), snapshots), 0L) + for (method in c("$.static_commands", "$.default")) { + code <- sprintf("`%s` <- function(x, name) stop(\"dispatch\")\napi <- staticfixture::api\napi$", method) + expect_length(member_labels(code, snapshots), 0L) + } + expect_false(file.exists(marker)) + expect_identical(member_binding(ns$api, "deferred")[[1L]], "deferred") +}) + test_that("Installed Redux members resolve without a Redis connection", { skip_if_not_installed("redux") marker <- withr::local_tempfile() snapshot <- member_prepare_package("redux") + for (code in c("redux::redis$", "library(redux)\nredis$", "commands <- redux::redis\ncommands$")) { + expect_true(all(c("GET", "SET", "PING") %in% member_labels(code, list(redux = snapshot), limit = 300L))) + items <- member_items(paste0(code, "GE"), list(redux = snapshot)) + item <- Filter(function(item) identical(item$label, "GET"), items)[[1L]] + expect_identical(item$detail, "GET(key)") + expect_identical(item$kind, CompletionItemKind$Method) + expect_identical(item$data$function_id, "redis$GET") + } expect_false("R6_redis_api" %in% names(snapshot$roots)) expect_length(member_labels("redux::R6_redis_api$new()$", list(redux = snapshot)), 0L) for (code in c('redis <- redux::hiredis(host = "127.0.0.1")', diff --git a/tests/testthat/test-member-external-subset.R b/tests/testthat/test-member-external-subset.R new file mode 100644 index 00000000..a4149c03 --- /dev/null +++ b/tests/testthat/test-member-external-subset.R @@ -0,0 +1,133 @@ +external_subset_fixture <- function(code, snapshots) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + document <- Document$new("file:///external-subset.R", "r", 1L, content) + parsed <- parse_document(document$uri, content) + parsed$version <- 1L + document$update_parse_data(parsed) + metadata <- MemberMetadataCache$new(32 * 1024^2) + for (package in names(snapshots)) metadata$set(package, snapshots[[package]]) + items <- member_completion(document$uri, list(member_metadata = metadata), document, + list(row = length(content) - 1L, col = nchar(tail(content, 1L))), FALSE, 200L) + vapply(items, `[[`, character(1L), "label") +} + +external_subset_snapshot <- function(ns, package = "subsetowner", exports = "record") { + member_index_freeze(member_package_index(member_namespace_input(ns, + package = package, exports = exports))) +} + +external_subset_registration <- function(operator, class, fn, .local_envir = parent.frame()) { + table <- get(".__S3MethodsTable__.", asNamespace("base")) + key <- paste0(operator, ".", class) + old <- if (exists(key, table, inherits = FALSE)) get(key, table, inherits = FALSE) else NULL + registerS3method(operator, class, fn, envir = asNamespace("base")) + withr::defer({ + if (is.null(old)) rm(list = key, envir = table) else assign(key, old, table) + }, envir = .local_envir) +} + +test_that("Externally registered subset methods never fall back to stored list fields", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(raw = list(wrong = TRUE)), class = "member_external_subset") + method <- function(x, i, ...) { + writeLines("called", marker) + list(virtual = TRUE) + } + for (operator in c("[", "[[")) external_subset_registration(operator, "member_external_subset", method) + expect_identical(names(ns$record[[1L]]), "virtual") + unlink(marker) + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + for (code in c("subsetowner::record[[1L]]$", "subsetowner::record[1L]$")) { + expect_length(external_subset_fixture(code, snapshots), 0L) + } + expect_false(file.exists(marker)) +}) + +test_that("Subset registrations from another installed namespace are recognized", { + # Resolve the trusted built-in lazy method before taking the snapshot. + get("[[.person", asNamespace("utils")) + get("[[.person", get(".__S3MethodsTable__.", asNamespace("base"))) + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(raw = list(wrong = TRUE)), class = "person") + snapshot <- external_subset_snapshot(ns) + expect_match(snapshot$subset_registry[["[[.person"]], "namespace:utils:", fixed = TRUE) + expect_length(external_subset_fixture("subsetowner::record[[1L]]$", list(subsetowner = snapshot)), 0L) +}) + +test_that("Registered subset precedence respects the class chain and local overrides", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(raw = list(wrong = TRUE)), class = c("member_external_first", "member_owned_second")) + ns$`[[.member_owned_second` <- function(x, i) list(owned = TRUE) + external_subset_registration("[[", "member_external_first", function(x, i) { + writeLines("called", marker) + list(external = TRUE) + }) + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + expect_length(external_subset_fixture("subsetowner::record[[1L]]$", snapshots), 0L) + expect_identical(external_subset_fixture(paste0( + "`[[.member_external_first` <- function(x, i) list(local = TRUE)\n", + "subsetowner::record[[1L]]$" + ), snapshots), "local") + expect_false(file.exists(marker)) + + ns$record <- structure(list(raw = list(wrong = TRUE)), class = c("member_owned_first", "member_external_first")) + ns$`[[.member_owned_first` <- function(x, i) list(owned = TRUE) + external_subset_registration("[[", "member_owned_first", ns$`[[.member_owned_first`) + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + expect_identical(external_subset_fixture("subsetowner::record[[1L]]$", snapshots), "owned") +}) + +test_that("Later namespace snapshots invalidate stale default subset assumptions", { + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(raw = list(wrong = TRUE)), class = "member_later_subset") + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + expect_identical(external_subset_fixture("subsetowner::record[[1L]]$", snapshots), "wrong") + marker <- withr::local_tempfile() + external_subset_registration("[[", "member_later_subset", function(x, i) { + writeLines("called", marker) + list(external = TRUE) + }) + other <- new.env(parent = baseenv()) + snapshots$subsetprovider <- external_subset_snapshot(other, "subsetprovider", character()) + expect_length(external_subset_fixture("subsetowner::record[[1L]]$", snapshots), 0L) + expect_false(file.exists(marker)) +}) + +test_that("Standard data-frame subset methods retain known column structure", { + ns <- new.env(parent = baseenv()) + ns$record <- data.frame(row = 1L) + ns$record$items <- list(list(done = TRUE)) + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + expect_identical(external_subset_fixture("subsetowner::record[[2L]][[1L]]$", snapshots), "done") + expect_identical(external_subset_fixture("subsetowner::record[[\"items\"]][[1L]]$", snapshots), "done") +}) + +test_that("A fresh isolated worker recognizes standard dataframe subset methods", { + registry <- callr::r(function(prepare) { + environment(prepare) <- asNamespace("languageserver") + ns <- new.env(parent = baseenv()) + ns$record <- data.frame(row = 1L) + ns$record$items <- list(list(done = TRUE)) + prepare(ns, "coldowner") + }, args = list(member_subset_registry), system_profile = FALSE, user_profile = FALSE) + expect_true(registry$complete) + expect_identical(unname(registry$methods[c("[.data.frame", "[[.data.frame")]), c("base", "base")) +}) + +test_that("Uncertain registered method bindings are not forced during preparation", { + marker <- withr::local_tempfile() + table <- get(".__S3MethodsTable__.", asNamespace("base")) + key <- "[[.member_active_subset" + makeActiveBinding(key, function() { + writeLines("getter", marker) + stop("must not invoke method lookup") + }, table) + withr::defer(rm(list = key, envir = table)) + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(raw = list(wrong = TRUE)), class = "member_active_subset") + snapshots <- list(subsetowner = external_subset_snapshot(ns)) + expect_length(external_subset_fixture("subsetowner::record[[1L]]$", snapshots), 0L) + expect_false(file.exists(marker)) +}) diff --git a/tests/testthat/test-member-inference-review.R b/tests/testthat/test-member-inference-review.R new file mode 100644 index 00000000..1252de68 --- /dev/null +++ b/tests/testthat/test-member-inference-review.R @@ -0,0 +1,85 @@ +inference_review_labels <- function(code, snapshots = list(), point = NULL) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + document <- Document$new("file:///inference-review.R", language = "r", version = 1L, content = content) + data <- parse_document(document$uri, content) + data$version <- 1L + document$update_parse_data(data) + metadata <- collections::dict() + for (package in names(snapshots)) metadata$set(package, member_index_thaw(snapshots[[package]])) + if (is.null(point)) point <- list(row = length(content) - 1L, col = nchar(tail(content, 1L))) + items <- member_completion(document$uri, list(member_metadata = metadata), document, + point, FALSE, 200L) + vapply(items, `[[`, character(1L), "label") +} + +test_that("Removing class attributes preserves source and static namespace members", { + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(alpha = list(done = TRUE)), class = "review_record") + snapshots <- list(reviewfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "reviewfixture", exports = "record" + )))) + for (class in c("NULL", "character()", "base::character()")) { + expect_identical(inference_review_labels(sprintf("structure(list(alpha = 1), class = %s)$", class)), "alpha") + expect_identical(inference_review_labels(sprintf("structure(reviewfixture::record, class = %s)$alpha$", class), + snapshots), "done") + } + expect_length(inference_review_labels(paste0( + "character <- function() stop(\"unknown class\")\n", + "structure(reviewfixture::record, class = character())$" + ), snapshots), 0L) +}) + +test_that("Named namespace elements survive class changes and custom dollar dispatch", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$record <- list(alpha = list(done = TRUE)) + ns$custom <- structure(list(alpha = list(done = TRUE)), class = "review_custom_dollar") + ns$`$.review_custom_dollar` <- function(x, name) { + writeLines("dispatch", marker) + stop("custom dollar") + } + snapshots <- list(reviewfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "reviewfixture", exports = c("record", "custom") + )))) + for (class in c("NULL", "character()", "\"list\"")) { + expect_identical(inference_review_labels(sprintf("structure(reviewfixture::record, class = %s)[[1L]]$", class), + snapshots), "done") + } + expect_identical(inference_review_labels("reviewfixture::custom[[1L]]$", snapshots), "done") + expect_identical(inference_review_labels(paste0( + "choose <- function() if(length(structure(reviewfixture::record, class = \"list\")) == 1L) ", + "list(selected = TRUE) else list(other = TRUE)\nchoose()$" + ), snapshots), "selected") + expect_false(file.exists(marker)) +}) + +test_that("Local dollar overrides invalidate static members inside function scopes", { + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(alpha = TRUE), class = "review_record") + snapshots <- list(reviewfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "reviewfixture", exports = "record" + )))) + for (method in c("$.review_record", "$.default")) { + code <- paste(c("f <- function() {", + sprintf("`%s` <- function(x, name) list(other = TRUE)", method), + "reviewfixture::record$al", "}"), collapse = "\n") + expect_length(inference_review_labels(code, snapshots, + point = list(row = 2L, col = nchar("reviewfixture::record$al"))), 0L) + } +}) + +test_that("Static namespace indexing respects known subset methods", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$record <- structure(list(alpha = list(done = TRUE)), class = "review_subset") + ns$`[[.review_subset` <- function(x, i, j = "default", ...) { + writeLines("method", marker) + if (j == "other") list(other = TRUE) else list(actual = TRUE) + } + snapshots <- list(reviewfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "reviewfixture", exports = "record" + )))) + expect_identical(inference_review_labels("reviewfixture::record[[1L]]$", snapshots), "actual") + expect_identical(inference_review_labels('reviewfixture::record[[1L, j = "other"]]$', snapshots), "other") + expect_false(file.exists(marker)) +}) diff --git a/tests/testthat/test-member-lazydata.R b/tests/testthat/test-member-lazydata.R new file mode 100644 index 00000000..89550c75 --- /dev/null +++ b/tests/testthat/test-member-lazydata.R @@ -0,0 +1,200 @@ +lazydata_fixture <- function(code, snapshots, startup = character(), point = NULL) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + document <- Document$new("file:///lazydata-members.R", "r", 1L, content) + data <- parse_document(document$uri, content) + data$version <- 1L + document$update_parse_data(data) + metadata <- MemberMetadataCache$new(32 * 1024^2) + for (package in names(snapshots)) metadata$set(package, snapshots[[package]]) + if (is.null(point)) point <- list(row = length(content) - 1L, col = nchar(tail(content, 1L))) + list(document = document, point = point, + workspace = list(member_metadata = metadata, startup_packages = startup)) +} + +lazydata_labels <- function(code, snapshots, startup = character(), point = NULL) { + fixture <- lazydata_fixture(code, snapshots, startup, point) + items <- member_completion(fixture$document$uri, fixture$workspace, fixture$document, fixture$point, FALSE, 200L) + vapply(items, `[[`, character(1L), "label") +} + +lazydata_test_package <- function(.local_envir = parent.frame()) { + root <- withr::local_tempdir(.local_envir = .local_envir) + package <- "lazycolumnfixture" + path <- file.path(root, package) + library <- file.path(root, "library") + marker <- file.path(root, "getter-called") + dir.create(file.path(path, "R"), recursive = TRUE) + dir.create(file.path(path, "data")) + dir.create(library) + writeLines(c( + "Package: lazycolumnfixture", "Version: 0.0.1", "Title: Lazy Dataset Fixture", + "Description: Test lazy dataset completion without external packages.", "License: MIT", + "Author: Test Fixture", "Maintainer: Test Fixture ", "LazyData: true" + ), file.path(path, "DESCRIPTION")) + writeLines('S3method("$", lazy_fixture_frame)', file.path(path, "NAMESPACE")) + writeLines(c( + "`$.lazy_fixture_frame` <- function(x, name) {", + paste0("writeLines(\"getter\", ", deparse(marker), ")"), + ".subset2(x, name)", "}" + ), file.path(path, "R", "methods.R")) + columns <- c("alpha", "beta", "a b") + observations <- data.frame(alpha = seq_len(150L), beta = rep("value", 150L), + `a b` = rep(TRUE, 150L), check.names = FALSE) + class(observations) <- c("lazy_fixture_frame", "lazy_fixture_table", "data.frame") + save(observations, file = file.path(path, "data", "observations.rda"), version = 2) + install <- system2(file.path(R.home("bin"), "R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(library)), shQuote(path)), + stdout = TRUE, stderr = TRUE) + status <- attr(install, "status") + if (is.null(status)) status <- 0L + expect_identical(status, 0L, info = paste(install, collapse = "\n")) + if (status != 0L) stop("Lazy dataset fixture installation failed") + withr::defer(if (package %in% loadedNamespaces()) unloadNamespace(package), envir = .local_envir) + list(root = root, package = package, library = library, columns = columns, marker = marker) +} + +test_that("Lazy datasets retain public visibility without exposing private bindings", { + state <- new.env(parent = emptyenv()) + state$loads <- 0L + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$shared <- list(private = TRUE) + ns$exported <- list(namespace = TRUE) + data <- new.env(parent = emptyenv()) + delayedAssign("shared", { + state$loads <- state$loads + 1L + data.frame(alpha = 1L, beta = 2L) + }, assign.env = data) + delayedAssign("exported", stop("shadowed dataset must not load"), assign.env = data) + delayedAssign("broken", stop("bad dataset"), assign.env = data) + makeActiveBinding("active", function() { + writeLines("getter", marker) + stop("getter") + }, data) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "lazyfixture", exports = "exported", lazydata = data + ))) + snapshots <- list(lazyfixture = snapshot) + expect_identical(state$loads, 1L) + expect_identical(lazydata_labels("lazyfixture::shared$", snapshots), c("alpha", "beta")) + expect_identical(lazydata_labels("library(lazyfixture)\nshared$", snapshots), c("alpha", "beta")) + expect_identical(lazydata_labels("copy <- lazyfixture::shared\ncopy$", snapshots), c("alpha", "beta")) + expect_identical(lazydata_labels("lazyfixture:::shared$", snapshots), "private") + expect_identical(lazydata_labels("lazyfixture::exported$", snapshots), "namespace") + expect_length(lazydata_labels("lazyfixture::broken$", snapshots), 0L) + expect_length(lazydata_labels("lazyfixture::active$", snapshots), 0L) + expect_length(lazydata_labels("shared$", snapshots), 0L) + expect_identical(state$loads, 1L) + expect_false(file.exists(marker)) +}) + +test_that("Implicit startup datasets follow R masking and lexical shadowing", { + make_snapshot <- function(package, column) { + ns <- new.env(parent = baseenv()) + data <- new.env(parent = emptyenv()) + data$shared <- stats::setNames(data.frame(1L), column) + member_index_freeze(member_package_index(member_namespace_input( + ns, package = package, exports = character(), lazydata = data + ))) + } + snapshots <- list(earlier = make_snapshot("earlier", "first"), later = make_snapshot("later", "second"), + explicit = make_snapshot("explicit", "third")) + expect_identical(lazydata_labels("shared$", snapshots, c("earlier", "later")), "second") + expect_identical(lazydata_labels("library(explicit)\nshared$", snapshots, c("earlier", "later")), "third") + expect_identical(lazydata_labels("shared <- list(local = TRUE)\nshared$", snapshots, c("earlier", "later")), "local") + expect_length(lazydata_labels("shared$", snapshots), 0L) + expect_length(lazydata_labels("f <- function(shared) { shared$\n}", snapshots, c("earlier", "later"), + point = list(row = 0L, col = nchar("f <- function(shared) { shared$"))), 0L) +}) + +test_that("Data-frame subclasses propose stored columns without invoking dollar methods", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$table <- structure(list(alpha = list(raw = TRUE), beta = 1L), class = c("lazy_table", "data.frame")) + ns$`$.lazy_table` <- function(x, name) { + writeLines("method", marker) + list(virtual = TRUE) + } + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "lazyfixture", exports = "table" + ))) + snapshots <- list(lazyfixture = snapshot) + expect_identical(lazydata_labels("lazyfixture::table$", snapshots), c("alpha", "beta")) + expect_length(lazydata_labels("lazyfixture::table$alpha$", snapshots), 0L) + expect_identical(lazydata_labels('structure(lazyfixture::table, class = "list")$alpha$', snapshots), "raw") + expect_false(file.exists(marker)) +}) + +test_that("Installed startup datasets supply mtcars columns", { + snapshots <- member_prepare_startup_data("datasets") + expect_true(isTRUE(snapshots$datasets$dataset_only)) + expect_identical(lazydata_labels("mtcars$", snapshots, "datasets"), sort(names(datasets::mtcars))) + expect_identical(lazydata_labels("datasets::mtcars$", snapshots), sort(names(datasets::mtcars))) + expect_length(lazydata_labels("datasets:::mtcars$", snapshots), 0L) +}) + +test_that("Installed lazy dataset subclasses supply columns without invoking getters", { + fixture <- lazydata_test_package() + withr::local_libpaths(c(fixture$library, .libPaths())) + ns <- asNamespace(fixture$package) + expect_false(exists("observations", ns, inherits = FALSE)) + expect_false("observations" %in% getNamespaceExports(ns)) + snapshots <- list(lazycolumnfixture = member_prepare_package(fixture$package)) + for (code in c("lazycolumnfixture::observations$", "library(lazycolumnfixture)\nobservations$", + "copy <- lazycolumnfixture::observations\ncopy$")) { + expect_identical(lazydata_labels(code, snapshots), sort(fixture$columns), info = code) + } + expect_length(lazydata_labels("lazycolumnfixture:::observations$", snapshots), 0L) + completion <- lazydata_fixture("lazycolumnfixture::observations$`a", snapshots) + items <- member_completion(completion$document$uri, completion$workspace, completion$document, + completion$point, FALSE, 200L) + item <- items[[which(vapply(items, `[[`, character(1L), "label") == "a b")]] + expect_identical(item$textEdit$newText, "`a b`") + expect_false(file.exists(fixture$marker)) +}) + +test_that("Startup dataset models can be enabled later and stay protected in the cache", { + snapshots <- member_prepare_startup_data("datasets") + old <- lsp_settings$get("member_completion") + withr::defer(lsp_settings$set("member_completion", old)) + lsp_settings$set("member_completion", FALSE) + testthat::local_mocked_bindings(workspace_startup_packages = function() "datasets", + workspace_startup_members = function() snapshots, workspace_startup_s3 = function() list(), + .package = "languageserver") + workspace <- Workspace$new(withr::local_tempdir()) + expect_length(workspace$startup_member_packages, 0L) + lsp_settings$set("member_completion", TRUE) + workspace$load_startup_members() + expect_identical(workspace$startup_member_packages, "datasets") + expect_true(workspace$member_metadata$has("datasets")) + metadata <- MemberMetadataCache$new(32 * 1024^2, max_entries = 2L) + metadata$set("datasets", snapshots$datasets) + for (package in c("first", "second")) { + index <- member_generic_index("") + index$package <- package + metadata$set(package, member_index_freeze(index), protect = workspace$startup_member_packages) + } + expect_true(metadata$has("datasets")) + expect_true("mtcars" %in% names(metadata$get("datasets")$roots)) +}) + +test_that("Dataset completion works over LSP from an initially incomplete buffer", { + skip_on_cran() + fixture <- lazydata_test_package() + # Add only the fixture directory, retaining the standalone server's normal + # library paths instead of forwarding check-worker proxy libraries. + client <- language_client(working_dir = fixture$root, lib_paths = fixture$library) + cases <- list(list(code = "mtcars$", columns = names(datasets::mtcars)), + list(code = "lazycolumnfixture::observations$", columns = fixture$columns)) + for (i in seq_along(cases)) { + case <- cases[[i]] + path <- file.path(fixture$root, paste0("dataset-", i, ".R")) + did_open(client, path, text = case$code) + timeout <- if (identical(Sys.getenv("R_COVR"), "true")) 90 else 45 + result <- respond_completion(client, path, c(0L, nchar(case$code)), timeout = timeout, retry = FALSE) + labels <- vapply(result$items, `[[`, character(1L), "label") + expect_true(all(case$columns %in% labels), info = case$code) + expect_identical(sort(labels), sort(case$columns), info = case$code) + } + expect_false(file.exists(fixture$marker)) +}) diff --git a/tests/testthat/test-member-namespace-review.R b/tests/testthat/test-member-namespace-review.R new file mode 100644 index 00000000..085f4911 --- /dev/null +++ b/tests/testthat/test-member-namespace-review.R @@ -0,0 +1,73 @@ +namespace_review_items <- function(snapshot, code) { + uri <- "file:///namespace-review.R" + document <- Document$new(uri, language = "r", version = 1L, content = code) + data <- parse_document(uri, code) + data$version <- 1L + document$update_parse_data(data) + metadata <- collections::dict() + metadata$set(snapshot$package, member_index_thaw(snapshot)) + member_completion(uri, list(member_metadata = metadata), document, + list(row = length(code) - 1L, col = nchar(tail(code, 1L))), TRUE, 200L) +} + +namespace_review_labels <- function(snapshot, code) { + vapply(namespace_review_items(snapshot, code), `[[`, character(1L), "label") +} + +test_that("Static namespace methods retain captured values and namespace helpers", { + ns <- new.env(parent = baseenv()) + ns$helper <- eval(quote(function(value) list(done = value)), ns) + ns$api <- eval(quote(local({ + flag <- TRUE + list(run = function(option = FALSE) { + if (flag) helper(option) else list(other = TRUE) + }, select = function() if (flag) list(selected = TRUE) else list(other = TRUE)) + })), ns) + ns$environment <- new.env(parent = emptyenv()) + ns$environment$run <- ns$api$run + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "closurefixture", exports = c("api", "environment") + ))) + expect_identical(namespace_review_labels(snapshot, "closurefixture::api$select()$"), "selected") + expect_identical(namespace_review_labels(snapshot, "closurefixture::api$run()$"), "done") + expect_identical(namespace_review_labels(snapshot, "closurefixture::environment$run()$"), "done") + expect_identical(namespace_review_labels(snapshot, c( + "helper <- function(...) list(wrong = TRUE)", "closurefixture::api$run()$" + )), "done") + item <- namespace_review_items(snapshot, "closurefixture::api$ru")[[1L]] + expect_identical(item$detail, "run(option = FALSE)") + expect_identical(item$data$function_id, "api$run") +}) + +test_that("Static list matrices and arrays never dispatch coercion hooks", { + marker <- withr::local_tempfile() + methods <- get(".__S3MethodsTable__.", asNamespace("base")) + keys <- c("as.list.matrix", "as.list.array") + previous <- lapply(keys, function(key) get0(key, methods, inherits = FALSE)) + withr::defer({ + for (i in seq_along(keys)) { + if (is.null(previous[[i]])) rm(list = keys[[i]], envir = methods) else + assign(keys[[i]], previous[[i]], methods) + } + }) + hook <- function(x, ...) { + writeLines("coercion", marker) + stop("coercion must not run") + } + for (key in keys) assign(key, hook, methods) + ns <- new.env(parent = baseenv()) + ns$matrix <- matrix(list(list(done = TRUE), 2L), nrow = 1L) + names(ns$matrix) <- c("alpha", "beta") + ns$array <- array(list(list(done = TRUE), 2L), dim = c(1L, 1L, 2L)) + names(ns$array) <- c("alpha", "beta") + before <- list(ns$matrix, ns$array) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "matrixfixture", exports = c("matrix", "array") + ))) + for (name in c("matrix", "array")) { + expect_identical(namespace_review_labels(snapshot, paste0("matrixfixture::", name, "$")), c("alpha", "beta")) + expect_identical(namespace_review_labels(snapshot, paste0("matrixfixture::", name, "$alpha$")), "done") + } + expect_identical(list(ns$matrix, ns$array), before) + expect_false(file.exists(marker)) +}) diff --git a/tests/testthat/test-member-namespace.R b/tests/testthat/test-member-namespace.R new file mode 100644 index 00000000..15f7aa9f --- /dev/null +++ b/tests/testthat/test-member-namespace.R @@ -0,0 +1,135 @@ +namespace_member_fixture <- function(code, snapshot, point = NULL) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + uri <- "file:///namespace-members.R" + document <- Document$new(uri, language = "r", version = 1L, content = content) + data <- parse_document(uri, content) + data$version <- 1L + document$update_parse_data(data) + metadata <- collections::dict() + metadata$set(snapshot$package, member_index_thaw(snapshot)) + if (is.null(point)) point <- list(row = length(content) - 1L, col = nchar(tail(content, 1L))) + list(uri = uri, document = document, point = point, + workspace = list(member_metadata = metadata, get_documentation = function(...) NULL)) +} + +namespace_member_items <- function(code, snapshot) { + fixture <- namespace_member_fixture(code, snapshot) + member_completion(fixture$uri, fixture$workspace, fixture$document, fixture$point, TRUE, 200L) +} + +namespace_member_labels <- function(code, snapshot) { + vapply(namespace_member_items(code, snapshot), `[[`, character(1L), "label") +} + +test_that("Static namespace lists and data frames expose names and nested structures", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$table <- data.frame(value = 1L, `a b` = TRUE, check.names = FALSE) + ns$bundle <- list(alpha = list(done = TRUE), table = ns$table, + run = function(key, limit = 2L) { + writeLines("method", marker) + list(result = key) + }) + ns$hidden <- list(secret = TRUE) + ns$vector <- c(alpha = 1, beta = 2) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "namespacefixture", exports = c("bundle", "table", "vector") + ))) + for (code in c("namespacefixture::bundle$", "library(namespacefixture)\nbundle$", + "api <- namespacefixture::bundle\ncopy <- api\ncopy$")) { + expect_identical(namespace_member_labels(code, snapshot), c("alpha", "run", "table"), info = code) + } + expect_identical(namespace_member_labels("namespacefixture::table$", snapshot), c("a b", "value")) + expect_identical(namespace_member_labels("namespacefixture::bundle$table$", snapshot), c("a b", "value")) + expect_identical(namespace_member_labels("namespacefixture::bundle$alpha$", snapshot), "done") + expect_identical(namespace_member_labels("namespacefixture::bundle[[1L]]$", snapshot), "done") + expect_identical(namespace_member_labels("lapply(namespacefixture::bundle, function(x) x)[[1L]]$", snapshot), "done") + expect_identical(namespace_member_labels(paste0( + "choose <- function(flag) if (flag) namespacefixture::bundle$alpha$done else TRUE\n", + "result <- function() if (choose(unknown)) list(selected = TRUE) else list(other = TRUE)\nresult()$" + ), snapshot), "selected") + expect_identical(namespace_member_labels("namespacefixture::bundle$run(stop(\"argument\"))$", snapshot), "result") + item <- namespace_member_items("namespacefixture::bundle$ru", snapshot)[[1L]] + expect_identical(item$detail, "run(key, limit = 2L)") + expect_identical(item$kind, CompletionItemKind$Method) + expect_identical(namespace_member_items("namespacefixture::table$`a", snapshot)[[1L]]$textEdit$newText, "`a b`") + expect_length(namespace_member_labels("namespacefixture::hidden$", snapshot), 0L) + expect_identical(namespace_member_labels("namespacefixture:::hidden$", snapshot), "secret") + expect_length(namespace_member_labels("library(namespacefixture)\nhidden$", snapshot), 0L) + expect_length(namespace_member_labels("namespacefixture::vector$", snapshot), 0L) + expect_length(namespace_member_labels("namespacefixture::bundle@", snapshot), 0L) + expect_false(file.exists(marker)) +}) + +test_that("Static classed lists retain distinct fields with ordinary dollar access", { + marker <- withr::local_tempfile() + ns <- new.env(parent = baseenv()) + ns$as.list.namespace_record <- function(x, ...) { + writeLines("coercion", marker) + stop("coercion") + } + ns$first <- structure(list(alpha = list(done = TRUE)), class = "namespace_record") + ns$second <- structure(list(beta = 1L), class = "namespace_record") + ns$box <- list(first = ns$first) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "namespacefixture", exports = c("first", "second", "box") + ))) + expect_identical(namespace_member_labels("namespacefixture::first$", snapshot), "alpha") + expect_identical(namespace_member_labels("namespacefixture::second$", snapshot), "beta") + expect_identical(namespace_member_labels("namespacefixture::box$first$alpha$", snapshot), "done") + expect_false(file.exists(marker)) +}) + +test_that("Static namespace pairlists expose named fields without forcing missing arguments", { + ns <- new.env(parent = baseenv()) + ns$args <- pairlist(alpha = list(done = TRUE), run = function(value = 1L) list(result = value)) + ns$args <- as.pairlist(c(as.list(ns$args), alist(absent = ))) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "namespacefixture", exports = "args" + ))) + expect_identical(namespace_member_labels("namespacefixture::args$", snapshot), c("absent", "alpha", "run")) + expect_identical(namespace_member_labels("namespacefixture::args$alpha$", snapshot), "done") + expect_identical(namespace_member_labels("namespacefixture::args$run()$", snapshot), "result") + expect_length(namespace_member_labels("namespacefixture::args$absent$", snapshot), 0L) +}) + +test_that("Static R6 instances expose public current fields and bound fluent methods", { + marker <- withr::local_tempfile() + Parent <- R6::R6Class("StaticParent", public = list(base = function() self)) + Client <- R6::R6Class("StaticClient", inherit = Parent, public = list( + value = 1L, + initialize = function() writeLines("initialize", marker), + step = function(amount = 1L) { + writeLines("method", marker) + self + }, + read = function() private$payload + ), private = list(payload = list(done = TRUE)), active = list(active = function(value) { + writeLines("getter", marker) + stop("getter") + })) + ns <- new.env(parent = baseenv()) + ns$client <- Client$new() + ns$api <- list(client = ns$client) + ns$private_client <- ns$client + unlink(marker) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + ns, package = "namespacefixture", exports = c("client", "api") + ))) + expected <- c("active", "base", "clone", "initialize", "read", "step", "value") + expect_identical(namespace_member_labels("namespacefixture::client$", snapshot), expected) + expect_identical(namespace_member_labels("library(namespacefixture)\nclient$", snapshot), expected) + expect_length(namespace_member_labels("namespacefixture::private_client$", snapshot), 0L) + expect_identical(namespace_member_labels("namespacefixture:::private_client$", snapshot), expected) + expect_identical(namespace_member_labels("namespacefixture::client$step(stop(\"argument\"))$", snapshot), expected) + expect_identical(namespace_member_labels("namespacefixture::client$base()$", snapshot), expected) + expect_identical(namespace_member_labels("namespacefixture::client$read()$", snapshot), "done") + expect_identical(namespace_member_labels("namespacefixture::api$client$", snapshot), expected) + expect_identical(namespace_member_labels("namespacefixture::api$client$step()$", snapshot), expected) + expect_length(namespace_member_labels("namespacefixture::client$active$", snapshot), 0L) + expect_length(namespace_member_labels("namespacefixture::client$.__enclos_env__$", snapshot), 0L) + item <- namespace_member_items("namespacefixture::client$ste", snapshot)[[1L]] + expect_identical(item$detail, "step(amount = 1L)") + expect_identical(item$kind, CompletionItemKind$Method) + expect_false(file.exists(marker)) +}) diff --git a/tests/testthat/test-member-ownership.R b/tests/testthat/test-member-ownership.R new file mode 100644 index 00000000..d9f17cba --- /dev/null +++ b/tests/testthat/test-member-ownership.R @@ -0,0 +1,88 @@ +ownership_member_fixture <- function(code, snapshots) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + document <- Document$new("file:///member-ownership.R", language = "r", version = 1L, content = content) + data <- parse_document(document$uri, content) + data$version <- 1L + document$update_parse_data(data) + metadata <- collections::dict() + for (package in names(snapshots)) metadata$set(package, member_index_thaw(snapshots[[package]])) + list(document = document, workspace = list(member_metadata = metadata), + point = list(row = length(content) - 1L, col = nchar(tail(content, 1L)))) +} + +ownership_member_items <- function(code, snapshots) { + fixture <- ownership_member_fixture(code, snapshots) + member_completion(fixture$document$uri, fixture$workspace, fixture$document, fixture$point, FALSE, 200L) +} + +ownership_member_labels <- function(code, snapshots) { + vapply(ownership_member_items(code, snapshots), `[[`, character(1L), "label") +} + +test_that("Static receivers retain namespace ownership when root names collide", { + make_snapshot <- function(package, name, fn) { + ns <- new.env(parent = baseenv()) + ns$api <- new.env(parent = emptyenv()) + class(ns$api) <- "ownership_commands" + ns$api[[name]] <- TRUE + ns$api$shared <- list(done = TRUE) + ns$api$run <- fn + ns$bundle <- list(api = ns$api) + ns$private <- ns$api + member_index_freeze(member_package_index(member_namespace_input( + ns, package = package, exports = c("api", "bundle") + ))) + } + snapshots <- list( + firstfixture = make_snapshot("firstfixture", "first", function(first = 1) list(first_result = TRUE)), + secondfixture = make_snapshot("secondfixture", "second", function(second = 2) list(second_result = TRUE)) + ) + for (order in list(names(snapshots), rev(names(snapshots)))) { + current <- snapshots[order] + for (root in c("secondfixture::api", "secondfixture::bundle$api", "secondfixture:::private")) { + expect_identical(ownership_member_labels(paste0(root, "$"), current), c("run", "second", "shared")) + expect_identical(ownership_member_labels(paste0(root, "$run()$"), current), "second_result") + item <- ownership_member_items(paste0(root, "$ru"), current)[[1L]] + expect_identical(item$detail, "run(second = 2)") + expect_identical(item$data$package, "secondfixture") + } + expect_identical(ownership_member_labels(paste0( + "list(unused = firstfixture::api, selected = secondfixture::api)$selected$run()$" + ), current), "second_result") + expect_identical(ownership_member_labels(paste0( + "choose <- function(flag) if (flag) firstfixture::api else secondfixture::api\nchoose(unknown)$" + ), current), c("run", "shared")) + expect_identical(ownership_member_labels(paste0( + "choose <- function(flag) if (flag) firstfixture::api else secondfixture::api\nchoose(unknown)$shared$" + ), current), "done") + } +}) + +test_that("Subset dispatch stays conservative across alternate namespace classes", { + ns <- new.env(parent = baseenv()) + ns$first <- structure(list(raw = list(wrong = TRUE)), class = "ownership_first") + ns$second <- structure(list(raw = list(wrong = TRUE)), class = "ownership_second") + ns$`[[.ownership_first` <- function(x, i) list(first = TRUE) + ns$`[[.ownership_second` <- function(x, i) list(second = TRUE) + snapshots <- list(ownershipfixture = member_index_freeze(member_package_index(member_namespace_input( + ns, package = "ownershipfixture", exports = c("first", "second") + )))) + expect_length(ownership_member_labels(paste0( + "choose <- function(flag) if (flag) ownershipfixture::first else ownershipfixture::second\n", + "choose(unknown)[[1L]]$" + ), snapshots), 0L) + expect_identical(ownership_member_labels("ownershipfixture::first[[1L]]$", snapshots), "first") +}) + +test_that("Callbacks created by namespace methods retain their defining package", { + make_snapshot <- function(package, label) { + ns <- new.env(parent = baseenv()) + ns$api <- list(run = function() list(callback = function() helper())) + ns$helper <- if (label == "first") function() list(first = TRUE) else function() list(second = TRUE) + member_index_freeze(member_package_index(member_namespace_input(ns, + package = package, exports = "api"))) + } + snapshots <- list(firstfixture = make_snapshot("firstfixture", "first"), + secondfixture = make_snapshot("secondfixture", "second")) + expect_identical(ownership_member_labels("secondfixture::api$run()$callback()$", snapshots), "second") +}) diff --git a/tests/testthat/test-member-package-recovery.R b/tests/testthat/test-member-package-recovery.R new file mode 100644 index 00000000..e31c3d85 --- /dev/null +++ b/tests/testthat/test-member-package-recovery.R @@ -0,0 +1,185 @@ +test_that("Incomplete namespace members retain syntax-confirmed package requests", { + for (code in c("recoveryfixture::frame$", "recoveryfixture:::object@", + "f(recoveryfixture::frame$", "recoveryfixture::frame$ # trailing comment", + "function() { recoveryfixture::frame$", "bad <- )\nrecoveryfixture::frame$", + "recoveryfixture::frame$\n# trailing comment")) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + parsed <- parse_document("file:///package-recovery.R", content) + expect_true(parsed$parse_error, info = code) + expect_identical(parsed$member_data$packages, "recoveryfixture", info = code) + } + expect_identical(member_incomplete_packages('"recoveryfixture"::frame$'), "recoveryfixture") + expect_identical(member_incomplete_packages("f(firstfixture::frame, secondfixture::frame$"), "secondfixture") +}) + +test_that("Package recovery ignores comments, strings, and unrelated incomplete syntax", { + for (content in list("# recoveryfixture::frame$", '"recoveryfixture::frame$"', + '"recoveryfixture::frame$', "`recoveryfixture::frame$`", + 'f("recoveryfixture::frame$")$', "recoveryfixture::frame", + "opaque(recoveryfixture", "`::`()$")) { + expect_length(member_incomplete_packages(content), 0L) + } + expect_length(member_incomplete_packages(rep("# comment", 130L)), 0L) + expect_length(member_incomplete_packages(strrep(" ", 65537L)), 0L) +}) + +test_that("Recovered namespace requests do not create source bindings", { + content <- "data <- recoveryfixture::frame$" + index <- member_document_index(content) + expect_identical(index$packages, "recoveryfixture") + expect_length(index$items, 0L) + expect_length(index$bindings, 0L) + expect_length(index$imports, 0L) +}) + +test_that("Parse recovery schedules namespace metadata without changing valid imports", { + uri <- "file:///package-recovery-routing.R" + doc <- Document$new(uri, language = "r", version = 1L, content = "recoveryfixture::frame$") + workspace <- new.env(parent = emptyenv()) + workspace$documents <- collections::dict() + workspace$documents$set(uri, doc) + workspace$index <- list(topology_revision = 0L, enabled = FALSE) + workspace$update_parse_data <- function(uri, data) doc$update_parse_data(data) + workspace$update_loaded_packages <- function() stop("Incomplete syntax must retain valid imports") + server <- new.env(parent = emptyenv()) + server$get_workspace <- function(...) workspace + server$queue_pending_replies <- function(...) NULL + tasks <- list() + server$resolve_task_manager <- list(add_task = function(uri, task) { + tasks[[length(tasks) + 1L]] <<- task + }) + testthat::local_mocked_bindings(resolve_task = function(self, uri, document, packages, + delay = 0, namespace_packages = character()) { + list(packages = packages, namespace_packages = namespace_packages) + }, .package = "languageserver") + parsed <- parse_document(uri, doc$content) + parse_callback(server, uri, 1L, parsed, cache = FALSE) + expect_length(tasks, 1L) + expect_identical(tasks[[1L]]$namespace_packages, "recoveryfixture") + expect_length(tasks[[1L]]$packages, 0L) + parse_callback(server, uri, 1L, parsed, cache = FALSE) + expect_length(tasks, 1L) + + doc$loaded_packages <- "priorfixture" + doc$requested_packages <- list(packages = "priorfixture", namespace_packages = "oldnamespace") + doc$set_content(2L, c("bad <- )", "nextfixture::object@")) + parsed <- parse_document(uri, doc$content) + parse_callback(server, uri, 2L, parsed, cache = FALSE) + expect_length(tasks, 2L) + expect_identical(tasks[[2L]]$packages, "priorfixture") + expect_identical(tasks[[2L]]$namespace_packages, c("oldnamespace", "nextfixture")) + expect_identical(doc$loaded_packages, "priorfixture") +}) + +package_recovery_wait_fixture <- function(code) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + uri <- "file:///package-recovery-wait.R" + doc <- Document$new(uri, language = "r", version = 1L, content = content) + parsed <- parse_document(uri, content) + parsed$version <- 1L + doc$update_parse_data(parsed) + doc$requested_packages <- list(packages = character(), namespace_packages = "recoveryfixture") + workspace <- list(documents = collections::dict(), member_metadata = collections::dict()) + workspace$documents$set(uri, doc) + server <- new.env(parent = emptyenv()) + server$get_workspace <- function(...) workspace + server$resolve_task_manager <- list(has = function(...) TRUE, promote = function(...) NULL) + server$member_task_manager <- list(has = function(...) TRUE, promote = function(...) NULL) + list(server = server, workspace = workspace, document = doc, uri = uri, + point = list(row = length(content) - 1L, col = nchar(tail(content, 1L)))) +} + +test_that("Cold member requests wait for only the receiver's pending namespace", { + for (code in c("recoveryfixture::frame$", "value <- recoveryfixture::frame\nvalue$")) { + fixture <- package_recovery_wait_fixture(code) + expect_true(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + fixture$workspace$member_metadata$set("recoveryfixture", list(package = "recoveryfixture", roots = list())) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + } + fixture <- package_recovery_wait_fixture("list(local = TRUE)$") + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + fixture <- package_recovery_wait_fixture("recoveryfixture::frame$") + fixture$server$member_task_manager <- list(has = function(...) FALSE) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) +}) + +test_that("Startup dataset-only catalogs wait for regular namespace objects", { + for (code in c("recoveryfixture::frame$", "value <- recoveryfixture::frame\nvalue$", + "list(known = recoveryfixture::observations, missing = recoveryfixture::frame)$missing$")) { + fixture <- package_recovery_wait_fixture(code) + partial <- list(package = "recoveryfixture", dataset_only = TRUE, + roots = list(observations = member_value(type = "list", fields = list(column = member_literal(1L))))) + fixture$workspace$member_metadata$set("recoveryfixture", partial) + expect_true(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point), info = code) + partial$dataset_only <- FALSE + fixture$workspace$member_metadata$set("recoveryfixture", partial) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + partial$dataset_only <- TRUE + partial$namespace_attempted <- TRUE + fixture$workspace$member_metadata$set("recoveryfixture", partial) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + partial$error <- "failed namespace preparation" + fixture$workspace$member_metadata$set("recoveryfixture", partial) + expect_true(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + fixture$server$member_task_manager <- list(has = function(...) FALSE) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + } +}) + +test_that("Readiness distinguishes dataset-only models in the member metadata cache", { + fixture <- package_recovery_wait_fixture("recoveryfixture::frame$") + fixture$workspace$member_metadata <- MemberMetadataCache$new(32 * 1024^2) + fixture$server$get_workspace <- function(...) fixture$workspace + index <- member_generic_index("") + index$package <- "recoveryfixture" + index$dataset_only <- TRUE + index$roots <- list(observations = member_value(type = "list", fields = list(column = member_literal(1L)))) + fixture$workspace$member_metadata$set("recoveryfixture", member_index_freeze(index)) + expect_true(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) + index$namespace_attempted <- TRUE + fixture$workspace$member_metadata$set("recoveryfixture", member_index_freeze(index)) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) +}) + +test_that("Startup public datasets are usable before a namespace upgrade", { + for (code in c("recoveryfixture::observations$", "value <- recoveryfixture::observations\nvalue$")) { + fixture <- package_recovery_wait_fixture(code) + fixture$workspace$member_metadata$set("recoveryfixture", list(package = "recoveryfixture", dataset_only = TRUE, + roots = list(observations = member_value(type = "list", fields = list(column = member_literal(1L)))))) + expect_false(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point), info = code) + } + fixture <- package_recovery_wait_fixture("recoveryfixture:::observations$") + fixture$workspace$member_metadata$set("recoveryfixture", list(package = "recoveryfixture", dataset_only = TRUE, + roots = list(observations = member_value(type = "list", fields = list(column = member_literal(1L)))))) + expect_true(member_metadata_waiting(fixture$server, fixture$uri, fixture$document, fixture$point)) +}) + +test_that("The first namespace member request queues independently of S3", { + fixture <- package_recovery_wait_fixture("recoveryfixture::frame$") + server <- fixture$server + server$pending_replies <- collections::dict() + queue <- collections::queue() + server$pending_replies$set(fixture$uri, list(`textDocument/completion` = queue)) + server$ClientCapabilities <- list() + fixture$workspace$member_metadata$set("recoveryfixture", list(package = "recoveryfixture", dataset_only = TRUE, + roots = list(observations = member_value(type = "list", fields = list(column = member_literal(1L)))))) + replies <- list() + server$deliver <- function(reply) replies[[length(replies) + 1L]] <<- reply + promoted <- 0L + server$member_task_manager$promote <- function(...) promoted <<- promoted + 1L + text_document_completion(server, 1L, list(textDocument = list(uri = fixture$uri), + position = list(line = fixture$point$row, character = fixture$point$col))) + expect_length(replies, 0L) + expect_equal(queue$size(), 1L) + expect_identical(promoted, 1L) +}) + +test_that("Member worker failures wake pending namespace completion requests", { + fixture <- package_recovery_wait_fixture("recoveryfixture::frame$") + ready <- character() + testthat::local_mocked_bindings(create_task = function(target, args, callback, error, ...) list(error = error), + s3_queue_ready = function(self, uri) ready <<- c(ready, uri), .package = "languageserver") + task <- resolve_member_task(fixture$server, fixture$uri, fixture$document, character(), "recoveryfixture") + task$error(simpleError("worker failed")) + expect_identical(ready, fixture$uri) +}) diff --git a/tests/testthat/test-member-r6-review.R b/tests/testthat/test-member-r6-review.R new file mode 100644 index 00000000..791b1380 --- /dev/null +++ b/tests/testthat/test-member-r6-review.R @@ -0,0 +1,97 @@ +r6_review_snapshot <- function(object) { + ns <- new.env(parent = baseenv()) + ns$object <- object + member_index_freeze(member_package_index(member_namespace_input( + ns, package = "r6reviewfixture", exports = "object" + ))) +} + +r6_review_labels <- function(code, snapshot) { + content <- strsplit(code, "\n", fixed = TRUE)[[1L]] + uri <- "file:///r6-review.R" + document <- Document$new(uri, language = "r", version = 1L, content = content) + data <- parse_document(uri, content) + data$version <- 1L + document$update_parse_data(data) + metadata <- collections::dict() + metadata$set("r6reviewfixture", member_index_thaw(snapshot)) + workspace <- list(member_metadata = metadata) + point <- list(row = length(content) - 1L, col = nchar(tail(content, 1L))) + items <- member_completion(uri, workspace, document, point, TRUE, 200L) + vapply(items, `[[`, character(1L), "label") +} + +test_that("Stored R6 function fields keep their original lexical bindings", { + marker <- withr::local_tempfile() + Class <- R6::R6Class("StoredCallbacks", public = list( + callback = NULL, unrelated = NULL, mimic = NULL, + run = function() self, + read = function() private$first() + ), private = list(payload = list(done = TRUE), + first = function() private$second(), second = function() private$payload)) + object <- Class$new() + object$callback <- local({ + self <- 42L + function() list(value = self) + }) + object$unrelated <- function() { + writeLines("method", marker) + private$payload + } + object$mimic <- local({ + self <- object + private <- NULL + function() { + self + private$payload + } + }) + snapshot <- r6_review_snapshot(object) + index <- member_index_thaw(snapshot) + value <- member_infer(str2lang("r6reviewfixture::object$callback()$value"), index, index$roots) + expect_identical(value$literal, 42L) + expect_identical(r6_review_labels("r6reviewfixture::object$callback()$", snapshot), "value") + expect_length(r6_review_labels("r6reviewfixture::object$unrelated()$", snapshot), 0L) + expect_length(r6_review_labels("r6reviewfixture::object$mimic()$", snapshot), 0L) + expect_identical(r6_review_labels("r6reviewfixture::object$read()$", snapshot), "done") + expect_identical(r6_review_labels("r6reviewfixture::object$run()$", snapshot), + c("callback", "clone", "mimic", "read", "run", "unrelated")) + expect_false(file.exists(marker)) +}) + +test_that("Stored inherited R6 methods retain each lexical super environment", { + Grand <- R6::R6Class("StoredGrand", public = list(run = function() list(grand = TRUE))) + Parent <- R6::R6Class("StoredParent", inherit = Grand, public = list(run = function() super$run())) + Child <- R6::R6Class("StoredChild", inherit = Parent, public = list(run = function() super$run())) + snapshot <- r6_review_snapshot(Child$new()) + expect_identical(r6_review_labels("r6reviewfixture::object$run()$", snapshot), "grand") +}) + +test_that("Stored non-portable R6 instances conceal their internal bindings", { + Class <- R6::R6Class("StoredNonPortable", portable = FALSE, + public = list(read = function() private$payload), + private = list(payload = list(done = TRUE))) + snapshot <- r6_review_snapshot(Class$new()) + expect_identical(r6_review_labels("r6reviewfixture::object$", snapshot), c("clone", "read")) + expect_identical(r6_review_labels("r6reviewfixture::object$read()$", snapshot), "done") + expect_length(r6_review_labels("r6reviewfixture::object$private$", snapshot), 0L) +}) + +test_that("Stored R6 locked data bindings survive invalid replacement attempts", { + Class <- R6::R6Class("StoredLockedData", public = list(payload = list(done = TRUE))) + object <- Class$new() + lockBinding("payload", object) + snapshot <- r6_review_snapshot(object) + expect_identical(r6_review_labels(paste0( + "f <- function() { object <- r6reviewfixture::object\nobject$payload <- unknown()\nobject$payload$" + ), snapshot), "done") +}) + +test_that("Stored R6 method contexts preserve private method binding locks", { + Class <- R6::R6Class("StoredPrivateLocks", public = list(read = function() { + private$helper <- function() list(fake = TRUE) + private$helper() + }), private = list(helper = function() list(real = TRUE))) + snapshot <- r6_review_snapshot(Class$new()) + expect_identical(r6_review_labels("r6reviewfixture::object$read()$", snapshot), "real") +}) diff --git a/tests/testthat/test-member-s4.R b/tests/testthat/test-member-s4.R index 86607e2b..675ce380 100644 --- a/tests/testthat/test-member-s4.R +++ b/tests/testthat/test-member-s4.R @@ -169,6 +169,118 @@ test_that("S4 namespace constructors and objects use inert descriptor metadata", expect_identical(s4_labels("s4fixture::Widget()@", list(s4fixture = changed)), "changed") }) +test_that("Static S4 namespace slots preserve stored objects and callable syntax", { + scope <- new.env(parent = asNamespace("methods")) + leaf <- paste0("StaticS4Leaf", Sys.getpid()) + box <- paste0("StaticS4Box", Sys.getpid()) + scope$Leaf <- methods::setClass(leaf, slots = c(value = "numeric"), + where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(leaf, where = scope))) + scope$Box <- methods::setClass(box, slots = c(typed = leaf, flexible = "ANY", items = "list", run = "function"), + where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(box, where = scope))) + marker <- withr::local_tempfile() + scope$object <- scope$Box( + typed = scope$Leaf(value = 1), flexible = scope$Leaf(value = 2), + items = list(child = scope$Leaf(value = 3)), + run = function(first, second = 2) { + writeLines("method", marker) + list(done = first) + } + ) + scope$private <- scope$object + store <- paste0(box, "Store") + Store <- methods::setClass(store, contains = "environment", where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(store, where = scope))) + scope$store <- Store() + scope$store@.xData$payload <- list(flag = TRUE) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + scope, package = "languageserver", exports = c("object", "store") + ))) + snapshots <- list(languageserver = snapshot) + for (code in list("languageserver::object@", c("library(languageserver)", "object@"), + c("original <- languageserver::object", "copy <- original", "copy@"))) { + expect_identical(s4_labels(code, snapshots), c("flexible", "items", "run", "typed")) + } + expect_identical(s4_labels("languageserver::object@typed@", snapshots), "value") + expect_identical(s4_labels("languageserver::object@flexible@", snapshots), "value") + expect_identical(s4_labels("languageserver::object@items$", snapshots), "child") + expect_identical(s4_labels("languageserver::object@items$child@", snapshots), "value") + expect_identical(s4_labels("languageserver:::private@flexible@", snapshots), "value") + expect_identical(s4_labels(c("copy <- languageserver:::private", "copy@"), snapshots), + c("flexible", "items", "run", "typed")) + expect_length(s4_labels("languageserver::private@", snapshots), 0L) + expect_identical(s4_labels("languageserver::store@", snapshots), ".xData") + expect_identical(s4_labels("languageserver::store@.xData$", snapshots), "payload") + expect_identical(s4_labels("languageserver::store@.xData$payload$", snapshots), "flag") + item <- s4_items("languageserver::object@ru", snapshots)[[1L]] + expect_identical(item$detail, "run(first, second = 2)") + expect_identical(item$kind, CompletionItemKind$Method) + fixture <- s4_fixture("languageserver::object@run(second = ", snapshots) + signature <- signature_reply(1L, fixture$uri, fixture$workspace, fixture$document, fixture$point)$result + expect_identical(signature$signatures[[1L]]$label, "run(first, second = 2)") + expect_identical(s4_labels("languageserver::object@run(stop(\"argument\"))$", snapshots), "done") + expect_length(s4_labels(c("object <- unknown()", "object@"), snapshots), 0L) + expect_false(file.exists(marker)) + expect_identical(unserialize(serialize(snapshot, NULL)), snapshot) +}) + +test_that("Static callable S4 objects retain slots and their stored data part", { + scope <- new.env(parent = asNamespace("methods")) + name <- paste0("StaticS4Function", Sys.getpid()) + Class <- methods::setClass(name, contains = "function", slots = c(label = "character"), + where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(name, where = scope))) + list_name <- paste0(name, "List") + List <- methods::setClass(list_name, contains = "list", where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(list_name, where = scope))) + marker <- withr::local_tempfile() + scope$object <- Class(.Data = function(value = 1L) { + writeLines("method", marker) + list(done = value) + }, label = "callable") + scope$container <- List(.Data = list(list(child = TRUE))) + fn <- function(value = 1L) list(done = value) + environment(fn) <- baseenv() + scope$bundle <- list(callable = Class(.Data = fn, label = "bundled")) + snapshot <- member_index_freeze(member_package_index(member_namespace_input( + scope, package = "languageserver", exports = c("object", "container", "bundle") + ))) + snapshots <- list(languageserver = snapshot) + expect_identical(s4_labels("languageserver::object@", snapshots), c(".Data", "label")) + expect_identical(s4_labels(c("library(languageserver)", "object@"), snapshots), c(".Data", "label")) + expect_identical(s4_labels("languageserver::object@.Data()$", snapshots), "done") + expect_identical(s4_labels("languageserver::object()$", snapshots), "done") + expect_identical(s4_labels("languageserver::bundle$callable@", snapshots), c(".Data", "label")) + expect_identical(s4_labels("languageserver::bundle$callable()$", snapshots), "done") + expect_identical(s4_labels("languageserver::container@.Data[[1L]]$", snapshots), "child") + item <- s4_items("languageserver::object@.D", snapshots)[[1L]] + expect_identical(item$detail, ".Data(value = 1L)") + expect_false(file.exists(marker)) +}) + +test_that("Static S4 snapshots bound stored slots and skip deferred class metadata", { + scope <- new.env(parent = asNamespace("methods")) + class <- paste0("StaticS4Deferred", Sys.getpid()) + Class <- methods::setClass(class, slots = c(value = "numeric"), where = scope, package = "languageserver") + withr::defer(suppressWarnings(methods::removeClass(class, where = scope))) + object <- Class(value = 1) + marker <- withr::local_tempfile() + lookup <- new.env(parent = emptyenv()) + delayedAssign(paste0(".__C__", class), { + writeLines("descriptor", marker) + stop("descriptor") + }, assign.env = lookup) + describe <- function(...) stop("stored slot visitor") + snapshot <- member_s4_instance_snapshot(object, describe, 0L, 4L, lookup) + expect_identical(snapshot$reason, "unknown_s4_class") + expect_false(file.exists(marker)) + snapshot <- member_s4_instance_snapshot(object, describe, 5L, 4L, scope) + expect_identical(snapshot$reason, "snapshot_depth") + snapshot <- member_s4_instance_snapshot(object, describe, 0L, 4L, scope, reserve = function(n) FALSE) + expect_identical(snapshot$reason, "snapshot_budget") +}) + test_that("S4 dependency slots and union branches preserve class package identities", { skip_if_not_installed("stats4") loadNamespace("stats4")