beatrina 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
package/kernel/sniff.R ADDED
@@ -0,0 +1,769 @@
1
+ # sniff.R — format and column-type detection for the import wizard.
2
+ #
3
+ # Pure functions, no I/O side effects beyond reading the file it is asked
4
+ # about, and no `emit`. worker.R sources this into its private scope; the test
5
+ # sources it into a plain environment. Nothing here touches globalenv().
6
+ #
7
+ # THE PRINCIPLE: read every column as `character`, then decide. R's own
8
+ # `read.csv` decides during the read, where a wrong guess is silent and
9
+ # unrecoverable — a column of "03/04/2024" becomes a factor or a string and
10
+ # the date is gone; a column of "1,5" becomes character and the number is
11
+ # gone. Detection on raw text is inspectable, overridable, and reversible.
12
+ #
13
+ # THE OTHER PRINCIPLE: a format is accepted only if it explains EVERY value.
14
+ # strptime ignores trailing characters — as.Date("2024-03-04 10:00", "%Y-%m-%d")
15
+ # returns a valid date and throws the time away without a word. So every
16
+ # candidate format is first shape-gated by an anchored regex built from the
17
+ # format itself, and only then parsed. That single rule is the difference
18
+ # between detection you can trust and RStudio's guess.
19
+
20
+ # ── the NA vocabulary ───────────────────────────────────────────────────────
21
+ # The tokens that mean "missing" in files people actually receive. Detected,
22
+ # reported, and written into the generated code as `na.strings` — never
23
+ # applied invisibly.
24
+ NA_TOKENS <- c("", "NA", "N/A", "n/a", "#N/A", "#NA", "NULL", "null", "None",
25
+ "none", "nan", "NaN", ".", "-", "--", "?", "missing", "MISSING",
26
+ "Missing", "unknown", "Unknown", "<NA>")
27
+
28
+ LOGICAL_TRUE <- c("TRUE", "true", "True", "T", "YES", "yes", "Yes", "Y")
29
+ LOGICAL_FALSE <- c("FALSE", "false", "False", "F", "NO", "no", "No", "N")
30
+
31
+ DELIMITERS <- list(
32
+ list(char = ",", name = "comma"),
33
+ list(char = ";", name = "semicolon"),
34
+ list(char = "\t", name = "tab"),
35
+ list(char = "|", name = "pipe"),
36
+ list(char = " ", name = "space")
37
+ )
38
+
39
+ MAX_SNIFF_LINES <- 500L # lines read to decide format
40
+ MAX_SNIFF_VALUES <- 5000L # values per column examined for type
41
+ MAX_PREVIEW_ROWS <- 30L # rows returned to the wizard
42
+ MAX_PREVIEW_COLS <- 60L
43
+ MAX_EXAMPLES <- 4L
44
+
45
+ `%||%` <- function(a, b) if (is.null(a)) b else a
46
+
47
+ REGEX_META <- c(".", "^", "$", "|", "(", ")", "[", "]", "{", "}", "*", "+", "?", "\\")
48
+
49
+ # ── format → anchored regex ────────────────────────────────────────────────
50
+
51
+ #' The regex that a string must match before a format is even tried.
52
+ #'
53
+ #' strptime accepts trailing garbage, so "2024-03-04 10:00:00" parses cleanly
54
+ #' as "%Y-%m-%d" and the time vanishes. Anchoring a shape built from the same
55
+ #' format string closes that hole, and it closes it for every format at once
56
+ #' rather than one special case at a time.
57
+ #'
58
+ #' @param fmt A strptime format string.
59
+ #' @return A single anchored regex, or NA when the format uses a directive
60
+ #' this builder does not model (the caller then declines the candidate).
61
+ format_regex <- function(fmt) {
62
+ stopifnot(is.character(fmt), length(fmt) == 1L)
63
+ pieces <- list(
64
+ "%Y" = "[0-9]{4}", "%y" = "[0-9]{2}",
65
+ "%m" = "[0-9]{1,2}", "%d" = "[0-9]{1,2}", "%e" = "[ 0-9][0-9]",
66
+ "%H" = "[0-9]{1,2}", "%I" = "[0-9]{1,2}",
67
+ "%M" = "[0-9]{2}", "%S" = "[0-9]{2}",
68
+ "%OS" = "[0-9]{2}(?:[.,][0-9]+)?",
69
+ "%j" = "[0-9]{3}",
70
+ "%b" = "[A-Za-z]{3,9}[.]?", "%B" = "[A-Za-z]{3,9}",
71
+ "%p" = "[AaPp][.]?[Mm][.]?",
72
+ "%z" = "(?:Z|[+-][0-9]{2}:?[0-9]{2})",
73
+ "%Z" = "[A-Za-z/_+-]{1,32}"
74
+ )
75
+ # Longest directives first: %OS must win over %O/%S, %B over %b's shape.
76
+ keys <- names(pieces)[order(-nchar(names(pieces)))]
77
+ out <- ""
78
+ rest <- fmt
79
+ while (nzchar(rest)) {
80
+ if (substr(rest, 1L, 1L) == "%") {
81
+ hit <- Find(function(k) startsWith(rest, k), keys)
82
+ if (is.null(hit)) return(NA_character_) # unmodelled directive
83
+ out <- paste0(out, pieces[[hit]])
84
+ rest <- substring(rest, nchar(hit) + 1L)
85
+ } else {
86
+ ch <- substr(rest, 1L, 1L)
87
+ # A literal space in a format matches run-of-whitespace in real files.
88
+ # Escaping by MEMBERSHIP rather than by a regex over regex metacharacters:
89
+ # the pattern that quotes `{` is itself invalid in TRE, which is exactly
90
+ # the kind of nested-escaping bug that hides until a "%B %d, %Y" shows up.
91
+ out <- paste0(out, if (ch == " ") "[[:space:]]+"
92
+ else if (ch %in% REGEX_META) paste0("\\", ch)
93
+ else ch)
94
+ rest <- substring(rest, 2L)
95
+ }
96
+ }
97
+ paste0("^", out, "$")
98
+ }
99
+
100
+ #' Does this format explain every value in `x`?
101
+ #'
102
+ #' All-or-nothing by design: a format that parses 97% of a column is not the
103
+ #' column's format, it is a coincidence plus three rows of damage.
104
+ #'
105
+ #' @param x Character vector, already stripped of NA tokens.
106
+ #' @param fmt strptime format.
107
+ #' @param kind "Date", "POSIXct" or "time".
108
+ #' @param tz Timezone used for the POSIXct trial parse.
109
+ #' @return TRUE when the shape matches everywhere AND every value parses.
110
+ format_explains <- function(x, fmt, kind = "Date", tz = "UTC") {
111
+ if (!length(x)) return(FALSE)
112
+ rx <- format_regex(fmt)
113
+ if (is.na(rx)) return(FALSE)
114
+ if (!all(grepl(rx, x, perl = TRUE))) return(FALSE)
115
+ parsed <- tryCatch({
116
+ if (identical(kind, "Date")) as.Date(x, format = fmt)
117
+ else as.POSIXct(strptime(x, format = fmt, tz = tz), tz = tz)
118
+ }, error = function(e) NULL, warning = function(w) NULL)
119
+ !is.null(parsed) && !anyNA(parsed)
120
+ }
121
+
122
+ # ── the format battery ──────────────────────────────────────────────────────
123
+
124
+ DATE_FORMATS <- c(
125
+ # ISO and other year-first shapes: unambiguous, so they go first.
126
+ "%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d", "%Y-%j",
127
+ # Day-first and month-first are listed as a PAIR; when both survive the
128
+ # column is genuinely ambiguous and the caller is told so.
129
+ "%d/%m/%Y", "%m/%d/%Y",
130
+ "%d-%m-%Y", "%m-%d-%Y",
131
+ "%d.%m.%Y", "%m.%d.%Y",
132
+ "%d/%m/%y", "%m/%d/%y", "%y-%m-%d", "%y/%m/%d",
133
+ # Month names — unambiguous whichever side they sit on.
134
+ "%d %b %Y", "%d %B %Y", "%b %d %Y", "%B %d %Y",
135
+ "%b %d, %Y", "%B %d, %Y", "%d-%b-%Y", "%d-%b-%y", "%b-%Y", "%B %Y"
136
+ )
137
+
138
+ TIME_SUFFIXES <- c(
139
+ " %H:%M:%S", "T%H:%M:%S", " %H:%M", "T%H:%M",
140
+ " %H:%M:%OS", "T%H:%M:%OS",
141
+ " %H:%M:%SZ", "T%H:%M:%SZ", "T%H:%M:%OSZ",
142
+ " %H:%M:%S%z", "T%H:%M:%S%z", "T%H:%M:%OS%z",
143
+ " %H:%M:%S %Z", " %I:%M %p", " %I:%M:%S %p"
144
+ )
145
+
146
+ TIME_ONLY_FORMATS <- c("%H:%M:%S", "%H:%M", "%H:%M:%OS", "%I:%M %p", "%I:%M:%S %p")
147
+
148
+ #' Every datetime format worth trying, cheapest-shape-first.
149
+ #' The cross product is large, so callers shape-gate on ONE value before
150
+ #' spending an all-or-nothing pass over the sample.
151
+ datetime_formats <- function() {
152
+ as.vector(t(outer(DATE_FORMATS, TIME_SUFFIXES, paste0)))
153
+ }
154
+
155
+ #' The pair a day/month ambiguity is made of, if this format is half of one.
156
+ #' @return The other format, or NA when the format cannot be ambiguous.
157
+ ambiguous_twin <- function(fmt) {
158
+ pairs <- c("%d/%m/%Y" = "%m/%d/%Y", "%m/%d/%Y" = "%d/%m/%Y",
159
+ "%d-%m-%Y" = "%m-%d-%Y", "%m-%d-%Y" = "%d-%m-%Y",
160
+ "%d.%m.%Y" = "%m.%d.%Y", "%m.%d.%Y" = "%d.%m.%Y",
161
+ "%d/%m/%y" = "%m/%d/%y", "%m/%d/%y" = "%d/%m/%y")
162
+ base_fmt <- sub("[ T].*$", "", fmt)
163
+ # match(), not `[[`: a named character vector THROWS on a missing name
164
+ # rather than returning NULL, so `%||%` never gets the chance to catch it.
165
+ idx <- match(base_fmt, names(pairs))
166
+ if (is.na(idx)) return(NA_character_)
167
+ twin <- unname(pairs[idx])
168
+ sub(base_fmt, twin, fmt, fixed = TRUE)
169
+ }
170
+
171
+ # ── numbers ─────────────────────────────────────────────────────────────────
172
+
173
+ #' Strip the decoration real spreadsheets put on numbers.
174
+ #'
175
+ #' @param x Character vector.
176
+ #' @param decimal "." or "," — the mark that survives; the other is grouping.
177
+ #' @return Character vector ready for as.numeric().
178
+ undecorate_number <- function(x, decimal = ".") {
179
+ out <- trimws(x)
180
+ out <- gsub("^[(]([^)]*)[)]$", "-\\1", out) # (1 234) accounting negative
181
+ out <- gsub("[   [:space:]]", "", out) # thin/no-break spaces
182
+ out <- gsub("[$€£¥%]", "", out) # currency and percent
183
+ if (identical(decimal, ",")) {
184
+ out <- gsub(".", "", out, fixed = TRUE)
185
+ out <- gsub(",", ".", out, fixed = TRUE)
186
+ } else {
187
+ out <- gsub(",", "", out, fixed = TRUE)
188
+ }
189
+ out
190
+ }
191
+
192
+ #' Is every value a number once the decoration is removed?
193
+ #' @return list(ok, decimal, decorated) — `decorated` says the plain read
194
+ #' would have failed, which is what the generated code has to handle.
195
+ numeric_reading <- function(x) {
196
+ plain <- suppressWarnings(as.numeric(x))
197
+ if (!anyNA(plain)) return(list(ok = TRUE, decimal = ".", decorated = FALSE))
198
+ try_mark <- function(mark) {
199
+ v <- suppressWarnings(as.numeric(undecorate_number(x, mark)))
200
+ !anyNA(v)
201
+ }
202
+ # Comma-decimal is tried first only when a comma is actually present as the
203
+ # LAST separator — otherwise "1,234" (a US thousand) would read as 1.234.
204
+ comma_decimal <- all(grepl("^[^,]*,[0-9]+$", trimws(x)))
205
+ if (comma_decimal && try_mark(",")) return(list(ok = TRUE, decimal = ",", decorated = TRUE))
206
+ if (try_mark(".")) return(list(ok = TRUE, decimal = ".", decorated = TRUE))
207
+ if (try_mark(",")) return(list(ok = TRUE, decimal = ",", decorated = TRUE))
208
+ list(ok = FALSE, decimal = ".", decorated = FALSE)
209
+ }
210
+
211
+ # ── one column ──────────────────────────────────────────────────────────────
212
+
213
+ #' Guess what a column of raw text actually is.
214
+ #'
215
+ #' @param x Character vector as read from the file (NA tokens still present).
216
+ #' @param name The column's name, used only for date-ish hints on numbers.
217
+ #' @param na_strings Tokens treated as missing.
218
+ #' @param tz Timezone assumed for naive timestamps.
219
+ #' @return A list describing the column: `type`, `format`, `tz`, `decimal`,
220
+ #' `ambiguous`, `alternatives`, counts and examples. Every field the wizard
221
+ #' shows and every field the code generator needs.
222
+ guess_column <- function(x, name = "", na_strings = NA_TOKENS, tz = "UTC") {
223
+ x <- as.character(x)
224
+ n_total <- length(x)
225
+ is_na <- is.na(x) | trimws(x) %in% na_strings
226
+ seen_na <- unique(trimws(x[is_na & !is.na(x)]))
227
+ xs <- trimws(x[!is_na])
228
+ if (length(xs) > MAX_SNIFF_VALUES) xs <- xs[seq_len(MAX_SNIFF_VALUES)]
229
+
230
+ out <- list(
231
+ name = name, type = "character", format = NULL, tz = NULL, decimal = NULL,
232
+ decorated = FALSE, strip = FALSE, pin = FALSE, ambiguous = FALSE,
233
+ alternatives = I(list()),
234
+ n_total = n_total, n_missing = sum(is_na), n_checked = length(xs),
235
+ n_distinct = length(unique(xs)),
236
+ # I(): jsonlite's auto_unbox turns a length-1 vector into a SCALAR, and a
237
+ # client doing `examples.join(...)` then gets a string and throws. The
238
+ # worker already learned this once (emit_dataframe marks columns/types
239
+ # the same way). Every field the wire promises as an array is marked, so
240
+ # the promise holds at length 0, 1 and many.
241
+ na_tokens = I(as.character(sort(seen_na))),
242
+ examples = I(as.character(utils::head(unique(xs), MAX_EXAMPLES))),
243
+ note = NULL
244
+ )
245
+ if (!length(xs)) {
246
+ out$note <- "every value is missing"
247
+ return(as_wire_column(out))
248
+ }
249
+
250
+ # 1. logical — spelled words only. 0/1 is a number until someone says
251
+ # otherwise; silently turning a count into TRUE/FALSE is not a guess a
252
+ # reader can undo.
253
+ if (all(xs %in% c(LOGICAL_TRUE, LOGICAL_FALSE))) {
254
+ out$type <- "logical"
255
+ return(as_wire_column(out))
256
+ }
257
+
258
+ # 2. identifiers that merely LOOK numeric. Two ways a number destroys an id,
259
+ # both silent and both common:
260
+ # * leading zeros carry meaning — 007, postal codes, gene ids, Finnish
261
+ # municipality codes — and reading them as numbers deletes them;
262
+ # * a digit string longer than a double holds exactly (2^53) comes back
263
+ # rounded, so the last digits of a long accession or account number
264
+ # quietly change.
265
+ # Both stay text, with the numeric reading offered rather than taken.
266
+ digits_only <- gsub("[^0-9]", "", xs)
267
+ looks_id <- any(grepl("^[+-]?0[0-9]", xs)) || any(nchar(digits_only) > 15L)
268
+ if (looks_id && !anyNA(suppressWarnings(as.numeric(xs)))) {
269
+ out$type <- "character"
270
+ # A STRUCTURED flag, not a sentence. The code generator has to decide
271
+ # whether to pin this column's class, and deciding it by matching the
272
+ # wording of a human-readable note is how "007" quietly became 7 again.
273
+ out$pin <- TRUE
274
+ out$note <- if (any(grepl("^[+-]?0[0-9]", xs))) {
275
+ "leading zeros — kept as text, because reading it as a number deletes them"
276
+ } else {
277
+ "too many digits for a number to hold exactly — kept as text"
278
+ }
279
+ out$alternatives <- c(out$alternatives, list(list(
280
+ type = "numeric", label = "number (drops leading zeros)", suggested = FALSE)))
281
+ return(as_wire_column(out))
282
+ }
283
+
284
+ # 3. numbers, including the decorated ones a plain read would drop.
285
+ num <- numeric_reading(xs)
286
+ if (num$ok) {
287
+ values <- suppressWarnings(as.numeric(undecorate_number(xs, num$decimal)))
288
+ whole <- all(values == floor(values)) && all(abs(values) <= .Machine$integer.max)
289
+ out$type <- if (whole && !any(grepl("[.]", xs))) "integer" else "numeric"
290
+ out$decimal <- num$decimal
291
+ out$decorated <- num$decorated
292
+ # Does `dec = ","` alone rescue this column, or does it need characters
293
+ # stripped out? The distinction decides whether the generated code is one
294
+ # clean read argument or a visible repair step, so it is settled here
295
+ # rather than guessed by the code generator.
296
+ out$strip <- num$decorated && anyNA(suppressWarnings(as.numeric(
297
+ if (identical(num$decimal, ",")) sub(",", ".", xs, fixed = TRUE) else xs)))
298
+ # A number that is really a date, offered but never imposed: Excel ships
299
+ # dates as days since 1899-12-30 and the column looks like 45000.
300
+ date_ish <- grepl("date|day|dob|birth|time|stamp", name, ignore.case = TRUE)
301
+ if (whole && all(values >= 1) && all(values <= 80000)) {
302
+ out$alternatives <- c(out$alternatives, list(list(
303
+ type = "excel_date", label = "Excel serial date (days since 1899-12-30)",
304
+ suggested = date_ish)))
305
+ }
306
+ if (all(values >= 5e8) && all(values <= 4e9)) {
307
+ out$alternatives <- c(out$alternatives, list(list(
308
+ type = "epoch_seconds", label = "Unix time (seconds since 1970-01-01)",
309
+ suggested = date_ish)))
310
+ }
311
+ if (all(values >= 5e11) && all(values <= 4e12)) {
312
+ out$alternatives <- c(out$alternatives, list(list(
313
+ type = "epoch_millis", label = "Unix time (milliseconds since 1970-01-01)",
314
+ suggested = date_ish)))
315
+ }
316
+ return(as_wire_column(out))
317
+ }
318
+
319
+ # 4. dates and timestamps. Shape-gate on one value, then demand the format
320
+ # explain the whole sample.
321
+ probe <- xs[1L]
322
+ shape_ok <- function(fmt) {
323
+ rx <- format_regex(fmt)
324
+ !is.na(rx) && grepl(rx, probe, perl = TRUE)
325
+ }
326
+ accept <- function(fmt, kind) {
327
+ out$type <<- kind
328
+ out$format <<- fmt
329
+ if (identical(kind, "POSIXct")) {
330
+ # A naive timestamp has no timezone in it, so one is CHOSEN here and
331
+ # written into the generated code where the reader can see it. R's
332
+ # default (tz = "") silently means "this machine, today" — the same file
333
+ # then reads differently in Helsinki and in Boston.
334
+ out$tz <<- if (grepl("%z|Z$", fmt)) "UTC" else tz
335
+ }
336
+ twin <- ambiguous_twin(fmt)
337
+ if (!is.na(twin) && format_explains(xs, twin, kind, tz)) {
338
+ out$ambiguous <<- TRUE
339
+ out$alternatives <<- c(out$alternatives, list(list(
340
+ type = kind, format = twin, label = describe_format(twin), suggested = FALSE)))
341
+ out$note <<- sprintf(
342
+ "Ambiguous: every value fits both %s and %s. No value has a day above 12, so the file itself cannot settle it — choose.",
343
+ describe_format(fmt), describe_format(twin))
344
+ }
345
+ as_wire_column(out)
346
+ }
347
+
348
+ dt_candidates <- Filter(shape_ok, datetime_formats())
349
+ hit <- Find(function(f) format_explains(xs, f, "POSIXct", tz), dt_candidates)
350
+ if (!is.null(hit)) return(accept(hit, "POSIXct"))
351
+
352
+ d_candidates <- Filter(shape_ok, DATE_FORMATS)
353
+ hit <- Find(function(f) format_explains(xs, f, "Date", tz), d_candidates)
354
+ if (!is.null(hit)) return(accept(hit, "Date"))
355
+
356
+ t_candidates <- Filter(shape_ok, TIME_ONLY_FORMATS)
357
+ hit <- Find(function(f) format_explains(xs, f, "POSIXct", tz), t_candidates)
358
+ if (!is.null(hit)) {
359
+ out$type <- "time"
360
+ out$format <- hit
361
+ out$note <- "clock time with no date — read as a duration since midnight"
362
+ return(as_wire_column(out))
363
+ }
364
+
365
+ # 5. character. A short, repeating vocabulary is offered as a factor, never
366
+ # imposed: stringsAsFactors bit a generation of R users precisely because
367
+ # it was the default rather than a decision.
368
+ if (out$n_distinct <= 25L && length(xs) >= 20L && out$n_distinct / length(xs) < 0.5) {
369
+ out$alternatives <- c(out$alternatives, list(list(
370
+ type = "factor", label = sprintf("factor (%d levels)", out$n_distinct),
371
+ suggested = FALSE)))
372
+ }
373
+ as_wire_column(out)
374
+ }
375
+
376
+ #' Re-assert the array shape of every list field after the appends above.
377
+ #'
378
+ #' `c(x, list(y))` drops the AsIs class, so marking `alternatives` once at
379
+ #' construction is not enough — it has to be re-marked at every exit.
380
+ as_wire_column <- function(col) {
381
+ col$alternatives <- I(unname(as.list(col$alternatives)))
382
+ col$examples <- I(as.character(col$examples))
383
+ col$na_tokens <- I(as.character(col$na_tokens))
384
+ col
385
+ }
386
+
387
+ #' A format string, in words — the wizard shows this, not "%d/%m/%Y".
388
+ describe_format <- function(fmt) {
389
+ if (is.null(fmt) || is.na(fmt)) return("")
390
+ words <- fmt
391
+ subs <- c("%Y" = "YYYY", "%y" = "YY", "%m" = "MM", "%d" = "DD", "%e" = "D",
392
+ "%H" = "hh", "%I" = "hh", "%M" = "mm", "%S" = "ss", "%OS" = "ss.s",
393
+ "%b" = "Mon", "%B" = "Month", "%p" = "AM/PM", "%z" = "+ZZZZ",
394
+ "%Z" = "TZ", "%j" = "DDD")
395
+ keys <- names(subs)[order(-nchar(names(subs)))]
396
+ Reduce(function(acc, k) gsub(k, subs[[k]], acc, fixed = TRUE), keys, words)
397
+ }
398
+
399
+ # ── the file ────────────────────────────────────────────────────────────────
400
+
401
+ #' Encoding, by evidence rather than hope.
402
+ #' @return list(encoding, bom) — `bom` is the byte count to skip.
403
+ sniff_encoding <- function(path) {
404
+ raw_head <- readBin(path, "raw", n = 65536L)
405
+ if (length(raw_head) >= 3L && identical(as.integer(raw_head[1:3]), c(239L, 187L, 191L))) {
406
+ return(list(encoding = "UTF-8", bom = 3L))
407
+ }
408
+ if (length(raw_head) >= 2L && identical(as.integer(raw_head[1:2]), c(255L, 254L))) {
409
+ return(list(encoding = "UTF-16LE", bom = 2L))
410
+ }
411
+ if (length(raw_head) >= 2L && identical(as.integer(raw_head[1:2]), c(254L, 255L))) {
412
+ return(list(encoding = "UTF-16BE", bom = 2L))
413
+ }
414
+ txt <- tryCatch(rawToChar(raw_head), error = function(e) "")
415
+ Encoding(txt) <- "UTF-8"
416
+ # validUTF8 on the whole blob: a single invalid byte means this is not UTF-8,
417
+ # and latin1 is the overwhelmingly likely alternative for CSVs in the wild.
418
+ list(encoding = if (validUTF8(txt)) "UTF-8" else "latin1", bom = 0L)
419
+ }
420
+
421
+ #' Which delimiter makes this file rectangular?
422
+ #'
423
+ #' Scored on CONSISTENCY, not frequency: the right delimiter is the one that
424
+ #' gives every line the same field count, which is a property a comma inside
425
+ #' quoted prose cannot fake. count.fields does the quote-aware counting — the
426
+ #' same parser read.table itself uses, so agreement is guaranteed.
427
+ #'
428
+ #' @return list(delim, name, fields, confidence)
429
+ sniff_delimiter <- function(lines, quote = "\"") {
430
+ score_one <- function(d) {
431
+ counts <- tryCatch(
432
+ utils::count.fields(textConnection(lines), sep = d$char, quote = quote,
433
+ blank.lines.skip = TRUE, comment.char = ""),
434
+ error = function(e) NULL, warning = function(w) NULL)
435
+ if (is.null(counts) || !length(counts)) return(NULL)
436
+ modal <- as.integer(names(sort(table(counts), decreasing = TRUE))[1L])
437
+ if (is.na(modal) || modal < 2L) return(NULL)
438
+ list(delim = d$char, name = d$name, fields = modal,
439
+ confidence = mean(counts == modal))
440
+ }
441
+ scored <- Filter(Negate(is.null), lapply(DELIMITERS, score_one))
442
+ if (!length(scored)) {
443
+ return(list(delim = ",", name = "comma", fields = 1L, confidence = 0))
444
+ }
445
+ # More columns breaks a tie between two perfectly consistent delimiters:
446
+ # a semicolon file also reads "consistently" as one comma-free column.
447
+ best <- scored[[which.max(vapply(scored, function(s)
448
+ s$confidence * 1000 + min(s$fields, 50L), numeric(1)))]]
449
+ best
450
+ }
451
+
452
+ #' Does the first row name the columns, or is it data?
453
+ #'
454
+ #' The test that actually discriminates: a header row is all text, while the
455
+ #' body has at least one field that is NOT text. A file that is character
456
+ #' throughout falls back to "distinct, non-empty, no leading digits" — weaker,
457
+ #' and reported as such so the wizard can show it as a guess.
458
+ sniff_header <- function(first, rest) {
459
+ if (!length(first)) return(TRUE)
460
+ looks_data <- function(v) {
461
+ v <- trimws(v[!is.na(v) & nzchar(trimws(v))])
462
+ if (!length(v)) return(FALSE)
463
+ num <- suppressWarnings(as.numeric(undecorate_number(v)))
464
+ any(!is.na(num)) || any(vapply(DATE_FORMATS, function(f)
465
+ format_explains(v, f, "Date"), logical(1)))
466
+ }
467
+ if (!length(rest) || !nrow(rest)) return(!looks_data(first))
468
+ body_typed <- any(vapply(seq_along(first), function(i)
469
+ looks_data(rest[, i]), logical(1)))
470
+ if (looks_data(first)) return(FALSE)
471
+ if (body_typed) return(TRUE)
472
+ all(nzchar(trimws(first))) && !anyDuplicated(first)
473
+ }
474
+
475
+ #' Everything the wizard needs to describe a delimited file.
476
+ #' @return A list: format, settings, columns, preview, and how it was decided.
477
+ sniff_delimited <- function(path, opts = list()) {
478
+ enc <- sniff_encoding(path)
479
+ encoding <- opts$encoding %||% enc$encoding
480
+ con <- file(path, open = "r", encoding = encoding)
481
+ on.exit(close(con), add = TRUE)
482
+ lines <- readLines(con, n = MAX_SNIFF_LINES, warn = FALSE)
483
+ if (enc$bom > 0L && length(lines)) {
484
+ lines[1L] <- sub("^", "", lines[1L])
485
+ }
486
+ lines <- lines[nzchar(lines)]
487
+ if (!length(lines)) {
488
+ # A second attempt with no declared encoding, because a wrong guess must
489
+ # not be the reason a perfectly good file "has no lines". Then, if it is
490
+ # still empty, say WHICH kind of empty — a zero-byte file and a cloud
491
+ # placeholder that has not downloaded yet are different problems.
492
+ con2 <- file(path, open = "r")
493
+ lines <- tryCatch(readLines(con2, n = MAX_SNIFF_LINES, warn = FALSE),
494
+ error = function(e) character(0))
495
+ close(con2)
496
+ lines <- lines[nzchar(lines)]
497
+ }
498
+ if (!length(lines)) {
499
+ size <- as.numeric(file.info(path)$size)
500
+ stop(if (is.na(size) || size == 0) "the file is empty (0 bytes)"
501
+ else sprintf("no readable text in %.0f KB - if this lives in iCloud or Google Drive it may not be downloaded yet",
502
+ size / 1024))
503
+ }
504
+
505
+ # Leading commentary — the "# exported from ..." banner spreadsheets add.
506
+ comment_lines <- which(grepl("^\\s*#", lines))
507
+ skip <- if (length(comment_lines) && all(comment_lines == seq_along(comment_lines))) {
508
+ length(comment_lines)
509
+ } else 0L
510
+ skip <- opts$skip %||% skip
511
+ body <- if (skip > 0L) lines[-seq_len(skip)] else lines
512
+
513
+ quote <- opts$quote %||% "\""
514
+ det <- if (!is.null(opts$delim)) {
515
+ list(delim = opts$delim, name = "chosen", fields = NA_integer_, confidence = 1)
516
+ } else sniff_delimiter(body, quote)
517
+
518
+ raw <- utils::read.table(
519
+ text = paste(body, collapse = "\n"), sep = det$delim, quote = quote,
520
+ header = FALSE, colClasses = "character", stringsAsFactors = FALSE,
521
+ na.strings = character(0), comment.char = "", check.names = FALSE,
522
+ fill = TRUE, blank.lines.skip = TRUE)
523
+
524
+ header <- opts$header %||% sniff_header(as.character(unlist(raw[1L, ])),
525
+ if (nrow(raw) > 1L) raw[-1L, , drop = FALSE] else raw[0, , drop = FALSE])
526
+ if (isTRUE(header)) {
527
+ nms <- as.character(unlist(raw[1L, ]))
528
+ data <- raw[-1L, , drop = FALSE]
529
+ } else {
530
+ nms <- paste0("V", seq_len(ncol(raw)))
531
+ data <- raw
532
+ }
533
+ nms[is.na(nms) | !nzchar(trimws(nms))] <- ""
534
+ blank <- !nzchar(nms)
535
+ nms[blank] <- paste0("V", which(blank))
536
+
537
+ na_strings <- opts$naStrings %||% NA_TOKENS
538
+ tz <- opts$tz %||% "UTC"
539
+ keep <- seq_len(min(ncol(data), MAX_PREVIEW_COLS))
540
+ cols <- lapply(keep, function(i)
541
+ guess_column(data[[i]], nms[i], na_strings = na_strings, tz = tz))
542
+
543
+ list(
544
+ format = "delimited",
545
+ settings = list(
546
+ delim = det$delim, delimName = det$name, quote = quote,
547
+ encoding = encoding, header = isTRUE(header), skip = skip,
548
+ naStrings = I(as.character(na_strings)), tz = tz,
549
+ # What DETECTION used, and what the generated code should say. Keeping
550
+ # them apart stops a re-sniff from narrowing its own vocabulary.
551
+ naObserved = I(observed_na_tokens(cols)),
552
+ confidence = det$confidence
553
+ ),
554
+ columns = I(unname(cols)),
555
+ names = I(as.character(nms[keep])),
556
+ preview = I(preview_rows(data[keep], nms[keep])),
557
+ nrow = nrow(data), ncol = ncol(data),
558
+ # `nrow` is what was SAMPLED. A 400 KB file sampled at 500 lines was
559
+ # reporting "499 rows" in the badge, which is a lie about the file rather
560
+ # than a description of the sample — so the true count travels with it.
561
+ totalRows = count_data_rows(path, header = isTRUE(header), skip = skip),
562
+ truncated = length(lines) >= MAX_SNIFF_LINES
563
+ )
564
+ }
565
+
566
+ #' How many data rows the file actually has.
567
+ #'
568
+ #' Counted in chunks so a large file costs a scan and not its size in memory,
569
+ #' and capped so a pathological one cannot become an unbounded wait. NA means
570
+ #' "more than the cap", which the caller renders as "500,000+".
571
+ #'
572
+ #' @return Integer row count excluding the header, or NA past the cap.
573
+ COUNT_ROW_CAP <- 5e6
574
+ count_data_rows <- function(path, header = TRUE, skip = 0L) {
575
+ con <- file(path, open = "r")
576
+ on.exit(close(con), add = TRUE)
577
+ n <- 0
578
+ repeat {
579
+ chunk <- tryCatch(readLines(con, n = 50000L, warn = FALSE),
580
+ error = function(e) character(0))
581
+ if (!length(chunk)) break
582
+ n <- n + sum(nzchar(chunk))
583
+ if (n > COUNT_ROW_CAP) return(NA_integer_)
584
+ }
585
+ as.integer(max(0, n - skip - (if (isTRUE(header)) 1L else 0L)))
586
+ }
587
+
588
+ #' The first rows, as plain strings, for the wizard\'s grid.
589
+ preview_rows <- function(data, nms) {
590
+ if (!ncol(data)) return(list())
591
+ head_df <- utils::head(data, MAX_PREVIEW_ROWS)
592
+ lapply(seq_len(nrow(head_df)), function(i)
593
+ as.list(stats::setNames(as.character(unlist(head_df[i, ], use.names = FALSE)), nms)))
594
+ }
595
+
596
+ #' An Excel workbook: which sheets it has, and what the chosen one holds.
597
+ sniff_excel <- function(path, opts = list()) {
598
+ if (!requireNamespace("readxl", quietly = TRUE)) {
599
+ stop("readxl is not installed - install.packages(\"readxl\") to import Excel files")
600
+ }
601
+ sheets <- readxl::excel_sheets(path)
602
+ sheet <- opts$sheet %||% sheets[1L]
603
+ skip <- opts$skip %||% 0L
604
+ # col_types = "text" is the same discipline as colClasses = "character":
605
+ # readxl's own guessing is what turns a mixed column into NAs.
606
+ raw <- as.data.frame(readxl::read_excel(path, sheet = sheet, skip = skip,
607
+ col_types = "text", .name_repair = "minimal"),
608
+ stringsAsFactors = FALSE)
609
+ nms <- names(raw)
610
+ nms[is.na(nms) | !nzchar(trimws(nms))] <- ""
611
+ blank <- !nzchar(nms)
612
+ nms[blank] <- paste0("V", which(blank))
613
+ na_strings <- opts$naStrings %||% NA_TOKENS
614
+ tz <- opts$tz %||% "UTC"
615
+ keep <- seq_len(min(ncol(raw), MAX_PREVIEW_COLS))
616
+ cols <- lapply(keep, function(i)
617
+ guess_column(raw[[i]], nms[i], na_strings = na_strings, tz = tz))
618
+ list(
619
+ format = "excel",
620
+ settings = list(sheet = sheet, sheets = as.list(sheets), skip = skip,
621
+ naStrings = I(as.character(na_strings)), tz = tz, header = TRUE),
622
+ columns = I(unname(cols)),
623
+ names = I(as.character(nms[keep])),
624
+ preview = I(preview_rows(raw[keep], nms[keep])),
625
+ nrow = nrow(raw), ncol = ncol(raw), truncated = FALSE
626
+ )
627
+ }
628
+
629
+ #' A serialized R object — nothing to detect, everything already typed.
630
+ sniff_native <- function(path, format) {
631
+ list(format = format, settings = list(), columns = I(list()), names = I(character(0)),
632
+ preview = I(list()), nrow = NA_integer_, ncol = NA_integer_,
633
+ truncated = FALSE,
634
+ note = "R keeps the column types inside the file - there is nothing to guess.")
635
+ }
636
+
637
+ #' Which reader this file needs, by extension and then by content.
638
+ #'
639
+ #' Extension first because it is right almost always and costs nothing; magic
640
+ #' bytes second because a .txt holding a zip header is an .xlsx someone
641
+ #' renamed, and reading it as text produces line noise rather than an error.
642
+ detect_format <- function(path, name = path) {
643
+ # Named off the SOURCE (a URL keeps its extension; its temp copy may not),
644
+ # sniffed off the bytes actually downloaded.
645
+ ext <- tolower(tools::file_ext(sub("[?#].*$", "", name)))
646
+ magic <- tryCatch(readBin(path, "raw", n = 8L), error = function(e) raw(0))
647
+ is_zip <- length(magic) >= 4L && identical(as.integer(magic[1:2]), c(80L, 75L))
648
+ is_gz <- length(magic) >= 2L && identical(as.integer(magic[1:2]), c(31L, 139L))
649
+ if (ext %in% c("xlsx", "xlsm")) return("excel")
650
+ if (ext == "xls") return("excel")
651
+ if (ext == "rds" || (is_gz && ext == "rds")) return("rds")
652
+ if (ext %in% c("rdata", "rda")) return("rdata")
653
+ if (ext %in% c("sav", "zsav", "por")) return("spss")
654
+ if (ext == "dta") return("stata")
655
+ if (ext %in% c("sas7bdat", "xpt")) return("sas")
656
+ if (ext == "json") return("json")
657
+ if (ext == "parquet") return("parquet")
658
+ if (ext == "fst") return("fst")
659
+ if (is_zip) return("excel")
660
+ "delimited"
661
+ }
662
+
663
+ #' The missing-value tokens this file actually uses.
664
+ #'
665
+ #' The DETECTION vocabulary is deliberately broad — 22 tokens — because a
666
+ #' sniffer should recognise every spelling of "missing" it might meet. Writing
667
+ #' all 22 into the generated code is a different matter: it is a wall of noise
668
+ #' in the one artefact the user has to read, and it is WRONG, because it
669
+ #' silently converts a legitimate "-" or "." value into NA in some future file
670
+ #' the same chunk is pointed at. So the code gets the tokens actually observed,
671
+ #' plus the two nobody argues about.
672
+ #'
673
+ #' @param cols The per-column guesses.
674
+ #' @return Character vector for `na.strings`.
675
+ observed_na_tokens <- function(cols) {
676
+ seen <- unlist(lapply(cols, function(cl) as.character(cl$na_tokens)), use.names = FALSE)
677
+ unique(c("", "NA", sort(seen)))
678
+ }
679
+
680
+ #' Which reader packages this session actually has.
681
+ #'
682
+ #' `system.file()`, not `requireNamespace()`: the question is "is it
683
+ #' installed", and requireNamespace answers it by LOADING the package —
684
+ #' which for arrow or rio is seconds of work and a namespace the user never
685
+ #' asked to load, every time the wizard opens.
686
+ #'
687
+ #' @return Named list of logicals, one per reader package.
688
+ reader_packages <- function() {
689
+ want <- c("rio", "readxl", "haven", "data.table", "arrow", "fst", "jsonlite", "readr")
690
+ as.list(stats::setNames(
691
+ vapply(want, function(p) nzchar(system.file(package = p)), logical(1)), want))
692
+ }
693
+
694
+ # ── remote sources ──────────────────────────────────────────────────────────
695
+ # A URL is a first-class source. Detection needs BYTES, so the file is fetched
696
+ # once to a temp copy and sniffed there — but the generated code keeps the
697
+ # URL, because a chunk that reads a temp path is reproducible for nobody.
698
+ #
699
+ # Cached per session: the wizard re-sniffs on every settings change, and
700
+ # re-downloading a 40 MB CSV each time somebody flips a delimiter is not a
701
+ # design, it is an accident.
702
+ URL_CACHE <- new.env(parent = emptyenv())
703
+ URL_TIMEOUT <- 60
704
+
705
+ is_url <- function(path) {
706
+ is.character(path) && length(path) == 1L &&
707
+ grepl("^(https?|ftps?)://", path, ignore.case = TRUE)
708
+ }
709
+
710
+ #' The local bytes for a source, downloading a URL once.
711
+ #' @return list(local, display, remote)
712
+ resolve_source <- function(path) {
713
+ if (!is_url(path)) return(list(local = path.expand(path), display = path, remote = FALSE))
714
+ hit <- URL_CACHE[[path]]
715
+ if (!is.null(hit) && file.exists(hit)) {
716
+ return(list(local = hit, display = path, remote = TRUE))
717
+ }
718
+ # The extension is kept so detect_format() can still read it off the name.
719
+ ext <- tools::file_ext(sub("[?#].*$", "", path))
720
+ dest <- tempfile("carmar-url-", fileext = if (nzchar(ext)) paste0(".", ext) else "")
721
+ old <- options(timeout = URL_TIMEOUT)
722
+ on.exit(options(old), add = TRUE)
723
+ ok <- tryCatch({
724
+ utils::download.file(path, dest, quiet = TRUE, mode = "wb")
725
+ file.exists(dest) && file.info(dest)$size > 0
726
+ }, error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
727
+ warning = function(w) structure(class = "carmar_fail", list(msg = conditionMessage(w))))
728
+ if (inherits(ok, "carmar_fail")) stop("could not download: ", ok$msg)
729
+ if (!isTRUE(ok)) stop("could not download that URL")
730
+ assign(path, dest, envir = URL_CACHE)
731
+ list(local = dest, display = path, remote = TRUE)
732
+ }
733
+
734
+ #' The whole detection pass for one file.
735
+ #'
736
+ #' @param path File to inspect.
737
+ #' @param opts Overrides from the wizard — any setting the user has changed is
738
+ #' respected and everything else is re-detected around it, which is what
739
+ #' makes the panel feel live rather than one-shot.
740
+ #' @return The description the wizard renders and the code generator consumes.
741
+ sniff_file <- function(path, opts = list()) {
742
+ stopifnot(is.character(path), length(path) == 1L, nzchar(path))
743
+ src <- resolve_source(path)
744
+ local <- src$local
745
+ if (!file.exists(local) || dir.exists(local)) stop("not a readable file")
746
+ fmt <- opts$format %||% detect_format(local, name = src$display)
747
+ out <- switch(fmt,
748
+ excel = sniff_excel(local, opts),
749
+ delimited = sniff_delimited(local, opts),
750
+ rds = sniff_native(path, "rds"),
751
+ rdata = sniff_native(path, "rdata"),
752
+ spss = sniff_native(path, "spss"),
753
+ stata = sniff_native(path, "stata"),
754
+ sas = sniff_native(path, "sas"),
755
+ json = sniff_native(path, "json"),
756
+ parquet = sniff_native(path, "parquet"),
757
+ fst = sniff_native(path, "fst"),
758
+ stop("unsupported file type: ", fmt))
759
+ # The DISPLAY path travels onward — the wizard shows it and the code
760
+ # generator writes it — so a URL import produces a chunk anyone can re-run.
761
+ out$path <- src$display
762
+ out$remote <- src$remote
763
+ out$size <- as.numeric(file.info(local)$size)
764
+ # Reported with every sniff so the wizard can offer only engines this
765
+ # session can actually run — generating rio code for a session without rio
766
+ # is a chunk that fails on the first line.
767
+ out$packages <- reader_packages()
768
+ out
769
+ }