This document is the single source of truth for the tabshiftr package. It exists so an LLM can write correct tabshiftr code from this file alone, without opening source. When in doubt, this document overrides any other description. Update this file whenever a function's signature, behaviour, or the package structure changes.
If you are an LLM reading this: read the "Common mistakes" section first. Most failures with tabshiftr come from a small set of recurring errors that the section names directly.
These are the mistakes that recur most often. Read them first.
tabshiftr is not a CSV/Excel reader. You read the file yourself
(typically via read.csv(..., header = FALSE) or readxl::read_excel)
into a data frame, then hand that data frame plus a schema to
reorganise(). The schema tells reorganise where in the table each
variable sits.
# WRONG - tabshiftr does not read files
schema <- setIDVar(name = "year", file = "data.csv", ...)
# CORRECT - read the file first
input <- read.csv("data.csv", header = FALSE)
schema <- setIDVar(name = "year", columns = 1)
out <- reorganise(input = input, schema = schema)Row numbers in the schema refer to rows of the in-memory table. If
you read with header = TRUE, the header row is consumed into column
names and row 1 of the data is actually row 2 of the file - every
row index in the schema is then off by one and the table breaks when
the header is more than a single row.
# CORRECT - default for read.csv is header = TRUE, so override
input <- read.csv("data.csv", header = FALSE)
input <- readxl::read_excel("data.xlsx", col_names = FALSE)
# WRONG - do not do this
input <- read.csv("data.csv") # consumes header silentlyDo not use the setFormat(header = ...) argument to compensate. Just
read with header = FALSE in the first place.
- Identifying variable (
setIDVar): a qualitative property that helps identify each observation. Year, region, commodity, etc. - Observed variable (
setObsVar): a quantitative measurement. Harvested area, production, count, etc. - Implicit variable: an identifying variable whose value is the
same for the whole table (or cluster) and isn't stored in any cell.
Use
setIDVar(name = "country", value = "Germany"). - Distinct variable: an identifying variable that lives outside
the main data block (e.g. in a header cell of each cluster). Use
setIDVar(..., distinct = TRUE).
Use setCluster when the same set of variables appears more than
once in the table, nested under another variable (e.g. one block per
country, stacked vertically). Pass the top-left cell of each cluster
in left and top (vectors, one per cluster). Then specify
variables once with column/row indices that are vectors of length
== number of clusters.
schema <- setCluster(id = "territories",
left = c(1, 1, 4), top = c(1, 8, 8)) |>
setIDVar(name = "territories",
columns = c(1, 1, 4), rows = c(2, 9, 9)) |>
setIDVar(name = "commodities", columns = c(1, 1, 4)) |>
setObsVar(name = "harvested", columns = c(2, 2, 5)) |>
setObsVar(name = "production", columns = c(3, 3, 6))If clusters all share the same layout, set relative = TRUE in
.find() calls and use single values - they'll be applied
cluster-relative.
If clusters are split by an observed variable (e.g. one cluster per
commodity, where commodity is otherwise an obs var), pass
id = "observed" rather than a cluster-ID variable name. The cluster
position itself encodes the value.
columns = c(...)is the column(s) where the values sit.rows = c(...)is the row where the variable name sits, used only when the variable is wide-format (spread across columns).value = "..."is for implicit variables (no cell in the table carries this value).
You set exactly one of columns OR value. Setting both errors.
If the row/column position of a variable is fixed across all tables
you'll process, hard-code the integer. Only reach for .find() when
the position varies between files but the cell content has a
recognisable pattern.
# fixed position
setIDVar(name = "year", columns = 2)
# variable position
setIDVar(name = "year", columns = .find(pattern = "20[0-9]{2}")).find() returns indices computed at validateSchema time by
matching the regex or function against the table content.
Both apply BEFORE variable extraction. setFilter(rows = ...) keeps
only those rows; setFilter(rows = ..., invert = TRUE) drops them.
setGroups(rows = .sum(c(3,4))) collapses rows 3 and 4 into one row
by summing numerics and pasting characters.
When using invert = TRUE to exclude rows, you must include the
header row in the exclusion list - it's not auto-preserved.
If reorganise() returns a table missing variables you defined, call
validateSchema(input, schema) directly and inspect the result. That
function fills in .find() lookups and resolves wildcards; if it
errors, the schema/table mismatch is what to fix.
Schemas are R objects. They can be saved via saveRDS() and loaded
later. Do not write or expect serialisation to external formats - that
was an explicit scope decision (2026-05-05).
A schema has three slots:
| Slot | Type | Holds |
|---|---|---|
clusters |
named list |
id, group, member, left, top, width, height describing one or more rectangular sub-tables |
format |
named list |
header, decimal, thousand, na_values, zero_values, flags |
variables |
named list |
one entry per declared variable; entries are lists with type, value, columns, rows, top, split, merge, factor, key, distinct (subset depending on whether it's an ID or Obs var) |
filter |
named list |
row/column filters added by setFilter |
groups |
named list |
row/column aggregations added by setGroups |
Schemas are built fluently via set*() calls and consumed by
reorganise(). Each setter returns a new schema object; chain via
|> or %>%.
The default empty schema is schema_default (internal). Setters
called with schema = NULL start from it.
read input -> set* / .find / .sum -> validateSchema -> validateInput -> reorganise -> tidy output
(you) (you, build schema) (internal) (internal) (you)
validateSchema resolves .find() lookups against the input, fills
in implicit positions, and asserts formal consistency. validateInput
applies setGroups aggregations.
Both validators are exported but called automatically by
reorganise. Use them directly only when debugging.
library(tabshiftr)
# Bundled messy table: territories down rows, year + commodity across
input <- tabs2shift$tidy
input
# Schema: declare which columns are which
schema <- setIDVar(name = "territories", columns = 1) |>
setIDVar(name = "year", columns = 2) |>
setIDVar(name = "commodities", columns = 3) |>
setObsVar(name = "harvested", columns = 5) |>
setObsVar(name = "production", columns = 6)
# Reorganise into tidy format
reorganise(input = input, schema = schema)For a more involved layout (multi-cluster, distinct variable):
input <- tabs2shift$clusters_messy
schema <- setCluster(id = "territories",
left = c(1, 1, 4), top = c(1, 8, 8)) |>
setIDVar(name = "territories",
columns = c(1, 1, 4), rows = c(2, 9, 9)) |>
setIDVar(name = "year", columns = 4, rows = 3:6,
distinct = TRUE) |>
setIDVar(name = "commodities", columns = c(1, 1, 4)) |>
setObsVar(name = "harvested", columns = c(2, 2, 5)) |>
setObsVar(name = "production", columns = c(3, 3, 6))
reorganise(input, schema)Each set* takes an existing schema (or NULL to start a fresh
one) and returns an updated schema. Chain with |>.
Declare an identifying variable.
namevariable name in the output (required).typedata type:"character"(default),"integer","numeric","logical","Date", or"_"to skip. Single-letter shortcuts ("c","i","n","l","D") are accepted. Dates must beYYYY-MM-DD; non-matching values become NA.valueimplicit value when the variable isn't in any cell.columnsinteger vector, the column(s) where values live.rowsinteger vector, the row where variable names sit (for wide-format variables where the same variable spans multiple columns with names in a header row).splitregex to extract this variable out of a compound cell. Usestidyr::extractsemantics.mergeglue string to join several columns into one variable.distinct = TRUEif the variable lives outside the main data block (e.g. one value per cluster header).
Provide exactly one of value, columns.
Declare an observed variable.
namevariable name (required).typedata type, default"numeric". Same options assetIDVar.columnsinteger vector.toprow where the variable's name sits when nested under a wide identifying variable.factormultiplier applied to raw values (unit conversion). Default 1.keylong-format trigger: column index containing variable names when several obs vars are stacked in two columns (names_col,values_col). Alternatively"cluster"if obs var names come from the cluster ID.valuethe level in the key column that selects this variable's rows.distinct = TRUEfor obs vars recorded outside the main block.
Declare format quirks of the source table.
decimal,thousandsingle characters.na_values,zero_valuescharacter vectors of strings to interpret as NA / 0.flagstwo-column data frame (flag,value) of suffixes/markers to strip from numeric cells (e.g.cfor "estimated",*for "provisional").
Declare cluster positions.
idname of the variable that identifies clusters, OR"observed"if the cluster splits an observed variable.groupname of a higher-level grouping variable when clusters are nested.left,topinteger vectors with one value per cluster (top-left cell coords).width,heightcluster size; if NULL, inferred from layout.memberinteger vector matching each cluster to its group whengroupis set.
Keep (or drop, with invert = TRUE) specific rows or columns before
extraction.
rows/columnsinteger vector or output of.find().invert = TRUEreverses sense. When inverting row filters, include the header row inrowsexplicitly - the header isn't auto-preserved.clusters = TRUE(default) applies to cluster rows; FALSE skips.operator`|`or`&`to combine with the preceding filter. NULL stacks as AND.
Aggregate groups of rows/columns into single rows/columns before
extraction. Pass the output of .sum():
schema <- setGroups(rows = .sum(c(3, 4))) |>
setGroups(columns = .sum(c(5, 6), fill = "down"))Defer position resolution until validateSchema runs. Use either a
regex pattern or a function fun that returns logical per cell.
col/rowrestrict the search to certain columns or rows (otherwise searches everywhere).relative = TRUEinterprets indices relative to a cluster's top-left.invert = TRUEselects the complement.
Define how a group of rows/columns is reduced to one. Pass the
indices in ....
characterfunction to combine character columns; defaultpaste0(na.omit(x), collapse = "-/-").numericfunction to combine numeric columns; defaultsum(x, na.rm = TRUE).fillone of"down","up","right", applied before aggregation to fill NAs.
reorganise(input, schema).
The single user-facing entry point that converts a messy input into a tidy output. Internally:
validateSchema(input, schema)- resolves.find(), fills in wildcards, asserts consistency.validateInput(schema, input)- appliessetGroupsaggregations.- Variable extraction loop - reads each declared variable from the positions in the schema, applies type coercion and format rules.
- Assembles the tidy tibble.
Returns a tibble.
Both are exported for debugging. Calling them yourself is useful when
reorganise produces unexpected output.
validateSchema(schema, input)returns a schema with.find()calls resolved into concrete integers, missing slots filled, and positions sanity-checked.validateInput(schema, input)returns the input table after pre-processing (group aggregation).
Each returns one component of a (validated) schema for inspection.
getIDVars(schema)- list of identifying-variable specs.getObsVars(schema)- list of observed-variable specs.getClusterVar(schema)- cluster description.getGroupVar(schema)- group description.
Primary use: debugging, and as building blocks inside reorganise.
schema_builder(input).
A Shiny gadget that opens in the browser (launch.browser = TRUE)
and lets the user point-and-click on cells to build a schema
interactively. Returns a schema object via result_env$schema.
- Click "Finish" to commit the schema and close.
- Closing the browser tab without Finish is treated as cancel - the
function unblocks and returns
NULL.
Cluster-ID flow is supported. Phase 1+2 of the builder are complete;
some rarer schema combinations are still being tested against
real-world layouts. If schema_builder produces a schema that
validateSchema rejects, fall back to writing the schema by hand.
A named list of example messy tables, used in vignettes and tests. Common entries:
tidy- already-tidy reference table.clusters_messy- multiple clusters with a distinct variable.messy_rows- rows that need filtering.group_sum- rows that need grouping/summing viasetGroups.- ... plus 20+ more covering the catalogue of layouts the package is designed to handle.
Use names(tabs2shift) to list them all.
- Setters are immutable. Each
set*returns a new schema. Chain via|>or%>%. Don't try to mutate in place. - Indices are 1-based and refer to the in-memory table. Row 1 is
the top row of whatever was read into R. Always read with
header = FALSEso row numbers are stable and consistent with the schema. columnsis positional. Schema indices are integers, not column names. Reading withheader = FALSEkeeps them stable.- Cluster-aware vectors. When you have N clusters,
columns = c(...)androws = c(...)must each be length N (or length 1 if shared). - NA handling is explicit. Strings to be interpreted as NA must
be listed in
setFormat(na_values = ...). There's no auto-detect. - Encoding: UTF-8. Source declares
Encoding: UTF-8in DESCRIPTION. Non-ASCII in roxygen is allowed; non-ASCII in R source (including comments) breaks CRAN checks. Use ASCII hyphens, not em-dashes.
- Public API (15 exports):
setIDVar,setObsVar,setFormat,setCluster,setFilter,setGroups,.find,.sum,reorganise,validateSchema,validateInput,getIDVars,getObsVars,getClusterVar,getGroupVar,schema_builder, plus the magrittr pipe re-export. - Internal:
schema_default,.sb_*(schema_builder scaffolding), schema validity helpers. Not user-facing. - S4 class
schema: defined inR/schema.R. User code rarely constructs one directly; setters are the way.
Imports: checkmate, rlang, tibble, dplyr, tidyr, magrittr,
tidyselect, testthat, crayon, methods, purrr, stringr, lubridate.
Suggests: knitr, rmarkdown, bookdown, readr, shiny, DT.
Shiny + DT are suggested because schema_builder is optional - if
you write schemas by hand you never need them.