#' General cell objects for Excel writing
#'
#' @description
#' `xl_cell_general` creates a vector of cell objects, each optionally
#' containing a **value**, a **formula**, and/or a **hyperlink**. It is the
#' fundamental building block used internally by [xl_formula()] and
#' [xl_hyperlink()], and can be used directly for mixed-type columns or cells
#' that combine multiple features (e.g., a formula with a pre-calculated
#' result, or a URL with separate display text and tooltip).
#'
#' An `xl_cell_general` behaves like a vector: it has a `length()`, supports
#' `[`, `[[`, `c()` and `rep()`, and recycles automatically when assigned to a
#' data frame column of a different length (just like [xl_formula()]).  It is
#' deliberately not a list, so that `df[, j] <- cells` assigns one column
#' rather than being read as a list of them.
#'
#' `df[i, j] <- x` sets that cell's `value`, whatever `x`'s type; a formula
#' needs [xl_formula()], since a cell column carries no column-wide notion of
#' "these are all formulas".
#'
#' @param value An atomic vector or a list of scalars, one per cell. Use `NA`
#'   for a cell that is empty unless `na` says otherwise (see `na` below, and
#'   note that a workbook-wide `na` reaches these values too). A list enables
#'   mixed types across cells
#'   in the same column (e.g., `list(1.5, "text", TRUE)`). Date and POSIXct
#'   scalars are supported and formatted as in [write_xlsx()].  When
#'   `hyperlink` is also set for the same cell, a **character** `value` is
#'   used as the display text shown in the cell instead of the raw URL; all
#'   other types are ignored for hyperlink cells.
#' @param formula A character vector of Excel formulas (each must start with
#'   `"="`), or `NA` for cells with no formula.  When both `value` and
#'   `formula` are supplied for the same cell, `value` is used as a
#'   pre-calculated result stored alongside the formula via
#'   `worksheet_write_formula_num()` (numeric value) or
#'   `worksheet_write_formula_str()` (character value).  This allows static
#'   xlsx exports that display formula text in the formula bar but do not
#'   require Excel to recalculate on open.
#' @param hyperlink A character vector of URLs, or a list where each element
#'   is `NA`, a single character URL, or a named list with elements:
#'   \describe{
#'     \item{`url`}{(required) The target URL.}
#'     \item{`tooltip`}{(optional) Tooltip text shown on hover.}
#'   }
#'   Supply a character `value` alongside `hyperlink` to show custom display
#'   text in the cell instead of the raw URL.  The hyperlink is written via
#'   `worksheet_write_url_opt()`.
#'
#' @param format An [xl_format] object (applied to every cell), or a list of
#'   `xl_format` objects (one per cell, recycled), or `NULL` for no formatting.
#'   Build formats with [xl_font()], [xl_fill()], [xl_border()], [xl_align()],
#'   [xl_num_format()] and [xl_protection()], combined with [xl_format()] or
#'   `+`.  When a cell's value is a date/time and its format sets no number
#'   format, the default date/time number format is applied automatically.
#' @param comment Cell comments (notes): a character vector of comment text
#'   (one per cell, recycled; `NA` for no comment), a single [xl_comment()]
#'   (recycled to every cell), or a list mixing strings / `xl_comment` / `NA`
#'   per cell.  `NULL` for no comments.
#' @param array,dynamic Logical (one per cell, recycled): how the cell's
#'   `formula` is stored.  `array = TRUE` writes a legacy *array* (Ctrl-Shift-
#'   Enter) formula; `dynamic = TRUE` writes a modern *dynamic array* formula,
#'   which Excel spills over as many cells as the result needs.  Neither can
#'   carry a character `value`, because Excel stores no cached string result for
#'   an array formula.  On a cell that has no `formula` the flags are inert, so a
#'   single `array = TRUE` can be recycled across a column that mixes formula
#'   and value cells.
#' @param na What to write in a cell that has no value, one per cell and
#'   recycled.  `NA` (the default) inherits the column's
#'   [xl_col_spec()]`(na = )`, and failing that the workbook's
#'   [xl_properties()]`(na = )`; a cell that sets its own overrides both.  This
#'   is also how a cell asks for a blank where the column or workbook would
#'   otherwise substitute something --- give it the value you want, `""` for an
#'   empty string.
#' @param array_range The range a legacy array formula covers, for the rare case
#'   where it must be declared: an Excel range string (`"C2:C11"`) or a
#'   `list(rows = , cols = )` spec, one per cell (`NA` for none). It must start
#'   at the cell holding the formula, and must extend into cells the sheet does
#'   not otherwise write --- the range is padded on write, so an overlap would
#'   have the padding and the sheet's own values overwrite each other.
#'
#'   Leave it unset for almost everything: a single-cell `dynamic` formula
#'   spills automatically in Excel 365 / 2021, and a single-cell `array` formula
#'   is the right spelling for a `SUMPRODUCT`-style aggregate. Supplying it
#'   forces the workbook out of the memory-efficient row-streaming mode, since
#'   libxlsxwriter cannot pad an array range while streaming.
#' @return An object of class `c("xl_cell_general", "xl_cell")`, which is a
#'   list of length `n` where each element is a named list with fields
#'   `value`, `formula`, `hyperlink`, `format`, `comment`, `array`, `dynamic`
#'   and `array_range`.
#'
#' @family cell content
#' @seealso [xl_formula()], [xl_hyperlink()], [write_xlsx()]
#' @export
#' @examples
#' # Value-only cell
#' xl_cell_general(value = 42)
#'
#' # Formula with a pre-calculated numeric result (static export)
#' xl_cell_general(value = 42.0, formula = "=SUM(A1:A10)")
#'
#' # Hyperlink with display text (value) and tooltip
#' xl_cell_general(
#'   value    = "Visit",
#'   hyperlink = list(url = "https://example.com", tooltip = "Go to example.com")
#' )
#'
#' # Vector of cells: value and formula cells in one column
#' cells <- c(
#'   xl_cell_general(value = 1.5),
#'   xl_cell_general(value = "note"),
#'   xl_cell_general(formula = "=A1+A2")
#' )
#'
#' # Used in a data frame (length-1 recycles to fill all rows, as with
#' # xl_formula())
#' df <- data.frame(x = 1:3)
#' df$formula_col <- xl_formula("=A1*2")   # backward-compatible shorthand
#' df$cell_col    <- xl_cell_general(value = 99L)  # all rows get 99
xl_cell_general <- function(value = NULL, formula = NULL, hyperlink = NULL,
                            format = NULL, comment = NULL, array = FALSE,
                            dynamic = FALSE, array_range = NULL, na = NA) {

  # Require at least one content argument -------------------------------------
  if (is.null(value) && is.null(formula) && is.null(hyperlink) &&
      is.null(comment))
    stop("At least one of 'value', 'formula', 'hyperlink', or 'comment' must ",
         "be provided. Use value = NA for a cell with nothing in it.",
         call. = FALSE)

  # Reject values writexl cannot write ----------------------------------------
  # Uses the same predicate as the column-level check in normalize_df(), so an
  # unsupported type cannot slip in by being wrapped in a cell object.  NULL
  # and NA are allowed: both mean "blank cell".
  if (!is.null(value)) {
    vals <- if (is_xl_rich_string(value)) list(value)
            else if (is.list(value)) value else list(value)
    keep <- !vapply(vals, is.null, logical(1))
    ok <- vapply(vals, function(v)
      is_xl_rich_string(v) || .is_supported_column(v), logical(1))
    bad <- which(keep & !ok)
    if (length(bad)) {
      detail <- vapply(bad, function(k) {
        v <- vals[[k]]
        cls <- paste(class(v), collapse = "/")
        ty <- typeof(v)
        lab <- if (identical(cls, ty)) cls else sprintf("%s (%s)", cls, ty)
        if (is.list(value)) sprintf("value[[%d]]: %s", k, lab)
        else sprintf("value: %s", lab)
      }, character(1))
      stop("xl_cell_general() cannot write these value type(s):\n  ",
           paste(detail, collapse = "\n  "), call. = FALSE)
    }
  }

  # Pre-normalise the hyperlink argument --------------------------------------
  # A named list with a 'url' element is a single hyperlink spec, not a list of
  # multiple hyperlinks.  Wrap it so length() = 1.
  if (is.list(hyperlink) && !is.null(hyperlink[["url"]])) {
    hyperlink <- list(hyperlink)
  }

  # Pre-normalise the array_range argument ------------------------------------
  # Same wrapping problem as `hyperlink`: a list(rows = , cols = ) spec is one
  # range, not a list of ranges.
  if (is.list(array_range) &&
      (!is.null(array_range[["rows"]]) || !is.null(array_range[["cols"]]))) {
    array_range <- list(array_range)
  }

  # Determine the output length n ---------------------------------------------
  # A single xl_comment counts as one comment (not its number of fields).
  comment_n <- if (is.null(comment)) 0L
               else if (is_xl_comment(comment)) 1L
               else length(comment)
  n <- max(c(
    if (!is.null(value))     .value_length(value) else 0L,
    if (!is.null(formula))   length(formula)   else 0L,
    if (!is.null(hyperlink)) length(hyperlink) else 0L,
    if (!is.null(array_range)) length(array_range) else 0L,
    comment_n
  ))

  # Normalise value to a list of length n -------------------------------------
  # An xl_rich_string is itself a list (of runs), so it has to be recognised
  # before the is.list() branch or its runs would be spread across cells.
  value_list <- if (is.null(value)) {
    rep(list(NA), n)
  } else if (is_xl_rich_string(value)) {
    rep(list(value), n)
  } else {
    rep_len(if (is.list(value)) value else as.list(value), n)
  }

  # Normalise formula to a character vector of length n -----------------------
  formula_vec <- if (is.null(formula)) {
    rep(NA_character_, n)
  } else {
    formula <- as.character(formula)
    bad <- !is.na(formula) & !startsWith(formula, "=")
    if (any(bad))
      stop("All non-NA formulas must start with '='", call. = FALSE)
    rep_len(formula, n)
  }

  # Normalise hyperlink to a list of length n ---------------------------------
  hyperlink_list <- if (is.null(hyperlink)) {
    rep(list(NA), n)
  } else {
    h <- if (is.list(hyperlink)) hyperlink else as.list(hyperlink)
    for (i in seq_along(h)) {
      el <- h[[i]]
      if (is.null(el))                                              next
      if (identical(el, NA))                                        next
      # character NA -> normalise to logical NA sentinel
      if (is.character(el) && length(el) == 1L && is.na(el)) {
        h[[i]] <- NA
        next
      }
      if (is.character(el) && length(el) == 1L)                    next
      if (is.list(el) && !is.null(el[["url"]]) &&
          is.character(el[["url"]]) && length(el[["url"]]) == 1L)  next
      stop(sprintf(
        paste0("hyperlink[[%d]] must be NA, a single character URL, or a ",
               "named list with a character 'url' element"),
        i), call. = FALSE)
    }
    rep_len(h, n)
  }

  # Normalise format to a list of length n ------------------------------------
  format_list <- if (is.null(format)) {
    vector("list", n)
  } else if (is_xl_format(format)) {
    rep(list(format), n)
  } else if (is.list(format)) {
    for (el in format)
      if (!is.null(el) && !is_xl_format(el))
        stop("each element of `format` must be an xl_format object or NULL",
             call. = FALSE)
    rep_len(format, n)
  } else {
    stop("`format` must be an xl_format object or a list of xl_format objects",
         call. = FALSE)
  }

  # Normalise comment to a list of length n -----------------------------------
  # Each element is NULL (no comment) or a C-ready comment payload.  A
  # character vector gives per-cell text with default options; a single
  # xl_comment recycles to every cell; a list mixes strings/xl_comment/NA.
  comment_list <- if (is.null(comment)) {
    vector("list", n)
  } else if (is_xl_comment(comment)) {
    rep(list(unclass(comment)), n)
  } else if (is.character(comment) || is.list(comment)) {
    rep_len(lapply(comment, .comment_payload), n)
  } else {
    stop("`comment` must be a character vector, an xl_comment, or a list of ",
         "strings/xl_comment objects", call. = FALSE)
  }

  # Normalise the array / dynamic flags and their range -----------------------
  array_vec   <- .cell_flag_vec(array, "array", n)
  dynamic_vec <- .cell_flag_vec(dynamic, "dynamic", n)
  range_list  <- .cell_range_list(array_range, n)
  .check_array_args(value_list, formula_vec, array_vec, dynamic_vec, range_list,
                    formula_given = !is.null(formula))
  .check_rich_value(value_list, formula_vec, hyperlink_list)

  # Normalise `na` to one entry per cell -------------------------------------
  # NULL where the cell inherits, so C can tell "this cell says blank is fine"
  # from "this cell has nothing to say".
  na_src <- if (is.list(na)) na else as.list(na)
  if (length(na_src) != 1L && length(na_src) != n)
    stop(sprintf("`na` must be length 1 or %d (one per cell), not %d", n,
                 length(na_src)), call. = FALSE)
  na_list <- rep_len(lapply(na_src, .val_na, arg = "na"), n)

  # Build the per-cell records ------------------------------------------------
  cells <- lapply(seq_len(n), function(i) {
    list(
      value       = value_list[[i]],
      formula     = formula_vec[[i]],
      hyperlink   = hyperlink_list[[i]],
      format      = format_list[[i]],
      comment     = comment_list[[i]],
      array       = array_vec[[i]],
      dynamic     = dynamic_vec[[i]],
      array_range = range_list[[i]],
      na          = na_list[[i]]
    )
  })

  .new_cell_vector(cells)
}

# --- The object ---------------------------------------------------------------
#
# An xl_cell_general is an integer vector carrying its per-cell records in an
# attribute, the shape factor uses.  The integers are bookkeeping: they are
# always seq_along(records), and nothing reads them.  What matters is that the
# object is *not* a list, because `[<-.data.frame` treats a list value as a
# list of columns -- so `df[, j] <- cells` assigned one cell record per column
# and lost the class, which is what broke a reverse dependency on 2.0.0.
#
# The carrier is an integer rather than the displayed text on purpose.  If the
# class is ever stripped -- recycling into a longer frame does it, in base R
# and in writexl 1.5.4 alike -- an integer leaves an obviously wrong 1, 2, 3
# rather than plausible-looking text that would write as silently wrong cells.

# The carrier is the authority on length, not the records.  R extends a data
# frame's rows through xpdrows.data.frame(), which does `class(y) <- NULL;
# length(y) <- n; attributes(y) <- saved` -- it strips the class, pads the
# carrier, and puts the old attributes back verbatim, so no method of ours is
# consulted and the records come back short.  Padding them here on the way out
# makes the object heal itself: the new cells read as empty, which is what
# extending a data frame means.
.cell_records <- function(x) {
  r <- attr(x, "records", exact = TRUE)
  n <- length(unclass(x))
  if (length(r) != n) {
    length(r) <- n
    r <- .fill_empty_cells(r)
  }
  r
}

.new_cell_vector <- function(records) {
  structure(seq_along(records), records = records,
            class = c("xl_cell_general", "xl_cell"))
}

#' @description
#' `as.character()` returns what each cell displays, so a cell built for a sheet
#' can be reused wherever a plain string is wanted --- [xl_merge()]'s or
#' [xl_comment()]'s `value`, for instance.  A cell that carries only a formula
#' shows `NA`: its displayed value comes from Excel, not from writexl.
#' @param x An `xl_cell_general`.
#' @param ... Ignored.
#' @rdname xl_cell_general
#' @export
as.character.xl_cell_general <- function(x, ...) {
  vapply(.cell_records(x), function(el) {
    v <- el[["value"]]
    if (is_xl_rich_string(v)) as.character(v)
    else if (is.null(v) || length(v) != 1L) NA_character_
    else as.character(v)
  }, character(1))
}

# A rich string is the cell's text, so it cannot share the cell with a formula
# (whose result Excel supplies) or a hyperlink (whose display text is a plain
# string).  Both would silently discard the runs, so refuse the combination.
.check_rich_value <- function(values, formulas, hyperlinks) {
  for (i in seq_along(values)) {
    if (!is_xl_rich_string(values[[i]])) next
    if (!is.na(formulas[[i]]))
      stop(sprintf(paste0("cell %d combines a rich string `value` with a ",
                          "`formula`; a formula's result comes from Excel, so ",
                          "the runs would be discarded"), i), call. = FALSE)
    hl <- hyperlinks[[i]]
    hl_set <- !(is.null(hl) || identical(hl, NA) ||
                (is.character(hl) && length(hl) == 1L && is.na(hl)))
    if (hl_set)
      stop(sprintf(paste0("cell %d combines a rich string `value` with a ",
                          "`hyperlink`; a hyperlink's display text is a plain ",
                          "string, so the runs would be discarded"), i),
           call. = FALSE)
  }
  invisible(NULL)
}

# The number of cells a `value` argument describes.  An xl_rich_string is one
# cell's worth of text, not one cell per run.
.value_length <- function(value) {
  if (is_xl_rich_string(value)) 1L else length(value)
}

# --- array / dynamic formula helpers -----------------------------------------

# Recycle a logical flag argument to length n, refusing NA (a flag is a
# decision, and NA would leave it ambiguous).
.cell_flag_vec <- function(x, arg, n) {
  if (is.null(x)) return(rep(FALSE, n))
  if (!is.logical(x) || !length(x) || anyNA(x))
    stop(sprintf("`%s` must be TRUE or FALSE (no NA)", arg), call. = FALSE)
  rep_len(x, n)
}

# Normalise array_range to a list of length n whose elements are NULL (no
# declared range) or a range spec to be resolved at write time.
.cell_range_list <- function(x, n) {
  if (is.null(x)) return(vector("list", n))
  r <- if (is.list(x)) x else as.list(x)
  r <- lapply(r, function(el) {
    if (is.null(el) || (length(el) == 1L && !is.list(el) && is.na(el)))
      return(NULL)
    if (is.character(el) && length(el) == 1L) return(el)
    if (is.list(el)) return(el)
    stop("each `array_range` must be NA, an Excel range string, or a ",
         "list(rows = , cols = ) spec", call. = FALSE)
  })
  rep_len(r, n)
}

# Validate the array/dynamic arguments.
#
# `array` / `dynamic` describe how a *formula* is stored, so on a cell with no
# formula they are simply inert -- which is what recycling a single TRUE across
# a column of mixed formula and value cells has to mean.  What is worth
# refusing is a flag that can never apply to anything, and a combination Excel
# cannot represent.
.check_array_args <- function(values, formulas, arrays, dynamics, ranges,
                              formula_given) {
  any_flag  <- any(arrays) || any(dynamics)
  any_range <- any(!vapply(ranges, is.null, logical(1)))
  if (any_flag && !formula_given)
    stop("`array`/`dynamic` describe how a formula is stored, but no `formula` ",
         "was supplied", call. = FALSE)
  if (any_range && !any_flag)
    stop("`array_range` applies only to an `array` or `dynamic` formula, and ",
         "neither is set", call. = FALSE)
  for (i in seq_along(formulas)) {
    if (is.na(formulas[[i]])) next          # flags are inert without a formula
    if (!(arrays[[i]] || dynamics[[i]])) next
    # libxlsxwriter has write_array_formula_num() but no _str() counterpart:
    # Excel stores no cached string result for an array formula.  Erroring beats
    # silently downgrading to a plain formula and losing the array semantics.
    v <- values[[i]]
    if (is.character(v) && length(v) == 1L && !is.na(v))
      stop(sprintf(paste0("cell %d combines a character `value` with ",
                          "`array`/`dynamic`; only a numeric pre-calculated ",
                          "result can be stored for an array formula"), i),
           call. = FALSE)
  }
  invisible(NULL)
}

# --- S3 methods --------------------------------------------------------------

#' @export
length.xl_cell_general <- function(x) length(unclass(x))

#' @export
`[.xl_cell_general` <- function(x, i, ...) {
  .new_cell_vector(.cell_records(x)[i])
}

# `x[[i]]` is one cell's record, the named list of its fields -- the carrier is
# bookkeeping and never the answer to a question about a cell.
#' @export
`[[.xl_cell_general` <- function(x, i, ...) .cell_records(x)[[i]]

#' @export
`[[<-.xl_cell_general` <- function(x, i, value) {
  recs <- .cell_records(x)
  recs[[i]] <- value
  .new_cell_vector(recs)
}

# Assigning into a cell column has to reach the records.  Without this method
# `df[i, j] <- value` writes into the integer carrier instead -- silently, and
# leaving the records one short -- because that is where `[<-.data.frame`
# sends it.  A bare value becomes the cell's `value`; an xl_cell_general on
# the right replaces those cells outright.
#' @export
`[<-.xl_cell_general` <- function(x, i, value) {
  recs <- .cell_records(x)
  .warn_formula_as_text(value)
  repl <- if (inherits(value, "xl_cell_general")) .cell_records(value)
          else .cell_records(xl_cell_general(value = value))
  # `i` may point past the end -- `d[3, 2] <-` on a one-row frame does exactly
  # that -- so index the list directly and let it grow.  seq_along(recs)[i]
  # would give NA there and drop the assignment on the floor.
  k <- if (is.logical(i)) sum(i, na.rm = TRUE) else length(i)
  recs[i] <- rep_len(repl, k)
  .new_cell_vector(.fill_empty_cells(recs))
}

# A string beginning with "=" assigned into a cell column is written as those
# characters, not as a formula: a cell column has no column-wide "these are all
# formulas".  That is the documented rule, but it is a quiet way to lose every
# formula on a sheet -- a reverse dependency shipped a workbook with twenty
# inert HYPERLINK strings and nothing said a word -- so say something.
#
# Only on assignment, where the ambiguity is.  xl_cell_general(value = "=x")
# writing text is deliberate and stays silent.
.warn_formula_as_text <- function(value) {
  if (inherits(value, "xl_cell_general") || !is.character(value)) return(invisible(NULL))
  v <- value[!is.na(value)]
  if (!length(v) || !any(startsWith(v, "="))) return(invisible(NULL))
  warning("assigning a string beginning with \"=\" into a cell column writes ",
          "those characters, not a formula. Use xl_formula() for a formula, ",
          "either per cell or over the finished column:
",
          "  df[[j]] <- xl_formula(df[[j]])", call. = FALSE)
  invisible(NULL)
}

# Extending a data frame pads its other columns, and `[<-.data.frame` does that
# through `length<-`.  Without this method the cell column stays short and the
# frame is refused with "replacement has N rows, data has N+1".
#' @export
`length<-.xl_cell_general` <- function(x, value) {
  recs <- .cell_records(x)
  length(recs) <- value
  .new_cell_vector(.fill_empty_cells(recs))
}

# `[<-` and `length<-` can both leave NULL holes past the old end.  A hole is a
# cell with nothing in it, which is what an unwritten cell already means.
.fill_empty_cells <- function(recs) {
  holes <- vapply(recs, is.null, logical(1))
  if (any(holes))
    recs[holes] <- list(.cell_records(xl_cell_general(value = NA))[[1L]])
  recs
}

#' @export
c.xl_cell_general <- function(x, ...) {
  parts <- lapply(list(x, ...), function(el)
    if (inherits(el, "xl_cell_general")) .cell_records(el)
    else .cell_records(xl_cell_general(value = el)))
  .new_cell_vector(do.call(c, parts))
}

#' @export
rep.xl_cell_general <- function(x, ...) {
  .new_cell_vector(rep(.cell_records(x), ...))
}

#' @export
as.data.frame.xl_cell_general <- function(x, row.names = NULL, optional = FALSE, ...) {
  # Omit names so that data.frame() uses the caller's argument name rather
  # than an arbitrary inner name.  This mirrors how I(list(...)) behaves:
  # names(xi) == NULL causes data.frame() to leave vnames[[i]] as the
  # argument name instead of overriding it.
  structure(
    list(x),
    class     = "data.frame",
    row.names = if (is.null(row.names)) seq_along(x) else row.names
  )
}

#' @export
print.xl_cell_general <- function(x, max = 10L, ...) {
  n <- length(x)
  cat(sprintf("[xl_cell_general: %d cell%s]\n", n, if (n == 1L) "" else "s"))
  show <- seq_len(min(n, max))
  for (i in show) {
    cell  <- .cell_records(x)[[i]]
    parts <- character(0L)

    val <- cell[["value"]]
    has_val <- !is.null(val) && length(val) > 0L &&
               !all(is.na(val))
    if (has_val)
      parts <- c(parts, paste0("value=", format(val)))

    fml <- cell[["formula"]]
    if (!is.null(fml) && !is.na(fml))
      parts <- c(parts, paste0("formula=", fml))

    hlk <- cell[["hyperlink"]]
    hlk_set <- !is.null(hlk) && !identical(hlk, NA) &&
               !(is.character(hlk) && length(hlk) == 1L && is.na(hlk))
    if (hlk_set)
      parts <- c(parts, "hyperlink=<set>")

    if (is_xl_format(cell[["format"]]))
      parts <- c(parts, "format=<set>")

    if (!is.null(cell[["comment"]]))
      parts <- c(parts, "comment=<set>")

    cat(sprintf("  [%d] %s\n", i,
                if (length(parts)) paste(parts, collapse = ", ")
                else "<empty>"))
  }
  if (n > max)
    cat(sprintf("  ... (%d more)\n", n - max))
  invisible(x)
}
