Add the table node, row selection and linked tables, and ship the misc registers as tables #19
@@ -9,3 +9,20 @@ replacement, and each removed path, column or flag.
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- First release: the CLI, the library and the shipped data set.
|
- First release: the CLI, the library and the shipped data set.
|
||||||
|
- Table categories: a category JSON naming a `rows` TSV beside it, with the options
|
||||||
|
`key`, `name`, `weight` and `parent`; a path selects a row by key or name,
|
||||||
|
`misc.country[SE]`, and descends to a linked table by name; linked tables draw
|
||||||
|
consistently within one render and draw group. `rows` is an option, so no
|
||||||
|
template may carry a field of that name. Refused at `New`: a `name` without a
|
||||||
|
`key`, a name spelling another row's key, a table named like a column of any
|
||||||
|
table above it, a table whose format or cell references its own family, and,
|
||||||
|
within one render and draw group, a path drawing a table another path selects a
|
||||||
|
row of, or two paths pinning different rows of one table.
|
||||||
|
- `New` refuses a root choice of templates sharing one format and one set of string
|
||||||
|
fields, naming the rows TSV to write instead.
|
||||||
|
- `misc.country`, `misc.currency`, `misc.language`, `misc.httpstatus` and
|
||||||
|
`misc.mimetype` are tables. `misc.country` is every ISO 3166 country that has a
|
||||||
|
capital, a currency and a TLD, with the columns `calling-code`, `capital`,
|
||||||
|
`currency`, `flag`, `languages`, `numeric` and `tld` added; `misc.currency` the
|
||||||
|
current ISO 4217 currencies with a minor unit, with `decimals` and `numeric`
|
||||||
|
added, and its symbols from CLDR. `DATA-LICENSES.md` lists each source.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Data licenses
|
||||||
|
|
||||||
|
Every shipped dataset, its source, its licence and the attribution it asks for. A
|
||||||
|
`data-import/` script rebuilds each sourced table; a curated one is hand-written.
|
||||||
|
|
||||||
|
| Table | Source | Licence | Attribution | Rebuild |
|
||||||
|
|-------|--------|---------|-------------|---------|
|
||||||
|
| `misc/country.tsv` | [datasets/country-codes](https://github.com/datasets/country-codes) | [PDDL 1.0](https://opendatacommons.org/licenses/pddl/1-0/) | none required | `data-import/country.py` |
|
||||||
|
| `misc/currency.tsv` | [datasets/currency-codes](https://github.com/datasets/currency-codes); symbols from [Unicode CLDR](https://github.com/unicode-org/cldr) `en.xml` and `root.xml` | PDDL 1.0; [Unicode License v3](https://www.unicode.org/license.txt) | CLDR: "Copyright © 1991-2025 Unicode, Inc. Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the United States and other countries." | `data-import/currency.py` |
|
||||||
|
| `misc/httpstatus.tsv` | curated (IANA HTTP status codes are facts) | — | — | — |
|
||||||
|
| `misc/language.tsv` | curated (ISO 639-1 codes are facts) | — | — | — |
|
||||||
|
| `misc/mimetype.tsv` | curated (IANA media types are facts) | — | — | — |
|
||||||
|
|
||||||
|
Every other category is hand-written JSON under [`data/`](data), MIT like the code.
|
||||||
@@ -17,6 +17,7 @@ fejkdata sv_SE.person.last # Eriksson
|
|||||||
fejkdata --seed 42 sv_SE.address # the same address every run
|
fejkdata --seed 42 sv_SE.address # the same address every run
|
||||||
fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
|
fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
|
||||||
fejkdata --list # every path the data offers
|
fejkdata --list # every path the data offers
|
||||||
|
fejkdata 'misc.country[SE].capital' # Stockholm — a table's row, selected by key or name
|
||||||
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
|
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
|
||||||
fejkdata --no-shipped-data -d ./mydata --list # only your data
|
fejkdata --no-shipped-data -d ./mydata --list # only your data
|
||||||
fejkdata 'name: {/sv_SE.person.last}' # name: <a surname> — an inline template
|
fejkdata 'name: {/sv_SE.person.last}' # name: <a surname> — an inline template
|
||||||
@@ -24,7 +25,8 @@ fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse or name: l
|
|||||||
```
|
```
|
||||||
|
|
||||||
A path names a category, or a field inside one: each dot segment descends one
|
A path names a category, or a field inside one: each dot segment descends one
|
||||||
level — folders, then the category (a JSON file), then fields. An argument that is
|
level — folders, then the category (a JSON file), then fields — and `[SE]` after a
|
||||||
|
[table](#table) selects its row. An argument that is
|
||||||
a JSON object, array or string, or that carries a `{` token, is instead an
|
a JSON object, array or string, or that carries a `{` token, is instead an
|
||||||
**inline template**: a format string or a JSON value compiled and rendered on the
|
**inline template**: a format string or a JSON value compiled and rendered on the
|
||||||
spot. Its tokens reach the data by reference from the root —
|
spot. Its tokens reach the data by reference from the root —
|
||||||
@@ -32,8 +34,8 @@ spot. Its tokens reach the data by reference from the root —
|
|||||||
available. An inline template sits in no folder, so the folder-relative `{.name}`
|
available. An inline template sits in no folder, so the folder-relative `{.name}`
|
||||||
and `{..name}` are rejected naming the root spelling, and one reference alone —
|
and `{..name}` are rejected naming the root spelling, and one reference alone —
|
||||||
`{/sv_SE.person}` — is the path written as a template, rejected naming the path, as is
|
`{/sv_SE.person}` — is the path written as a template, rejected naming the path, as is
|
||||||
a path written `/sv_SE.person`. A path never contains a brace, a bracket or a quote,
|
a path written `/sv_SE.person`. A path never contains a brace or a quote, and a
|
||||||
so the two cannot collide (see [Decisions](#decisions)).
|
bracket only as a selector after a name, so the two cannot collide (see [Decisions](#decisions)).
|
||||||
|
|
||||||
| Flag | |
|
| Flag | |
|
||||||
|------|--|
|
|------|--|
|
||||||
@@ -220,7 +222,10 @@ Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
|
|||||||
`country` (ISO 3166), `creditcard` (Luhn-valid), `currency` (ISO 4217), `emoji`,
|
`country` (ISO 3166), `creditcard` (Luhn-valid), `currency` (ISO 4217), `emoji`,
|
||||||
`httpstatus`, `language` (ISO 639), `mac`, `mimetype`, `objectid`, `timezone`
|
`httpstatus`, `language` (ISO 639), `mac`, `mimetype`, `objectid`, `timezone`
|
||||||
(IANA), `useragent` and `uuid` (v4). Many carry sub-fields — `misc.currency.symbol`,
|
(IANA), `useragent` and `uuid` (v4). Many carry sub-fields — `misc.currency.symbol`,
|
||||||
`misc.country.alpha2`, `misc.httpstatus.code` — which `--list` shows.
|
`misc.country.alpha2`, `misc.httpstatus.code` — which `--list` shows. `country`,
|
||||||
|
`currency`, `httpstatus`, `language` and `mimetype` are [tables](#table), so
|
||||||
|
`misc.country[SE].capital` and `misc.currency[Euro].symbol` select a row;
|
||||||
|
[`DATA-LICENSES.md`](DATA-LICENSES.md) names each table's source and licence.
|
||||||
|
|
||||||
## Data format
|
## Data format
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ Every value is a **node**, nestable without limit:
|
|||||||
| string | `"Malmö"` | its text, with any `{…}` tokens expanded |
|
| string | `"Malmö"` | its text, with any `{…}` tokens expanded |
|
||||||
| choice | `["a", "b", …]` | one item, picked at random |
|
| choice | `["a", "b", …]` | one item, picked at random |
|
||||||
| template | `{"format": "…", …}` | its format, with `{name}` tokens rendering the named fields |
|
| template | `{"format": "…", …}` | its format, with `{name}` tokens rendering the named fields |
|
||||||
|
| table | `{"format": "…", "rows": "x.tsv", …}` | its format over one row of the TSV beside it ([Table](#table)) |
|
||||||
|
|
||||||
### Format string
|
### Format string
|
||||||
|
|
||||||
@@ -340,10 +346,114 @@ renders a null as `""`. The other items' weights skew its odds:
|
|||||||
load: `null` anywhere but a column, naming `""`, and a column whose items hold
|
load: `null` anywhere but a column, naming `""`, and a column whose items hold
|
||||||
different datatypes.
|
different datatypes.
|
||||||
|
|
||||||
|
### Table
|
||||||
|
|
||||||
|
A table is a category whose rows come from a TSV beside its JSON file: the header
|
||||||
|
names the columns, each line below it is one row, and the format renders the row
|
||||||
|
drawn. Save `mydata/country.tsv` and `mydata/country.json`:
|
||||||
|
|
||||||
|
```tsv
|
||||||
|
alpha2 name population
|
||||||
|
DK Denmark 5900000
|
||||||
|
NO Norway 5500000
|
||||||
|
SE Sweden 10500000
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "format": "{name} ({alpha2})", "rows": "country.tsv", "key": "alpha2", "name": "name", "weight": "population" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fejkdata -d ./mydata country # Sweden (SE), about half the time
|
||||||
|
fejkdata -d ./mydata country.alpha2 # NO
|
||||||
|
fejkdata -d ./mydata 'country[SE]' # Sweden (SE)
|
||||||
|
fejkdata -d ./mydata 'country[Norway].alpha2' # NO
|
||||||
|
fejkdata -d ./mydata --format csv country # alpha2,name,population → SE,Sweden,10500000
|
||||||
|
```
|
||||||
|
|
||||||
|
`rows` names the TSV beside the category file; `key` names the column a path
|
||||||
|
selects a row by, `name` a column it also selects by, `weight` a column of positive
|
||||||
|
numbers that skews the draw, and `parent` the table a column links to
|
||||||
|
([Linked tables](#linked-tables)). The format's `{tokens}` read the columns, and the
|
||||||
|
columns are the [record](#records)'s columns, so `--format csv` writes the rows and
|
||||||
|
`--list` shows `country.alpha2`. A cell is a string node: `1{digits(2)} {digits(2)}`
|
||||||
|
in a cell draws digits and `{/misc.uuid}` reads a reference, while `{name}` in a cell
|
||||||
|
is refused, since a cell has no sibling. Each cell may select its own row of another
|
||||||
|
table, `{/misc.currency[SEK].symbol}` on one row and `{/misc.currency[EUR].symbol}`
|
||||||
|
on the next, since only one row renders. `New` proves the header, the options and every
|
||||||
|
cell token, and refuses a TSV no category names, a key that is empty or repeats, a
|
||||||
|
weight that is not a positive number, and a key or name holding `[`, `]`, `{`, `}`,
|
||||||
|
`"` or `|`, which a selector cannot spell; the rows are indexed on the first draw that
|
||||||
|
selects one. A `name` needs a `key`, since a name naming several rows is reported by
|
||||||
|
their keys, and a name spelling another row's key is refused, since the key would
|
||||||
|
select first and the name never. The table's options are its own — `rows`, `key`,
|
||||||
|
`name`, `weight` and `parent` — so a column may be named `name`, as one usually is.
|
||||||
|
|
||||||
|
A choice of templates sharing one format and one set of string fields is a table
|
||||||
|
written by hand, and `New` refuses it in a data file naming the TSV to write; an
|
||||||
|
inline template has no file beside it, so there it stays a choice.
|
||||||
|
|
||||||
|
### Row selection
|
||||||
|
|
||||||
|
`[key]` or `[name]` after a table's name selects one row: `misc.country[SE]` and
|
||||||
|
`misc.country[Sweden]` name one row, and `misc.country[SE].capital` reads its column.
|
||||||
|
A name naming several rows is an error listing their keys, unless a row selected
|
||||||
|
before it settles which ([Linked tables](#linked-tables)). A selector is part of the path, so it works
|
||||||
|
wherever a path does: `Fake`, `FakeRecord`, a `{/misc.country[SE].capital}` reference
|
||||||
|
and a struct tag. A dot inside the brackets belongs to the key or name, so
|
||||||
|
`city[St. Louis]` selects it. A path starts with a name, and `[` still opens a JSON
|
||||||
|
array at the start of a CLI argument, so `'[SE]'` alone names nothing.
|
||||||
|
|
||||||
|
### Linked tables
|
||||||
|
|
||||||
|
A table's `parent` names a column and, by the same name, the table beside it that the
|
||||||
|
column links to by key. Save `mydata/city.tsv` and `mydata/city.json` beside the
|
||||||
|
`country` table above:
|
||||||
|
|
||||||
|
```tsv
|
||||||
|
name country population
|
||||||
|
Copenhagen DK 660000
|
||||||
|
Göteborg SE 600000
|
||||||
|
Oslo NO 710000
|
||||||
|
Stockholm SE 990000
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "format": "{name}", "rows": "city.tsv", "key": "name", "parent": "country", "weight": "population" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fejkdata -d ./mydata 'country[SE].city' # Stockholm or Göteborg
|
||||||
|
fejkdata -d ./mydata country.city.name # a country drawn, then a city inside it
|
||||||
|
fejkdata -d ./mydata 'city[Oslo].country' # NO — the link column's cell
|
||||||
|
fejkdata -d ./mydata '{/city.name}, {/country.name}' # Oslo, Norway — one consistent draw
|
||||||
|
```
|
||||||
|
|
||||||
|
A path descends from a row to a linked table by name, at any depth, and `--list`
|
||||||
|
advertises each direct step. Within one render and [draw group](#draw-group), linked
|
||||||
|
tables agree: the first table a reference path reads pins its ancestors, and a
|
||||||
|
descendant read after it is drawn inside them, so `{/city.name}` and
|
||||||
|
`{/country.name}` are a city and its country whichever is read first. A selected row
|
||||||
|
pins the render the same way, so every reference path into one family of linked
|
||||||
|
tables in one render and group selects the same rows: one that selects none beside
|
||||||
|
one that does is refused naming the spelling that does, `{/country[SE].city.name}`
|
||||||
|
beside `{/country[SE].name}`, and two selecting different rows are refused naming a
|
||||||
|
`drawGroup` to draw them apart in. A bare `{/city}` beside a path into its family is
|
||||||
|
refused too, since a bare reference draws each time, and so is a path that draws a
|
||||||
|
table another path in the group selects a row of, `{/city.name}` beside
|
||||||
|
`{/country[SE].name}`, which the first token rendered would otherwise decide. `New`
|
||||||
|
also refuses a link cell that is no key of the parent, a parent row no child links
|
||||||
|
to, a chain of parents that closes, a table named like a column of any table above
|
||||||
|
it, and a cell or format of a table that references a table of its own family, through
|
||||||
|
any template, a `repeat` or a `drawGroup` included, since a row rendered whole would
|
||||||
|
draw the family apart from itself: read the family from a template beside it, or add
|
||||||
|
the value as a column.
|
||||||
|
|
||||||
### Options and fields
|
### Options and fields
|
||||||
|
|
||||||
`format`, `weight`, `repeat`, `separator`, `datatype` and `drawGroup` are the only options;
|
`format`, `weight`, `repeat`, `separator`, `datatype` and `drawGroup` are the only options;
|
||||||
**any other key is a field** (see [Decisions](#decisions)). An object that does nothing a
|
**any other key is a field** (see [Decisions](#decisions)), and `rows` makes a category
|
||||||
|
a [table](#table), so no template carries a field of that name. An object that does nothing a
|
||||||
string can't — only a `format` — is rejected naming the string, as is a one-item
|
string can't — only a `format` — is rejected naming the string, as is a one-item
|
||||||
choice naming its item.
|
choice naming its item.
|
||||||
|
|
||||||
@@ -470,10 +580,11 @@ its groups by name; the unnamed group spans them all.
|
|||||||
Renders e.g. `Sara Eriksson pays Ebba Lind; signed Eriksson`: the signature reads the
|
Renders e.g. `Sara Eriksson pays Ebba Lind; signed Eriksson`: the signature reads the
|
||||||
payer's draw, while the payee is drawn apart. Rejected at load, each naming nothing: a
|
payer's draw, while the payee is drawn apart. Rejected at load, each naming nothing: a
|
||||||
`drawGroup` of `""` (the default); one naming the draw group its template already draws
|
`drawGroup` of `""` (the default); one naming the draw group its template already draws
|
||||||
in; one on a template that renders no reference path short of a `repeat` or a nested
|
in; one on a template that renders no reference path — a bare reference to a
|
||||||
`drawGroup`, on a `repeat` itself — each iteration renders in no draw group — or on an
|
[table](#table) counts, since the group answers for the family it draws in — short of a
|
||||||
inline template's root, which nothing references. So is a path reading into a level that
|
`repeat` or a nested `drawGroup`, on a `repeat` itself — each iteration renders in no
|
||||||
carries a `drawGroup`.
|
draw group — or on an inline template's root, which nothing references. So is a path
|
||||||
|
reading into a level that carries a `drawGroup`.
|
||||||
|
|
||||||
### Correlated fields
|
### Correlated fields
|
||||||
|
|
||||||
@@ -538,7 +649,7 @@ a minor only adds, and a major is the only release that changes what exists.
|
|||||||
|
|
||||||
| Surface | Major | Minor |
|
| Surface | Major | Minor |
|
||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| Shipped data | remove or rename a path; change a category's format; remove a value, or change a weight or a repeat; add a reference from one shipped category into another | a path outside a record's columns, a locale, a value in a list |
|
| Shipped data | remove or rename a path; change a category's format; remove a value, or change a weight or a repeat; add a reference from one shipped category into another; change a table's key, name, weight or parent column, or remove a row | a path outside a record's columns, a locale, a value in a list, a row |
|
||||||
| Records | remove, rename, retype or add a column; let a column be null | a record, as a new category |
|
| Records | remove, rename, retype or add a column; let a column be null | a record, as a new category |
|
||||||
| Data format | a fence: a spelling `New` rejects that it accepted; a template option, since it reserves a field name | a builtin |
|
| Data format | a fence: a spelling `New` rejects that it accepted; a template option, since it reserves a field name | a builtin |
|
||||||
| CLI | remove or rename a flag, or change its default; change what an exit code means; change the framing a `--format` writes (header, quoting, statement shape), the `--list` layout, or what an error names | a flag, a format |
|
| CLI | remove or rename a flag, or change its default; change what an exit code means; change the framing a `--format` writes (header, quoting, statement shape), the `--list` layout, or what an error names | a flag, a format |
|
||||||
@@ -559,8 +670,8 @@ with no breaking change. From `v2` the module path carries `/vN`, so fences ship
|
|||||||
batched into as few majors as possible.
|
batched into as few majors as possible.
|
||||||
|
|
||||||
[`testdata/shipped_shape.txt`](testdata/shipped_shape.txt) pins every path, each
|
[`testdata/shipped_shape.txt`](testdata/shipped_shape.txt) pins every path, each
|
||||||
template category's format, the categories each category reads, and each column's
|
template category's format, the categories each category reads, each column's
|
||||||
datatype and nullability; a pull request that
|
datatype and nullability, and each table's key, name, weight and parent columns; a pull request that
|
||||||
changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed,
|
changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed,
|
||||||
renamed or retyped line is a major.
|
renamed or retyped line is a major.
|
||||||
|
|
||||||
@@ -796,6 +907,86 @@ renamed or retyped line is a major.
|
|||||||
almost always costs an allocation too (a lost pre-size, a per-item map, an extra
|
almost always costs an allocation too (a lost pre-size, a per-item map, an extra
|
||||||
copy). The benchmark suite (see Development) reports time for a human, not as a
|
copy). The benchmark suite (see Development) reports time for a human, not as a
|
||||||
pass/fail gate.
|
pass/fail gate.
|
||||||
|
- **Rows live in a TSV, the shape in JSON.** `New` allocates once per node, so a
|
||||||
|
register of thirty thousand rows written as JSON objects would cost it a second;
|
||||||
|
a TSV is one allocation whose cells are substrings, and the JSON says only how a
|
||||||
|
row is composed. The TSV sits beside its category file, named by `rows`, so a
|
||||||
|
data directory stays a directory of categories, and one nothing names is a
|
||||||
|
load error rather than a file silently ignored.
|
||||||
|
- **A selector is bracketed, and a dot inside it is literal.** `municipality[0180]`
|
||||||
|
reads as selection to anyone who has indexed an array, and `[St. Louis]` keeps a
|
||||||
|
name whole where a colon or a dot-separated spelling could not; zsh needs the
|
||||||
|
brackets quoted, which the README's examples show. A name that spells another
|
||||||
|
row's key is refused at load rather than shadowed: a key names one row by
|
||||||
|
contract, so the name could never select its own, and the check is one lookup per
|
||||||
|
row against the key index that already exists.
|
||||||
|
- **`parent` names the link column and the table alike.** One word says both, so a
|
||||||
|
child table sits in its parent's folder and links on a column of the parent's
|
||||||
|
name; the geo plan wants exactly that, and a table needing another folder or
|
||||||
|
another column name would be asking for a second spelling.
|
||||||
|
- **After a row, a path names a column or a linked table.** `locality[Lund].address`,
|
||||||
|
a template beside the family under a selected row, is refused today; admitting it
|
||||||
|
later is additive, since a refused spelling gains a meaning and no accepted one
|
||||||
|
changes, so the door stays open for the address records the plan describes.
|
||||||
|
- **A table read into is pinned; a table rendered whole draws afresh.** A path into a
|
||||||
|
table pins its row for the render and group, as a reference path pins its level,
|
||||||
|
and a bare `{/city}` draws each time, as a bare reference does; so a bare table
|
||||||
|
beside a path into its family is refused like a bare reference beside a path into
|
||||||
|
it. A bare table reference still counts as a read for a `drawGroup`, since the group
|
||||||
|
is what draws it apart from the family's pins.
|
||||||
|
- **Every reference path into one family selects the same rows, per render and
|
||||||
|
group.** A selector pins rows for the render, and a read that draws freely before it
|
||||||
|
could pin a row the selector contradicts, so accepting both would make the result
|
||||||
|
depend on which token rendered first. Requiring one selection per family per group
|
||||||
|
is checkable at load with no data lookup beyond the selectors themselves, and the
|
||||||
|
error names the rewrite. Two selectors naming one row by key and by name compare
|
||||||
|
equal, since the load check resolves them.
|
||||||
|
- **A parent row with no child row is a load error.** A descendant is drawn inside the
|
||||||
|
nearest pinned ancestor, so every ancestor row must lead to a row at every level
|
||||||
|
below it, or a render could find nothing to draw. The import script drops or fills
|
||||||
|
such rows; the alternative, falling back to a free draw, would break the consistency
|
||||||
|
the link exists for without saying so.
|
||||||
|
- **The choice-of-rows fence guards a data file's root, and requires string fields.**
|
||||||
|
A table is a category with a TSV beside its file, so only a root choice has the
|
||||||
|
spelling the fence names; a nested choice of same-shaped templates and an inline
|
||||||
|
one keep loading. Fields must all be strings because a cell is a string node: a
|
||||||
|
choice whose items carry a nested choice, as `misc.car` does, is not one table but
|
||||||
|
two linked ones, which a later conversion writes.
|
||||||
|
- **A table is a record of string columns.** Its columns are the CSV header and the
|
||||||
|
`INSERT` column list, fixed by the TSV header, so a table is a record by
|
||||||
|
construction; every column is a string until a typed column option earns its place.
|
||||||
|
- **The key index is built at load, the rest on first draw.** A link is proved
|
||||||
|
against the parent's keys and a key's uniqueness is a data mistake, so both are
|
||||||
|
load-time; the name index and the per-parent child lists serve only a draw or a
|
||||||
|
selection, so they wait for the first one, keeping `New` linear in the bytes read.
|
||||||
|
- **Two categories may name one TSV.** Each is a view of the file with its own
|
||||||
|
format and options, at the cost of holding the rows twice, which is what a
|
||||||
|
category over a register with two natural formats asks for; a TSV nothing names
|
||||||
|
stays a load error, since that one is a file forgotten rather than shared.
|
||||||
|
- **The rows of a table are alternatives.** Only one row renders, so a cell in one
|
||||||
|
row and a cell in another never meet, and each may select its own row of another
|
||||||
|
table; the cells of one row, and whatever they reach, do meet, and so does the
|
||||||
|
format beside them. The rule the fence applies is that two reads meet unless
|
||||||
|
they sit in two rows of one table, or in a row outside a selected ancestor's. A
|
||||||
|
choice's items get no such treatment yet: two items selecting different rows
|
||||||
|
are still refused.
|
||||||
|
- **A table never reaches its own family, by any route.** A `repeat` iteration and a
|
||||||
|
`drawGroup` each draw apart on purpose, but a row that lists three localities from
|
||||||
|
other regions is the output the family exists to prevent, so the own-family fence
|
||||||
|
walks through both rather than stopping where the draw fences do.
|
||||||
|
- **Tables carrying token cells stay small.** The family fence compares the reads of
|
||||||
|
every pair of rows that can render together, so a table whose every row's cell
|
||||||
|
selects a row of another table loads in time quadratic in its rows: about a
|
||||||
|
second at four thousand rows. No shipped table carries such cells, and a register
|
||||||
|
is a column set rather than a set of references, so the fence is left as it is
|
||||||
|
until a real data set needs the indexed form.
|
||||||
|
- **A path is walked once without drawing before it is walked for real.** A path
|
||||||
|
that fails below its first level then moves no seeded stream, at the cost of one
|
||||||
|
draw-free walk per call, which allocates nothing.
|
||||||
|
- **`List` advertises direct descents only.** `region.municipality.locality` is
|
||||||
|
listed, and `region.locality` resolves too but is not: the set of every descent
|
||||||
|
through a chain of five tables is every subsequence of it, and the direct chain is
|
||||||
|
the one a reader can predict from the tables' parents.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -839,6 +1030,15 @@ in its own commit:
|
|||||||
REPIN=1 docker compose run --rm --user "$(id -u):$(id -g)" test
|
REPIN=1 docker compose run --rm --user "$(id -u):$(id -g)" test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A shipped table built from a source is rebuilt by its script under
|
||||||
|
[`data-import/`](data-import), one command per dataset, fetching the source named in
|
||||||
|
[`DATA-LICENSES.md`](DATA-LICENSES.md):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm --user "$(id -u):$(id -g)" data-import data-import/country.py
|
||||||
|
docker compose run --rm --user "$(id -u):$(id -g)" data-import data-import/currency.py
|
||||||
|
```
|
||||||
|
|
||||||
To release, head `CHANGELOG.md` with the version's section in place of `Unreleased`
|
To release, head `CHANGELOG.md` with the version's section in place of `Unreleased`
|
||||||
and merge: once `main` passes the gate, CI tags that commit `vX.Y.Z` and publishes
|
and merge: once `main` passes the gate, CI tags that commit `vX.Y.Z` and publishes
|
||||||
the Gitea release with the section as its body. A top heading of `[Unreleased]`
|
the Gitea release with the section as its body. A top heading of `[Unreleased]`
|
||||||
@@ -849,7 +1049,8 @@ publishes nothing.
|
|||||||
```
|
```
|
||||||
fejkdata.go Generator, New, options, the embedded data set, List
|
fejkdata.go Generator, New, options, the embedded data set, List
|
||||||
node.go the node model and JSON -> node compilation
|
node.go the node model and JSON -> node compilation
|
||||||
path.go the dotted-path walk, and proving a path resolves
|
table.go tables: the rows TSV, its options and links, row selection and draws
|
||||||
|
path.go the dotted-path walk with its selectors, and proving a path resolves
|
||||||
render.go Fake and the recursive renderer (choices, format strings, expansions)
|
render.go Fake and the recursive renderer (choices, format strings, expansions)
|
||||||
record.go records: Record, the JSON/CSV/SQL serializers, and their entry points
|
record.go records: Record, the JSON/CSV/SQL serializers, and their entry points
|
||||||
struct.go structs: FakeStruct, fake tags, and a field's Go type as its column's datatype
|
struct.go structs: FakeStruct, fake tags, and a field's Go type as its column's datatype
|
||||||
@@ -857,6 +1058,7 @@ inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplat
|
|||||||
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
|
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
|
||||||
hold.go the hold: one draw per expansion for paths and operands, and its fences
|
hold.go the hold: one draw per expansion for paths and operands, and its fences
|
||||||
draw.go one reference draw per render and group: draw sets, the group option, and its fence
|
draw.go one reference draw per render and group: draw sets, the group option, and its fence
|
||||||
|
family.go a family of linked tables: the rows a render pins, and the fence over paths into one family
|
||||||
reference.go reference sigils, and binding references across the tree
|
reference.go reference sigils, and binding references across the tree
|
||||||
graph.go the render graph: edges, cycles, the repeat bound, tree walks
|
graph.go the render graph: edges, cycles, the repeat bound, tree walks
|
||||||
builtins.go the {name()} function registry and its implementations
|
builtins.go the {name()} function registry and its implementations
|
||||||
@@ -865,7 +1067,8 @@ datatype.go column datatypes: DataType, where datatype and null may sit, a c
|
|||||||
value.go the value proof: what a typed column or calc operand holds, checked at load
|
value.go the value proof: what a typed column or calc operand holds, checked at load
|
||||||
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
||||||
cmd/fejkdata/ the fejkdata CLI
|
cmd/fejkdata/ the fejkdata CLI
|
||||||
data/ shipped data (JSON), embedded at build: locale folders + a misc folder
|
data/ shipped data (JSON, and a TSV per table), embedded at build: locale folders + a misc folder
|
||||||
|
data-import/ the scripts that rebuild each sourced table (see DATA-LICENSES.md)
|
||||||
release-tooling/ the release CI publishes from the changelog heading
|
release-tooling/ the release CI publishes from the changelog heading
|
||||||
testdata/ the pinned shipped shape (see Versioning)
|
testdata/ the pinned shipped shape (see Versioning)
|
||||||
```
|
```
|
||||||
|
|||||||
+19
-7
@@ -23,16 +23,17 @@ import (
|
|||||||
|
|
||||||
const usage = `Usage: fejkdata [flags] <path|template>
|
const usage = `Usage: fejkdata [flags] <path|template>
|
||||||
|
|
||||||
<path> a category, or a dotted path into one (person, person.last)
|
<path> a category, or a dotted path into one (person, person.last);
|
||||||
|
a table's row by key or name: 'misc.country[SE]', 'misc.country[Sweden].tld'
|
||||||
<template> a format string or JSON value to render inline, e.g.
|
<template> a format string or JSON value to render inline, e.g.
|
||||||
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
|
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
|
||||||
|
|
||||||
An argument containing a { token, or a JSON object, array or string, is a
|
An argument containing a { token, or a JSON object, array or string, is a
|
||||||
template; any other argument is a path (a path never contains a brace, a bracket
|
template; any other argument is a path (a path never contains a brace or a quote,
|
||||||
or a quote). Templates reach the data by reference from the root —
|
and a bracket only as a [selector] after a table's name). Templates reach the
|
||||||
{/sv_SE.person.last} — whether the data is shipped or layered with --data-path. An
|
data by reference from the root — {/sv_SE.person.last} — whether the data is
|
||||||
argument carrying a bracket, a closing brace or a quote but no valid JSON names
|
shipped or layered with --data-path. An argument carrying a closing brace or a
|
||||||
neither.
|
quote but no valid JSON names neither.
|
||||||
|
|
||||||
With --format json, ndjson, csv or sql the argument must name a record — a
|
With --format json, ndjson, csv or sql the argument must name a record — a
|
||||||
template whose fields are its columns — and the rows are written as one JSON
|
template whose fields are its columns — and the rows are written as one JSON
|
||||||
@@ -396,7 +397,18 @@ func defaultTable(arg string, kind argKind) string {
|
|||||||
if kind == argTemplate {
|
if kind == argTemplate {
|
||||||
return "records"
|
return "records"
|
||||||
}
|
}
|
||||||
segments := strings.Split(arg, ".")
|
var names strings.Builder // the path with its [selectors] cut out
|
||||||
|
for depth, i := 0, 0; i < len(arg); i++ {
|
||||||
|
switch {
|
||||||
|
case arg[i] == '[':
|
||||||
|
depth++
|
||||||
|
case arg[i] == ']':
|
||||||
|
depth--
|
||||||
|
case depth == 0:
|
||||||
|
names.WriteByte(arg[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments := strings.Split(names.String(), ".")
|
||||||
return segments[len(segments)-1]
|
return segments[len(segments)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,41 @@ func TestClassify(t *testing.T) {
|
|||||||
t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want)
|
t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := classify("[abc]"); err == nil || !strings.Contains(err.Error(), `holds a "["`) {
|
if _, err := classify("[abc]"); err == nil || !strings.Contains(err.Error(), `"["`) || !strings.Contains(err.Error(), "JSON") {
|
||||||
t.Errorf("classify([abc]) = %v; want it rejected naming the bracket", err)
|
t.Errorf("classify([abc]) = %v; want it rejected naming the leading bracket", err)
|
||||||
|
}
|
||||||
|
if got, err := classify("geo.SE.municipality[St. Louis].name"); err != nil || got != argPath {
|
||||||
|
t.Errorf("classify(a selecting path) = %v, %v; want a path", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSelectsATableRow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"region.json": `{"format":"{name}","rows":"region.tsv","key":"code","name":"name"}`,
|
||||||
|
"region.tsv": "code\tname\n01\tStockholms län\n12\tSkåne län\n",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code, out, errb := runOut("--no-shipped-data", "-d", dir, "region[12]"); code != 0 || out != "Skåne län\n" {
|
||||||
|
t.Fatalf("region[12] = %d, %q, stderr=%q", code, out, errb)
|
||||||
|
}
|
||||||
|
if code, out, _ := runOut("--no-shipped-data", "-d", dir, "region[Skåne län].code"); code != 0 || out != "12\n" {
|
||||||
|
t.Fatalf("region[Skåne län].code = %d, %q", code, out)
|
||||||
|
}
|
||||||
|
if code, out, _ := runOut("--no-shipped-data", "-d", dir, "--format", "sql", "region[12]"); code != 0 || out != `INSERT INTO "region" ("code", "name") VALUES ('12', 'Skåne län');`+"\n" {
|
||||||
|
t.Fatalf("--format sql region[12] = %d, %q, want the table named without its selector", code, out)
|
||||||
|
}
|
||||||
|
if code, _, errb := runOut("--no-shipped-data", "-d", dir, "region[99]"); code != 1 || !strings.Contains(errb, `"99"`) {
|
||||||
|
t.Fatalf("region[99] = %d, stderr=%q, want a runtime error naming the row", code, errb)
|
||||||
|
}
|
||||||
|
if code, _, errb := runOut("--no-shipped-data", "-d", dir, "[Skåne län]"); code != 2 || !strings.Contains(errb, "JSON") {
|
||||||
|
t.Fatalf("[Skåne län] = %d, stderr=%q, want misuse: a leading bracket that is no JSON names nothing", code, errb)
|
||||||
|
}
|
||||||
|
if code, out, _ := runOut("--seed", "1", "--format", "csv", "misc.country[SE]"); code != 0 || !strings.HasPrefix(out, "alpha2,") || !strings.Contains(out, "\nSE,SWE,") {
|
||||||
|
t.Fatalf("--format csv misc.country[SE] = %d, %q", code, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,11 +387,11 @@ func TestRunInlineTemplate(t *testing.T) {
|
|||||||
func TestRunTemplateMisuse(t *testing.T) {
|
func TestRunTemplateMisuse(t *testing.T) {
|
||||||
for arg, want := range map[string]string{
|
for arg, want := range map[string]string{
|
||||||
"{bad": "unterminated",
|
"{bad": "unterminated",
|
||||||
"[red,green]": "names no template either",
|
"[red,green]": `starts with "["`,
|
||||||
`{"format":"x"}`: "is a string",
|
`{"format":"x"}`: "is a string",
|
||||||
"name: {/no.such.path}": "no entry",
|
"name: {/no.such.path}": "no entry",
|
||||||
"{/sv_SE.person}": "write sv_SE.person",
|
"{/sv_SE.person}": "write sv_SE.person",
|
||||||
"x[1]": "names no template either",
|
"x[1]y": `"]"`,
|
||||||
` ["a","b"] `: "may not be padded",
|
` ["a","b"] `: "may not be padded",
|
||||||
} {
|
} {
|
||||||
code, out, errb := runOut("--seed", "1", arg)
|
code, out, errb := runOut("--seed", "1", arg)
|
||||||
|
|||||||
@@ -64,5 +64,12 @@ services:
|
|||||||
<<: *go
|
<<: *go
|
||||||
command: sh
|
command: sh
|
||||||
|
|
||||||
|
data-import:
|
||||||
|
image: python:3.14.7-slim
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
entrypoint: python3
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
gocache:
|
gocache:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rebuild data/misc/country.tsv from datasets/country-codes (PDDL).
|
||||||
|
|
||||||
|
data-import/country.py [--source URL_OR_FILE] [--out FILE]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOURCE = "https://raw.githubusercontent.com/datasets/country-codes/main/data/country-codes.csv"
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "data" / "misc" / "country.tsv"
|
||||||
|
COLUMNS = ["alpha2", "alpha3", "calling-code", "capital", "currency", "flag", "languages", "name", "numeric", "tld"]
|
||||||
|
# Gaps in the source, keyed by alpha2.
|
||||||
|
FIXUPS = {"TR": {"currency": "TRY"}}
|
||||||
|
|
||||||
|
|
||||||
|
def read(source):
|
||||||
|
if re.match(r"^https?://", source):
|
||||||
|
with urllib.request.urlopen(source, timeout=60) as r:
|
||||||
|
return r.read().decode("utf-8")
|
||||||
|
return Path(source).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def flag(alpha2):
|
||||||
|
return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in alpha2)
|
||||||
|
|
||||||
|
|
||||||
|
def calling_code(dial):
|
||||||
|
first = re.sub(r"[^0-9-]", "", dial.split(",")[0])
|
||||||
|
return first if re.match(r"^\d+-\d{3}$", first) else first.split("-")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def languages(field):
|
||||||
|
return ",".join(p for p in field.split(",") if p)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(text):
|
||||||
|
for r in csv.DictReader(io.StringIO(text)):
|
||||||
|
alpha2 = r["ISO3166-1-Alpha-2"]
|
||||||
|
row = {
|
||||||
|
"alpha2": alpha2,
|
||||||
|
"alpha3": r["ISO3166-1-Alpha-3"],
|
||||||
|
"calling-code": calling_code(r["Dial"]),
|
||||||
|
"capital": r["Capital"],
|
||||||
|
"currency": r["ISO4217-currency_alphabetic_code"].split(",")[0],
|
||||||
|
"flag": flag(alpha2),
|
||||||
|
"languages": languages(r["Languages"]),
|
||||||
|
"name": r["CLDR display name"] or r["official_name_en"],
|
||||||
|
"numeric": r["ISO3166-1-numeric"].zfill(3),
|
||||||
|
"tld": r["TLD"],
|
||||||
|
}
|
||||||
|
row.update(FIXUPS.get(alpha2, {}))
|
||||||
|
if all(row[c] for c in ("alpha2", "alpha3", "calling-code", "capital", "currency", "name", "tld")):
|
||||||
|
yield row
|
||||||
|
|
||||||
|
|
||||||
|
def write(out, table):
|
||||||
|
lines = ["\t".join(COLUMNS)]
|
||||||
|
for row in sorted(table, key=lambda r: r["alpha2"]):
|
||||||
|
cells = [row[c] for c in COLUMNS]
|
||||||
|
assert not any("\t" in c or "\n" in c for c in cells), row
|
||||||
|
lines.append("\t".join(cells))
|
||||||
|
Path(out).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
return len(lines) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
p.add_argument("--source", default=SOURCE)
|
||||||
|
p.add_argument("--out", default=str(OUT))
|
||||||
|
a = p.parse_args()
|
||||||
|
n = write(a.out, rows(read(a.source)))
|
||||||
|
print(f"{a.out}: {n} rows", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rebuild data/misc/currency.tsv from datasets/currency-codes (PDDL) and CLDR's symbols (Unicode).
|
||||||
|
|
||||||
|
data-import/currency.py [--source URL_OR_FILE] [--symbols URL_OR_FILE ...] [--out FILE]
|
||||||
|
|
||||||
|
The symbols come from the first locale file that has one, narrow symbols before wide.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOURCE = "https://raw.githubusercontent.com/datasets/currency-codes/main/data/codes-all.csv"
|
||||||
|
SYMBOLS = [
|
||||||
|
"https://raw.githubusercontent.com/unicode-org/cldr/main/common/main/en.xml",
|
||||||
|
"https://raw.githubusercontent.com/unicode-org/cldr/main/common/main/root.xml",
|
||||||
|
]
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "data" / "misc" / "currency.tsv"
|
||||||
|
COLUMNS = ["code", "decimals", "name", "numeric", "symbol"]
|
||||||
|
|
||||||
|
|
||||||
|
def read(source):
|
||||||
|
if re.match(r"^https?://", source):
|
||||||
|
with urllib.request.urlopen(source, timeout=60) as r:
|
||||||
|
return r.read().decode("utf-8")
|
||||||
|
return Path(source).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def symbols(xml_texts):
|
||||||
|
"""CLDR's symbol per code: the first locale's narrow symbol, else the first locale's wide one."""
|
||||||
|
narrow, wide = {}, {}
|
||||||
|
for text in xml_texts:
|
||||||
|
for c in ET.fromstring(text).iter("currency"):
|
||||||
|
for s in c.findall("symbol"):
|
||||||
|
into = narrow if s.get("alt") == "narrow" else wide if s.get("alt") is None else None
|
||||||
|
if into is not None:
|
||||||
|
into.setdefault(c.get("type"), s.text)
|
||||||
|
return {**wide, **narrow}
|
||||||
|
|
||||||
|
|
||||||
|
def rows(text, symbol):
|
||||||
|
seen = set()
|
||||||
|
for r in csv.DictReader(io.StringIO(text)):
|
||||||
|
code = r["AlphabeticCode"]
|
||||||
|
if not code or code in seen or r["WithdrawalDate"] or r["Entity"].startswith("ZZ") or not r["MinorUnit"].isdigit():
|
||||||
|
continue
|
||||||
|
seen.add(code)
|
||||||
|
yield {
|
||||||
|
"code": code,
|
||||||
|
"decimals": r["MinorUnit"],
|
||||||
|
"name": r["Currency"],
|
||||||
|
"numeric": r["NumericCode"].zfill(3),
|
||||||
|
"symbol": symbol.get(code, code),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write(out, table):
|
||||||
|
lines = ["\t".join(COLUMNS)]
|
||||||
|
for row in sorted(table, key=lambda r: r["code"]):
|
||||||
|
cells = [row[c] for c in COLUMNS]
|
||||||
|
assert all(cells) and not any("\t" in c or "\n" in c for c in cells), row
|
||||||
|
lines.append("\t".join(cells))
|
||||||
|
Path(out).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
return len(lines) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
p.add_argument("--source", default=SOURCE)
|
||||||
|
p.add_argument("--symbols", nargs="+", default=SYMBOLS)
|
||||||
|
p.add_argument("--out", default=str(OUT))
|
||||||
|
a = p.parse_args()
|
||||||
|
n = write(a.out, rows(read(a.source), symbols(read(s) for s in a.symbols)))
|
||||||
|
print(f"{a.out}: {n} rows", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -62,6 +62,9 @@ func loadData(sources []dataSource) (map[string]node, error) {
|
|||||||
if len(root) == 0 {
|
if len(root) == 0 {
|
||||||
return nil, fmt.Errorf("no .json data found")
|
return nil, fmt.Errorf("no .json data found")
|
||||||
}
|
}
|
||||||
|
if err := linkTables(root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := linkRefs(root); err != nil {
|
if err := linkRefs(root); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -83,22 +86,56 @@ func loadDir(src dataSource, dir string) (*folder, error) {
|
|||||||
return nil, fmt.Errorf("%s: %w", src.name(dir), err)
|
return nil, fmt.Errorf("%s: %w", src.name(dir), err)
|
||||||
}
|
}
|
||||||
g := &folder{children: map[string]node{}}
|
g := &folder{children: map[string]node{}}
|
||||||
|
files := &categoryFiles{src: src, dir: dir, tsv: map[string]bool{}}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".tsv") && !e.IsDir() {
|
||||||
|
files.tsv[e.Name()] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data
|
if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
full := path.Join(dir, e.Name())
|
full := path.Join(dir, e.Name())
|
||||||
load := loadFile
|
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
load = loadFolder
|
if err := loadFolder(src, g, full, e.Name()); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := load(src, g, full, e.Name()); err != nil {
|
continue
|
||||||
|
}
|
||||||
|
if err := loadFile(src, g, full, e.Name(), files); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, named := range files.tsv {
|
||||||
|
if !named && !strings.HasPrefix(name, ".") {
|
||||||
|
return nil, fmt.Errorf("%s: no category names it in its rows; a table's rows file sits beside a category file naming it", src.name(path.Join(dir, name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// categoryFiles is what a category may name beside itself: the rows files of its
|
||||||
|
// directory, each marked once a category names it.
|
||||||
|
type categoryFiles struct {
|
||||||
|
src dataSource
|
||||||
|
dir string
|
||||||
|
tsv map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// rows reads the rows file a category names beside it.
|
||||||
|
func (c *categoryFiles) rows(name string) (string, error) {
|
||||||
|
if _, present := c.tsv[name]; !present {
|
||||||
|
return "", fmt.Errorf("rows names %s, which is not beside it in %s", name, c.src.name(c.dir))
|
||||||
|
}
|
||||||
|
c.tsv[name] = true
|
||||||
|
b, err := fs.ReadFile(c.src.fsys, path.Join(c.dir, name))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: %w", c.src.name(path.Join(c.dir, name)), err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadFolder adds a subdirectory as a nested folder, unless nothing under it is data.
|
// loadFolder adds a subdirectory as a nested folder, unless nothing under it is data.
|
||||||
func loadFolder(src dataSource, g *folder, full, name string) error {
|
func loadFolder(src dataSource, g *folder, full, name string) error {
|
||||||
child, err := loadDir(src, full)
|
child, err := loadDir(src, full)
|
||||||
@@ -117,7 +154,7 @@ func loadFolder(src dataSource, g *folder, full, name string) error {
|
|||||||
|
|
||||||
// loadFile compiles a *.json file into a category named after it; any other file
|
// loadFile compiles a *.json file into a category named after it; any other file
|
||||||
// is skipped.
|
// is skipped.
|
||||||
func loadFile(src dataSource, g *folder, full, file string) error {
|
func loadFile(src dataSource, g *folder, full, file string, files *categoryFiles) error {
|
||||||
if !strings.HasSuffix(file, ".json") {
|
if !strings.HasSuffix(file, ".json") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -133,7 +170,7 @@ func loadFile(src dataSource, g *folder, full, file string) error {
|
|||||||
if err := json.Unmarshal(b, &raw); err != nil {
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
return fmt.Errorf("%s: %w", src.name(full), err)
|
return fmt.Errorf("%s: %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
n, err := compile(raw)
|
n, err := compileCategory(raw, name, files)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", src.name(full), err)
|
return fmt.Errorf("%s: %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-22
@@ -1,22 +1 @@
|
|||||||
[
|
{ "format": "{name}", "rows": "country.tsv", "key": "alpha2", "name": "name" }
|
||||||
{ "format": "{name}", "name": "Australia", "alpha2": "AU", "alpha3": "AUS" },
|
|
||||||
{ "format": "{name}", "name": "Brazil", "alpha2": "BR", "alpha3": "BRA" },
|
|
||||||
{ "format": "{name}", "name": "Canada", "alpha2": "CA", "alpha3": "CAN" },
|
|
||||||
{ "format": "{name}", "name": "China", "alpha2": "CN", "alpha3": "CHN" },
|
|
||||||
{ "format": "{name}", "name": "Denmark", "alpha2": "DK", "alpha3": "DNK" },
|
|
||||||
{ "format": "{name}", "name": "Finland", "alpha2": "FI", "alpha3": "FIN" },
|
|
||||||
{ "format": "{name}", "name": "France", "alpha2": "FR", "alpha3": "FRA" },
|
|
||||||
{ "format": "{name}", "name": "Germany", "alpha2": "DE", "alpha3": "DEU" },
|
|
||||||
{ "format": "{name}", "name": "India", "alpha2": "IN", "alpha3": "IND" },
|
|
||||||
{ "format": "{name}", "name": "Italy", "alpha2": "IT", "alpha3": "ITA" },
|
|
||||||
{ "format": "{name}", "name": "Japan", "alpha2": "JP", "alpha3": "JPN" },
|
|
||||||
{ "format": "{name}", "name": "Mexico", "alpha2": "MX", "alpha3": "MEX" },
|
|
||||||
{ "format": "{name}", "name": "Netherlands", "alpha2": "NL", "alpha3": "NLD" },
|
|
||||||
{ "format": "{name}", "name": "Norway", "alpha2": "NO", "alpha3": "NOR" },
|
|
||||||
{ "format": "{name}", "name": "Poland", "alpha2": "PL", "alpha3": "POL" },
|
|
||||||
{ "format": "{name}", "name": "Spain", "alpha2": "ES", "alpha3": "ESP" },
|
|
||||||
{ "format": "{name}", "name": "Sweden", "alpha2": "SE", "alpha3": "SWE" },
|
|
||||||
{ "format": "{name}", "name": "Switzerland", "alpha2": "CH", "alpha3": "CHE" },
|
|
||||||
{ "format": "{name}", "name": "United Kingdom", "alpha2": "GB", "alpha3": "GBR" },
|
|
||||||
{ "format": "{name}", "name": "United States", "alpha2": "US", "alpha3": "USA" }
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
alpha2 alpha3 calling-code capital currency flag languages name numeric tld
|
||||||
|
AD AND 376 Andorra la Vella EUR 🇦🇩 ca Andorra 020 .ad
|
||||||
|
AE ARE 971 Abu Dhabi AED 🇦🇪 ar-AE,fa,en,hi,ur United Arab Emirates 784 .ae
|
||||||
|
AF AFG 93 Kabul AFN 🇦🇫 fa-AF,ps,uz-AF,tk Afghanistan 004 .af
|
||||||
|
AG ATG 1-268 St. John's XCD 🇦🇬 en-AG Antigua & Barbuda 028 .ag
|
||||||
|
AI AIA 1-264 The Valley XCD 🇦🇮 en-AI Anguilla 660 .ai
|
||||||
|
AL ALB 355 Tirana ALL 🇦🇱 sq,el Albania 008 .al
|
||||||
|
AM ARM 374 Yerevan AMD 🇦🇲 hy Armenia 051 .am
|
||||||
|
AO AGO 244 Luanda AOA 🇦🇴 pt-AO Angola 024 .ao
|
||||||
|
AR ARG 54 Buenos Aires ARS 🇦🇷 es-AR,en,it,de,fr,gn Argentina 032 .ar
|
||||||
|
AS ASM 1-684 Pago Pago USD 🇦🇸 en-AS,sm,to American Samoa 016 .as
|
||||||
|
AT AUT 43 Vienna EUR 🇦🇹 de-AT,hr,hu,sl Austria 040 .at
|
||||||
|
AU AUS 61 Canberra AUD 🇦🇺 en-AU Australia 036 .au
|
||||||
|
AW ABW 297 Oranjestad AWG 🇦🇼 nl-AW,pap,es,en Aruba 533 .aw
|
||||||
|
AX ALA 358 Mariehamn EUR 🇦🇽 sv-AX Åland Islands 248 .ax
|
||||||
|
AZ AZE 994 Baku AZN 🇦🇿 az,ru,hy Azerbaijan 031 .az
|
||||||
|
BA BIH 387 Sarajevo BAM 🇧🇦 bs,hr-BA,sr-BA Bosnia 070 .ba
|
||||||
|
BB BRB 1-246 Bridgetown BBD 🇧🇧 en-BB Barbados 052 .bb
|
||||||
|
BD BGD 880 Dhaka BDT 🇧🇩 bn-BD,en Bangladesh 050 .bd
|
||||||
|
BE BEL 32 Brussels EUR 🇧🇪 nl-BE,fr-BE,de-BE Belgium 056 .be
|
||||||
|
BF BFA 226 Ouagadougou XOF 🇧🇫 fr-BF,mos Burkina Faso 854 .bf
|
||||||
|
BG BGR 359 Sofia EUR 🇧🇬 bg,tr-BG,rom Bulgaria 100 .bg
|
||||||
|
BH BHR 973 Manama BHD 🇧🇭 ar-BH,en,fa,ur Bahrain 048 .bh
|
||||||
|
BI BDI 257 Gitega BIF 🇧🇮 fr-BI,rn Burundi 108 .bi
|
||||||
|
BJ BEN 229 Porto-Novo XOF 🇧🇯 fr-BJ Benin 204 .bj
|
||||||
|
BL BLM 590 Gustavia EUR 🇧🇱 fr St. Barthélemy 652 .gp
|
||||||
|
BM BMU 1-441 Hamilton BMD 🇧🇲 en-BM,pt Bermuda 060 .bm
|
||||||
|
BN BRN 673 Bandar Seri Begawan BND 🇧🇳 ms-BN,en-BN Brunei 096 .bn
|
||||||
|
BO BOL 591 Sucre BOB 🇧🇴 es-BO,qu,ay Bolivia 068 .bo
|
||||||
|
BR BRA 55 Brasilia BRL 🇧🇷 pt-BR,es,en,fr Brazil 076 .br
|
||||||
|
BS BHS 1-242 Nassau BSD 🇧🇸 en-BS Bahamas 044 .bs
|
||||||
|
BT BTN 975 Thimphu INR 🇧🇹 dz Bhutan 064 .bt
|
||||||
|
BW BWA 267 Gaborone BWP 🇧🇼 en-BW,tn-BW Botswana 072 .bw
|
||||||
|
BY BLR 375 Minsk BYN 🇧🇾 be,ru Belarus 112 .by
|
||||||
|
BZ BLZ 501 Belmopan BZD 🇧🇿 en-BZ,es Belize 084 .bz
|
||||||
|
CA CAN 1 Ottawa CAD 🇨🇦 en-CA,fr-CA,iu Canada 124 .ca
|
||||||
|
CC CCK 61 West Island AUD 🇨🇨 ms-CC,en Cocos Islands 166 .cc
|
||||||
|
CD COD 243 Kinshasa CDF 🇨🇩 fr-CD,ln,ktu,kg,sw,lua Congo - Kinshasa 180 .cd
|
||||||
|
CF CAF 236 Bangui XAF 🇨🇫 fr-CF,sg,ln,kg Central African Republic 140 .cf
|
||||||
|
CG COG 242 Brazzaville XAF 🇨🇬 fr-CG,kg,ln-CG Congo - Brazzaville 178 .cg
|
||||||
|
CH CHE 41 Bern CHF 🇨🇭 de-CH,fr-CH,it-CH,rm Switzerland 756 .ch
|
||||||
|
CI CIV 225 Yamoussoukro XOF 🇨🇮 fr-CI Côte d’Ivoire 384 .ci
|
||||||
|
CK COK 682 Avarua NZD 🇨🇰 en-CK,mi Cook Islands 184 .ck
|
||||||
|
CL CHL 56 Santiago CLP 🇨🇱 es-CL Chile 152 .cl
|
||||||
|
CM CMR 237 Yaounde XAF 🇨🇲 en-CM,fr-CM Cameroon 120 .cm
|
||||||
|
CN CHN 86 Beijing CNY 🇨🇳 zh-CN,yue,wuu,dta,ug,za China 156 .cn
|
||||||
|
CO COL 57 Bogota COP 🇨🇴 es-CO Colombia 170 .co
|
||||||
|
CR CRI 506 San Jose CRC 🇨🇷 es-CR,en Costa Rica 188 .cr
|
||||||
|
CU CUB 53 Havana CUP 🇨🇺 es-CU,pap Cuba 192 .cu
|
||||||
|
CV CPV 238 Praia CVE 🇨🇻 pt-CV Cape Verde 132 .cv
|
||||||
|
CW CUW 599 Willemstad XCG 🇨🇼 nl,pap Curaçao 531 .cw
|
||||||
|
CX CXR 61 Flying Fish Cove AUD 🇨🇽 en,zh,ms-CX Christmas Island 162 .cx
|
||||||
|
CY CYP 357 Nicosia EUR 🇨🇾 el-CY,tr-CY,en Cyprus 196 .cy
|
||||||
|
CZ CZE 420 Prague CZK 🇨🇿 cs,sk Czechia 203 .cz
|
||||||
|
DE DEU 49 Berlin EUR 🇩🇪 de Germany 276 .de
|
||||||
|
DJ DJI 253 Djibouti DJF 🇩🇯 fr-DJ,ar,so-DJ,aa Djibouti 262 .dj
|
||||||
|
DK DNK 45 Copenhagen DKK 🇩🇰 da-DK,en,fo,de-DK Denmark 208 .dk
|
||||||
|
DM DMA 1-767 Roseau XCD 🇩🇲 en-DM Dominica 212 .dm
|
||||||
|
DO DOM 1-809 Santo Domingo DOP 🇩🇴 es-DO Dominican Republic 214 .do
|
||||||
|
DZ DZA 213 Algiers DZD 🇩🇿 ar-DZ Algeria 012 .dz
|
||||||
|
EC ECU 593 Quito USD 🇪🇨 es-EC Ecuador 218 .ec
|
||||||
|
EE EST 372 Tallinn EUR 🇪🇪 et,ru Estonia 233 .ee
|
||||||
|
EG EGY 20 Cairo EGP 🇪🇬 ar-EG,en,fr Egypt 818 .eg
|
||||||
|
EH ESH 212 El-Aaiun MAD 🇪🇭 ar,mey Western Sahara 732 .eh
|
||||||
|
ER ERI 291 Asmara ERN 🇪🇷 aa-ER,ar,tig,kun,ti-ER Eritrea 232 .er
|
||||||
|
ES ESP 34 Madrid EUR 🇪🇸 es-ES,ca,gl,eu,oc Spain 724 .es
|
||||||
|
ET ETH 251 Addis Ababa ETB 🇪🇹 am,en-ET,om-ET,ti-ET,so-ET,sid Ethiopia 231 .et
|
||||||
|
FI FIN 358 Helsinki EUR 🇫🇮 fi-FI,sv-FI,smn Finland 246 .fi
|
||||||
|
FJ FJI 679 Suva FJD 🇫🇯 en-FJ,fj Fiji 242 .fj
|
||||||
|
FK FLK 500 Stanley FKP 🇫🇰 en-FK Falkland Islands 238 .fk
|
||||||
|
FM FSM 691 Palikir USD 🇫🇲 en-FM,chk,pon,yap,kos,uli,woe,nkr,kpg Micronesia 583 .fm
|
||||||
|
FO FRO 298 Torshavn DKK 🇫🇴 fo,da-FO Faroe Islands 234 .fo
|
||||||
|
FR FRA 33 Paris EUR 🇫🇷 fr-FR,frp,br,co,ca,eu,oc France 250 .fr
|
||||||
|
GA GAB 241 Libreville XAF 🇬🇦 fr-GA Gabon 266 .ga
|
||||||
|
GB GBR 44 London GBP 🇬🇧 en-GB,cy-GB,gd UK 826 .uk
|
||||||
|
GD GRD 1-473 St. George's XCD 🇬🇩 en-GD Grenada 308 .gd
|
||||||
|
GE GEO 995 Tbilisi GEL 🇬🇪 ka,ru,hy,az Georgia 268 .ge
|
||||||
|
GF GUF 594 Cayenne EUR 🇬🇫 fr-GF French Guiana 254 .gf
|
||||||
|
GG GGY 44 St Peter Port GBP 🇬🇬 en,nrf Guernsey 831 .gg
|
||||||
|
GH GHA 233 Accra GHS 🇬🇭 en-GH,ak,ee,tw Ghana 288 .gh
|
||||||
|
GI GIB 350 Gibraltar GIP 🇬🇮 en-GI,es,it,pt Gibraltar 292 .gi
|
||||||
|
GL GRL 299 Nuuk DKK 🇬🇱 kl,da-GL,en Greenland 304 .gl
|
||||||
|
GM GMB 220 Banjul GMD 🇬🇲 en-GM,mnk,wof,wo,ff Gambia 270 .gm
|
||||||
|
GN GIN 224 Conakry GNF 🇬🇳 fr-GN Guinea 324 .gn
|
||||||
|
GP GLP 590 Basse-Terre EUR 🇬🇵 fr-GP Guadeloupe 312 .gp
|
||||||
|
GQ GNQ 240 Ciudad de la Paz XAF 🇬🇶 es-GQ,fr,pt Equatorial Guinea 226 .gq
|
||||||
|
GR GRC 30 Athens EUR 🇬🇷 el-GR,en,fr Greece 300 .gr
|
||||||
|
GT GTM 502 Guatemala City GTQ 🇬🇹 es-GT Guatemala 320 .gt
|
||||||
|
GU GUM 1-671 Hagatna USD 🇬🇺 en-GU,ch-GU Guam 316 .gu
|
||||||
|
GW GNB 245 Bissau XOF 🇬🇼 pt-GW,pov Guinea-Bissau 624 .gw
|
||||||
|
GY GUY 592 Georgetown GYD 🇬🇾 en-GY Guyana 328 .gy
|
||||||
|
HK HKG 852 Hong Kong HKD 🇭🇰 zh-HK,yue,zh,en Hong Kong 344 .hk
|
||||||
|
HN HND 504 Tegucigalpa HNL 🇭🇳 es-HN,cab,miq Honduras 340 .hn
|
||||||
|
HR HRV 385 Zagreb EUR 🇭🇷 hr-HR,sr Croatia 191 .hr
|
||||||
|
HT HTI 509 Port-au-Prince HTG 🇭🇹 ht,fr-HT Haiti 332 .ht
|
||||||
|
HU HUN 36 Budapest HUF 🇭🇺 hu-HU Hungary 348 .hu
|
||||||
|
ID IDN 62 Jakarta IDR 🇮🇩 id,en,nl,jv Indonesia 360 .id
|
||||||
|
IE IRL 353 Dublin EUR 🇮🇪 en-IE,ga-IE Ireland 372 .ie
|
||||||
|
IL ISR 972 Jerusalem ILS 🇮🇱 he,ar-IL,en-IL Israel 376 .il
|
||||||
|
IM IMN 44 Douglas GBP 🇮🇲 en,gv Isle of Man 833 .im
|
||||||
|
IN IND 91 New Delhi INR 🇮🇳 en-IN,hi,bn,te,mr,ta,ur,gu,kn,ml,or,pa,as,bh,sat,ks,ne,sd,kok,doi,mni,sit,sa,fr,lus,inc India 356 .in
|
||||||
|
IO IOT 246 Diego Garcia USD 🇮🇴 en-IO British Indian Ocean Territory 086 .io
|
||||||
|
IQ IRQ 964 Baghdad IQD 🇮🇶 ar-IQ,ku,hy Iraq 368 .iq
|
||||||
|
IR IRN 98 Tehran IRR 🇮🇷 fa-IR,ku Iran 364 .ir
|
||||||
|
IS ISL 354 Reykjavik ISK 🇮🇸 is,en,de,da,sv,no Iceland 352 .is
|
||||||
|
IT ITA 39 Rome EUR 🇮🇹 it-IT,de-IT,fr-IT,sc,ca,co,sl Italy 380 .it
|
||||||
|
JE JEY 44 Saint Helier GBP 🇯🇪 en,fr,nrf Jersey 832 .je
|
||||||
|
JM JAM 1-876 Kingston JMD 🇯🇲 en-JM Jamaica 388 .jm
|
||||||
|
JO JOR 962 Amman JOD 🇯🇴 ar-JO,en Jordan 400 .jo
|
||||||
|
JP JPN 81 Tokyo JPY 🇯🇵 ja Japan 392 .jp
|
||||||
|
KE KEN 254 Nairobi KES 🇰🇪 en-KE,sw-KE Kenya 404 .ke
|
||||||
|
KG KGZ 996 Bishkek KGS 🇰🇬 ky,uz,ru Kyrgyzstan 417 .kg
|
||||||
|
KH KHM 855 Phnom Penh KHR 🇰🇭 km,fr,en Cambodia 116 .kh
|
||||||
|
KI KIR 686 Tarawa AUD 🇰🇮 en-KI,gil Kiribati 296 .ki
|
||||||
|
KM COM 269 Moroni KMF 🇰🇲 ar,fr-KM Comoros 174 .km
|
||||||
|
KN KNA 1-869 Basseterre XCD 🇰🇳 en-KN St. Kitts & Nevis 659 .kn
|
||||||
|
KP PRK 850 Pyongyang KPW 🇰🇵 ko-KP North Korea 408 .kp
|
||||||
|
KR KOR 82 Seoul KRW 🇰🇷 ko-KR,en South Korea 410 .kr
|
||||||
|
KW KWT 965 Kuwait City KWD 🇰🇼 ar-KW,en Kuwait 414 .kw
|
||||||
|
KY CYM 1-345 George Town KYD 🇰🇾 en-KY Cayman Islands 136 .ky
|
||||||
|
KZ KAZ 7 Nur-Sultan KZT 🇰🇿 kk,ru Kazakhstan 398 .kz
|
||||||
|
LA LAO 856 Vientiane LAK 🇱🇦 lo,fr,en Laos 418 .la
|
||||||
|
LB LBN 961 Beirut LBP 🇱🇧 ar-LB,fr-LB,en,hy Lebanon 422 .lb
|
||||||
|
LC LCA 1-758 Castries XCD 🇱🇨 en-LC St. Lucia 662 .lc
|
||||||
|
LI LIE 423 Vaduz CHF 🇱🇮 de-LI Liechtenstein 438 .li
|
||||||
|
LK LKA 94 Colombo LKR 🇱🇰 si,ta,en Sri Lanka 144 .lk
|
||||||
|
LR LBR 231 Monrovia LRD 🇱🇷 en-LR Liberia 430 .lr
|
||||||
|
LS LSO 266 Maseru LSL 🇱🇸 en-LS,st,zu,xh Lesotho 426 .ls
|
||||||
|
LT LTU 370 Vilnius EUR 🇱🇹 lt,ru,pl Lithuania 440 .lt
|
||||||
|
LU LUX 352 Luxembourg EUR 🇱🇺 lb,de-LU,fr-LU Luxembourg 442 .lu
|
||||||
|
LV LVA 371 Riga EUR 🇱🇻 lv,ru,lt Latvia 428 .lv
|
||||||
|
LY LBY 218 Tripoli LYD 🇱🇾 ar-LY,it,en Libya 434 .ly
|
||||||
|
MA MAR 212 Rabat MAD 🇲🇦 ar-MA,ber,fr Morocco 504 .ma
|
||||||
|
MC MCO 377 Monaco EUR 🇲🇨 fr-MC,en,it Monaco 492 .mc
|
||||||
|
MD MDA 373 Chisinau MDL 🇲🇩 ro,ru,gag,tr Moldova 498 .md
|
||||||
|
ME MNE 382 Podgorica EUR 🇲🇪 sr,hu,bs,sq,hr,rom Montenegro 499 .me
|
||||||
|
MF MAF 590 Marigot EUR 🇲🇫 fr St. Martin 663 .gp
|
||||||
|
MG MDG 261 Antananarivo MGA 🇲🇬 fr-MG,mg Madagascar 450 .mg
|
||||||
|
MH MHL 692 Majuro USD 🇲🇭 mh,en-MH Marshall Islands 584 .mh
|
||||||
|
MK MKD 389 Skopje MKD 🇲🇰 mk,sq,tr,rmm,sr North Macedonia 807 .mk
|
||||||
|
ML MLI 223 Bamako XOF 🇲🇱 fr-ML,bm Mali 466 .ml
|
||||||
|
MM MMR 95 Nay Pyi Taw MMK 🇲🇲 my Myanmar 104 .mm
|
||||||
|
MN MNG 976 Ulaanbaatar MNT 🇲🇳 mn,ru Mongolia 496 .mn
|
||||||
|
MO MAC 853 Macao MOP 🇲🇴 zh,zh-MO,pt Macao 446 .mo
|
||||||
|
MP MNP 1-670 Saipan USD 🇲🇵 fil,tl,zh,ch-MP,en-MP Northern Mariana Islands 580 .mp
|
||||||
|
MQ MTQ 596 Fort-de-France EUR 🇲🇶 fr-MQ Martinique 474 .mq
|
||||||
|
MR MRT 222 Nouakchott MRU 🇲🇷 ar-MR,fuc,snk,fr,mey,wo Mauritania 478 .mr
|
||||||
|
MS MSR 1-664 Plymouth XCD 🇲🇸 en-MS Montserrat 500 .ms
|
||||||
|
MT MLT 356 Valletta EUR 🇲🇹 mt,en-MT Malta 470 .mt
|
||||||
|
MU MUS 230 Port Louis MUR 🇲🇺 en-MU,bho,fr Mauritius 480 .mu
|
||||||
|
MV MDV 960 Male MVR 🇲🇻 dv,en Maldives 462 .mv
|
||||||
|
MW MWI 265 Lilongwe MWK 🇲🇼 ny,yao,tum,swk Malawi 454 .mw
|
||||||
|
MX MEX 52 Mexico City MXN 🇲🇽 es-MX Mexico 484 .mx
|
||||||
|
MY MYS 60 Kuala Lumpur MYR 🇲🇾 ms-MY,en,zh,ta,te,ml,pa,th Malaysia 458 .my
|
||||||
|
MZ MOZ 258 Maputo MZN 🇲🇿 pt-MZ,vmw Mozambique 508 .mz
|
||||||
|
NA NAM 264 Windhoek NAD 🇳🇦 en-NA,af,de,hz,naq Namibia 516 .na
|
||||||
|
NC NCL 687 Noumea XPF 🇳🇨 fr-NC New Caledonia 540 .nc
|
||||||
|
NE NER 227 Niamey XOF 🇳🇪 fr-NE,ha,kr,dje Niger 562 .ne
|
||||||
|
NF NFK 672 Kingston AUD 🇳🇫 en-NF Norfolk Island 574 .nf
|
||||||
|
NG NGA 234 Abuja NGN 🇳🇬 en-NG,ha,yo,ig,ff Nigeria 566 .ng
|
||||||
|
NI NIC 505 Managua NIO 🇳🇮 es-NI,en Nicaragua 558 .ni
|
||||||
|
NL NLD 31 Amsterdam EUR 🇳🇱 nl-NL,fy-NL Netherlands 528 .nl
|
||||||
|
NO NOR 47 Oslo NOK 🇳🇴 no,nb,nn,se,fi Norway 578 .no
|
||||||
|
NP NPL 977 Kathmandu NPR 🇳🇵 ne,en Nepal 524 .np
|
||||||
|
NR NRU 674 Yaren AUD 🇳🇷 na,en-NR Nauru 520 .nr
|
||||||
|
NU NIU 683 Alofi NZD 🇳🇺 niu,en-NU Niue 570 .nu
|
||||||
|
NZ NZL 64 Wellington NZD 🇳🇿 en-NZ,mi New Zealand 554 .nz
|
||||||
|
OM OMN 968 Muscat OMR 🇴🇲 ar-OM,en,bal,ur Oman 512 .om
|
||||||
|
PA PAN 507 Panama City PAB 🇵🇦 es-PA,en Panama 591 .pa
|
||||||
|
PE PER 51 Lima PEN 🇵🇪 es-PE,qu,ay Peru 604 .pe
|
||||||
|
PF PYF 689 Papeete XPF 🇵🇫 fr-PF,ty French Polynesia 258 .pf
|
||||||
|
PG PNG 675 Port Moresby PGK 🇵🇬 en-PG,ho,meu,tpi Papua New Guinea 598 .pg
|
||||||
|
PH PHL 63 Manila PHP 🇵🇭 tl,en-PH,fil,ceb,ilo,hil,war,pam,bik,bcl,pag,mrw,tsg,mdh,cbk,krj,sgd,msb,akl,ibg,yka,mta,abx Philippines 608 .ph
|
||||||
|
PK PAK 92 Islamabad PKR 🇵🇰 ur-PK,en-PK,pa,sd,ps,brh Pakistan 586 .pk
|
||||||
|
PL POL 48 Warsaw PLN 🇵🇱 pl Poland 616 .pl
|
||||||
|
PM SPM 508 Saint-Pierre EUR 🇵🇲 fr-PM St. Pierre & Miquelon 666 .pm
|
||||||
|
PN PCN 870 Adamstown NZD 🇵🇳 en-PN Pitcairn 612 .pn
|
||||||
|
PR PRI 1 San Juan USD 🇵🇷 en-PR,es-PR Puerto Rico 630 .pr
|
||||||
|
PT PRT 351 Lisbon EUR 🇵🇹 pt-PT,mwl Portugal 620 .pt
|
||||||
|
PW PLW 680 Melekeok USD 🇵🇼 pau,sov,en-PW,tox,ja,fil,zh Palau 585 .pw
|
||||||
|
PY PRY 595 Asuncion PYG 🇵🇾 es-PY,gn Paraguay 600 .py
|
||||||
|
QA QAT 974 Doha QAR 🇶🇦 ar-QA,es Qatar 634 .qa
|
||||||
|
RE REU 262 Saint-Denis EUR 🇷🇪 fr-RE Réunion 638 .re
|
||||||
|
RO ROU 40 Bucharest RON 🇷🇴 ro,hu,rom Romania 642 .ro
|
||||||
|
RS SRB 381 Belgrade RSD 🇷🇸 sr,hu,bs,rom Serbia 688 .rs
|
||||||
|
RU RUS 7 Moscow RUB 🇷🇺 ru,tt,xal,cau,ady,kv,ce,tyv,cv,udm,tut,mns,bua,myv,mdf,chm,ba,inh,kbd,krc,av,sah,nog Russia 643 .ru
|
||||||
|
RW RWA 250 Kigali RWF 🇷🇼 rw,en-RW,fr-RW,sw Rwanda 646 .rw
|
||||||
|
SA SAU 966 Riyadh SAR 🇸🇦 ar-SA Saudi Arabia 682 .sa
|
||||||
|
SB SLB 677 Honiara SBD 🇸🇧 en-SB,tpi Solomon Islands 090 .sb
|
||||||
|
SC SYC 248 Victoria SCR 🇸🇨 en-SC,fr-SC Seychelles 690 .sc
|
||||||
|
SD SDN 249 Khartoum SDG 🇸🇩 ar-SD,en,fia Sudan 729 .sd
|
||||||
|
SE SWE 46 Stockholm SEK 🇸🇪 sv-SE,se,sma,fi-SE Sweden 752 .se
|
||||||
|
SG SGP 65 Singapore SGD 🇸🇬 cmn,en-SG,ms-SG,ta-SG,zh-SG Singapore 702 .sg
|
||||||
|
SH SHN 290 Jamestown SHP 🇸🇭 en-SH St. Helena 654 .sh
|
||||||
|
SI SVN 386 Ljubljana EUR 🇸🇮 sl,sh Slovenia 705 .si
|
||||||
|
SJ SJM 47 Longyearbyen NOK 🇸🇯 no,ru Svalbard & Jan Mayen 744 .sj
|
||||||
|
SK SVK 421 Bratislava EUR 🇸🇰 sk,hu Slovakia 703 .sk
|
||||||
|
SL SLE 232 Freetown SLE 🇸🇱 en-SL,men,tem Sierra Leone 694 .sl
|
||||||
|
SM SMR 378 San Marino EUR 🇸🇲 it-SM San Marino 674 .sm
|
||||||
|
SN SEN 221 Dakar XOF 🇸🇳 fr-SN,wo,fuc,mnk Senegal 686 .sn
|
||||||
|
SO SOM 252 Mogadishu SOS 🇸🇴 so-SO,ar-SO,it,en-SO Somalia 706 .so
|
||||||
|
SR SUR 597 Paramaribo SRD 🇸🇷 nl-SR,en,srn,hns,jv Suriname 740 .sr
|
||||||
|
SS SSD 211 Juba SSP 🇸🇸 en South Sudan 728 .ss
|
||||||
|
ST STP 239 Sao Tome STN 🇸🇹 pt-ST São Tomé & Príncipe 678 .st
|
||||||
|
SV SLV 503 San Salvador SVC 🇸🇻 es-SV El Salvador 222 .sv
|
||||||
|
SX SXM 1-721 Philipsburg XCG 🇸🇽 nl,en Sint Maarten 534 .sx
|
||||||
|
SY SYR 963 Damascus SYP 🇸🇾 ar-SY,ku,hy,arc,fr,en Syria 760 .sy
|
||||||
|
SZ SWZ 268 Mbabane SZL 🇸🇿 en-SZ,ss-SZ Eswatini 748 .sz
|
||||||
|
TC TCA 1-649 Cockburn Town USD 🇹🇨 en-TC Turks & Caicos Islands 796 .tc
|
||||||
|
TD TCD 235 N'Djamena XAF 🇹🇩 fr-TD,ar-TD,sre Chad 148 .td
|
||||||
|
TF ATF 262 Port-aux-Francais EUR 🇹🇫 fr French Southern Territories 260 .tf
|
||||||
|
TG TGO 228 Lome XOF 🇹🇬 fr-TG,ee,hna,kbp,dag,ha Togo 768 .tg
|
||||||
|
TH THA 66 Bangkok THB 🇹🇭 th,en Thailand 764 .th
|
||||||
|
TJ TJK 992 Dushanbe TJS 🇹🇯 tg,ru Tajikistan 762 .tj
|
||||||
|
TL TLS 670 Dili USD 🇹🇱 tet,pt-TL,id,en Timor-Leste 626 .tl
|
||||||
|
TM TKM 993 Ashgabat TMT 🇹🇲 tk,ru,uz Turkmenistan 795 .tm
|
||||||
|
TN TUN 216 Tunis TND 🇹🇳 ar-TN,fr Tunisia 788 .tn
|
||||||
|
TO TON 676 Nuku'alofa TOP 🇹🇴 to,en-TO Tonga 776 .to
|
||||||
|
TR TUR 90 Ankara TRY 🇹🇷 tr-TR,ku,diq,az,av Türkiye 792 .tr
|
||||||
|
TT TTO 1-868 Port of Spain TTD 🇹🇹 en-TT,hns,fr,es,zh Trinidad & Tobago 780 .tt
|
||||||
|
TV TUV 688 Funafuti AUD 🇹🇻 tvl,en,sm,gil Tuvalu 798 .tv
|
||||||
|
TW TWN 886 Taipei TWD 🇹🇼 zh-TW,zh,nan,hak Taiwan 158 .tw
|
||||||
|
TZ TZA 255 Dodoma TZS 🇹🇿 sw-TZ,en,ar Tanzania 834 .tz
|
||||||
|
UA UKR 380 Kyiv UAH 🇺🇦 uk,ru-UA,rom,pl,hu Ukraine 804 .ua
|
||||||
|
UG UGA 256 Kampala UGX 🇺🇬 en-UG,lg,sw,ar Uganda 800 .ug
|
||||||
|
US USA 1 Washington USD 🇺🇸 en-US,es-US,haw,fr US 840 .us
|
||||||
|
UY URY 598 Montevideo UYU 🇺🇾 es-UY Uruguay 858 .uy
|
||||||
|
UZ UZB 998 Tashkent UZS 🇺🇿 uz,ru,tg Uzbekistan 860 .uz
|
||||||
|
VA VAT 39 Vatican City EUR 🇻🇦 la,it,fr Vatican City 336 .va
|
||||||
|
VC VCT 1-784 Kingstown XCD 🇻🇨 en-VC,fr St. Vincent & Grenadines 670 .vc
|
||||||
|
VE VEN 58 Caracas VES 🇻🇪 es-VE Venezuela 862 .ve
|
||||||
|
VG VGB 1-284 Road Town USD 🇻🇬 en-VG British Virgin Islands 092 .vg
|
||||||
|
VI VIR 1-340 Charlotte Amalie USD 🇻🇮 en-VI U.S. Virgin Islands 850 .vi
|
||||||
|
VN VNM 84 Hanoi VND 🇻🇳 vi,en,fr,zh,km Vietnam 704 .vn
|
||||||
|
VU VUT 678 Port Vila VUV 🇻🇺 bi,en-VU,fr-VU Vanuatu 548 .vu
|
||||||
|
WF WLF 681 Mata Utu XPF 🇼🇫 wls,fud,fr-WF Wallis & Futuna 876 .wf
|
||||||
|
WS WSM 685 Apia WST 🇼🇸 sm,en-WS Samoa 882 .ws
|
||||||
|
YE YEM 967 Sanaa YER 🇾🇪 ar-YE Yemen 887 .ye
|
||||||
|
YT MYT 262 Mamoudzou EUR 🇾🇹 fr-YT Mayotte 175 .yt
|
||||||
|
ZA ZAF 27 Pretoria ZAR 🇿🇦 zu,xh,af,nso,en-ZA,tn,st,ts,ss,ve,nr South Africa 710 .za
|
||||||
|
ZM ZMB 260 Lusaka ZMW 🇿🇲 en-ZM,bem,loz,lun,lue,ny,toi Zambia 894 .zm
|
||||||
|
ZW ZWE 263 Harare ZWG 🇿🇼 en-ZW,sn,nr,nd Zimbabwe 716 .zw
|
||||||
|
+1
-18
@@ -1,18 +1 @@
|
|||||||
[
|
{ "format": "{code}", "rows": "currency.tsv", "key": "code", "name": "name" }
|
||||||
{ "format": "{code}", "code": "AUD", "name": "Australian Dollar", "symbol": "$" },
|
|
||||||
{ "format": "{code}", "code": "BRL", "name": "Brazilian Real", "symbol": "R$" },
|
|
||||||
{ "format": "{code}", "code": "CAD", "name": "Canadian Dollar", "symbol": "$" },
|
|
||||||
{ "format": "{code}", "code": "CHF", "name": "Swiss Franc", "symbol": "CHF" },
|
|
||||||
{ "format": "{code}", "code": "CNY", "name": "Chinese Yuan", "symbol": "¥" },
|
|
||||||
{ "format": "{code}", "code": "DKK", "name": "Danish Krone", "symbol": "kr" },
|
|
||||||
{ "format": "{code}", "code": "EUR", "name": "Euro", "symbol": "€" },
|
|
||||||
{ "format": "{code}", "code": "GBP", "name": "Pound Sterling", "symbol": "£" },
|
|
||||||
{ "format": "{code}", "code": "INR", "name": "Indian Rupee", "symbol": "₹" },
|
|
||||||
{ "format": "{code}", "code": "JPY", "name": "Japanese Yen", "symbol": "¥" },
|
|
||||||
{ "format": "{code}", "code": "MXN", "name": "Mexican Peso", "symbol": "$" },
|
|
||||||
{ "format": "{code}", "code": "NOK", "name": "Norwegian Krone", "symbol": "kr" },
|
|
||||||
{ "format": "{code}", "code": "PLN", "name": "Polish Zloty", "symbol": "zł" },
|
|
||||||
{ "format": "{code}", "code": "SEK", "name": "Swedish Krona", "symbol": "kr" },
|
|
||||||
{ "format": "{code}", "code": "USD", "name": "US Dollar", "symbol": "$" },
|
|
||||||
{ "format": "{code}", "code": "ZAR", "name": "South African Rand", "symbol": "R" }
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
code decimals name numeric symbol
|
||||||
|
AED 2 UAE Dirham 784 AED
|
||||||
|
AFN 2 Afghani 971 ؋
|
||||||
|
ALL 2 Lek 008 ALL
|
||||||
|
AMD 2 Armenian Dram 051 ֏
|
||||||
|
AOA 2 Kwanza 973 Kz
|
||||||
|
ARS 2 Argentine Peso 032 $
|
||||||
|
AUD 2 Australian Dollar 036 $
|
||||||
|
AWG 2 Aruban Florin 533 AWG
|
||||||
|
AZN 2 Azerbaijan Manat 944 ₼
|
||||||
|
BAM 2 Convertible Mark 977 KM
|
||||||
|
BBD 2 Barbados Dollar 052 $
|
||||||
|
BDT 2 Taka 050 ৳
|
||||||
|
BHD 3 Bahraini Dinar 048 BHD
|
||||||
|
BIF 0 Burundi Franc 108 BIF
|
||||||
|
BMD 2 Bermudian Dollar 060 $
|
||||||
|
BND 2 Brunei Dollar 096 $
|
||||||
|
BOB 2 Boliviano 068 Bs
|
||||||
|
BOV 2 Mvdol 984 BOV
|
||||||
|
BRL 2 Brazilian Real 986 R$
|
||||||
|
BSD 2 Bahamian Dollar 044 $
|
||||||
|
BTN 2 Ngultrum 064 BTN
|
||||||
|
BWP 2 Pula 072 P
|
||||||
|
BYN 2 Belarusian Ruble 933 BYN
|
||||||
|
BZD 2 Belize Dollar 084 $
|
||||||
|
CAD 2 Canadian Dollar 124 $
|
||||||
|
CDF 2 Congolese Franc 976 CDF
|
||||||
|
CHE 2 WIR Euro 947 CHE
|
||||||
|
CHF 2 Swiss Franc 756 Fr.
|
||||||
|
CHW 2 WIR Franc 948 CHW
|
||||||
|
CLF 4 Unidad de Fomento 990 CLF
|
||||||
|
CLP 0 Chilean Peso 152 $
|
||||||
|
CNY 2 Yuan Renminbi 156 ¥
|
||||||
|
COP 2 Colombian Peso 170 $
|
||||||
|
COU 2 Unidad de Valor Real 970 COU
|
||||||
|
CRC 2 Costa Rican Colon 188 ₡
|
||||||
|
CUP 2 Cuban Peso 192 $
|
||||||
|
CVE 2 Cabo Verde Escudo 132 CVE
|
||||||
|
CZK 2 Czech Koruna 203 Kč
|
||||||
|
DJF 0 Djibouti Franc 262 DJF
|
||||||
|
DKK 2 Danish Krone 208 kr
|
||||||
|
DOP 2 Dominican Peso 214 $
|
||||||
|
DZD 2 Algerian Dinar 012 DZD
|
||||||
|
EGP 2 Egyptian Pound 818 E£
|
||||||
|
ERN 2 Nakfa 232 ERN
|
||||||
|
ETB 2 Ethiopian Birr 230 ETB
|
||||||
|
EUR 2 Euro 978 €
|
||||||
|
FJD 2 Fiji Dollar 242 $
|
||||||
|
FKP 2 Falkland Islands Pound 238 £
|
||||||
|
GBP 2 Pound Sterling 826 £
|
||||||
|
GEL 2 Lari 981 ₾
|
||||||
|
GHS 2 Ghana Cedi 936 GH₵
|
||||||
|
GIP 2 Gibraltar Pound 292 £
|
||||||
|
GMD 2 Dalasi 270 GMD
|
||||||
|
GNF 0 Guinean Franc 324 FG
|
||||||
|
GTQ 2 Quetzal 320 Q
|
||||||
|
GYD 2 Guyana Dollar 328 $
|
||||||
|
HKD 2 Hong Kong Dollar 344 $
|
||||||
|
HNL 2 Lempira 340 L
|
||||||
|
HTG 2 Gourde 332 HTG
|
||||||
|
HUF 2 Forint 348 Ft
|
||||||
|
IDR 2 Rupiah 360 Rp
|
||||||
|
ILS 2 New Israeli Sheqel 376 ₪
|
||||||
|
INR 2 Indian Rupee 356 ₹
|
||||||
|
IQD 3 Iraqi Dinar 368 IQD
|
||||||
|
IRR 2 Iranian Rial 364 IRR
|
||||||
|
ISK 0 Iceland Krona 352 kr
|
||||||
|
JMD 2 Jamaican Dollar 388 $
|
||||||
|
JOD 3 Jordanian Dinar 400 JOD
|
||||||
|
JPY 0 Yen 392 ¥
|
||||||
|
KES 2 Kenyan Shilling 404 KES
|
||||||
|
KGS 2 Som 417 ⃀
|
||||||
|
KHR 2 Riel 116 ៛
|
||||||
|
KMF 0 Comorian Franc 174 CF
|
||||||
|
KPW 2 North Korean Won 408 ₩
|
||||||
|
KRW 0 Won 410 ₩
|
||||||
|
KWD 3 Kuwaiti Dinar 414 KWD
|
||||||
|
KYD 2 Cayman Islands Dollar 136 $
|
||||||
|
KZT 2 Tenge 398 ₸
|
||||||
|
LAK 2 Lao Kip 418 ₭
|
||||||
|
LBP 2 Lebanese Pound 422 L£
|
||||||
|
LKR 2 Sri Lanka Rupee 144 Rs
|
||||||
|
LRD 2 Liberian Dollar 430 $
|
||||||
|
LSL 2 Loti 426 LSL
|
||||||
|
LYD 3 Libyan Dinar 434 LYD
|
||||||
|
MAD 2 Moroccan Dirham 504 MAD
|
||||||
|
MDL 2 Moldovan Leu 498 MDL
|
||||||
|
MGA 2 Malagasy Ariary 969 Ar
|
||||||
|
MKD 2 Denar 807 MKD
|
||||||
|
MMK 2 Kyat 104 K
|
||||||
|
MNT 2 Tugrik 496 ₮
|
||||||
|
MOP 2 Pataca 446 MOP
|
||||||
|
MRU 2 Ouguiya 929 MRU
|
||||||
|
MUR 2 Mauritius Rupee 480 Rs
|
||||||
|
MVR 2 Rufiyaa 462 MVR
|
||||||
|
MWK 2 Malawi Kwacha 454 MWK
|
||||||
|
MXN 2 Mexican Peso 484 $
|
||||||
|
MXV 2 Mexican Unidad de Inversion (UDI) 979 MXV
|
||||||
|
MYR 2 Malaysian Ringgit 458 RM
|
||||||
|
MZN 2 Mozambique Metical 943 MZN
|
||||||
|
NAD 2 Namibia Dollar 516 $
|
||||||
|
NGN 2 Naira 566 ₦
|
||||||
|
NIO 2 Cordoba Oro 558 C$
|
||||||
|
NOK 2 Norwegian Krone 578 kr
|
||||||
|
NPR 2 Nepalese Rupee 524 Rs
|
||||||
|
NZD 2 New Zealand Dollar 554 $
|
||||||
|
OMR 3 Rial Omani 512 OMR
|
||||||
|
PAB 2 Balboa 590 PAB
|
||||||
|
PEN 2 Sol 604 PEN
|
||||||
|
PGK 2 Kina 598 PGK
|
||||||
|
PHP 2 Philippine Peso 608 ₱
|
||||||
|
PKR 2 Pakistan Rupee 586 Rs
|
||||||
|
PLN 2 Zloty 985 zł
|
||||||
|
PYG 0 Guarani 600 ₲
|
||||||
|
QAR 2 Qatari Rial 634 QAR
|
||||||
|
RON 2 Romanian Leu 946 lei
|
||||||
|
RSD 2 Serbian Dinar 941 RSD
|
||||||
|
RUB 2 Russian Ruble 643 ₽
|
||||||
|
RWF 0 Rwanda Franc 646 RF
|
||||||
|
SAR 2 Saudi Riyal 682 SAR
|
||||||
|
SBD 2 Solomon Islands Dollar 090 $
|
||||||
|
SCR 2 Seychelles Rupee 690 SCR
|
||||||
|
SDG 2 Sudanese Pound 938 SDG
|
||||||
|
SEK 2 Swedish Krona 752 kr
|
||||||
|
SGD 2 Singapore Dollar 702 $
|
||||||
|
SHP 2 Saint Helena Pound 654 £
|
||||||
|
SLE 2 Leone 925 SLE
|
||||||
|
SOS 2 Somali Shilling 706 SOS
|
||||||
|
SRD 2 Surinam Dollar 968 $
|
||||||
|
SSP 2 South Sudanese Pound 728 £
|
||||||
|
STN 2 Dobra 930 Db
|
||||||
|
SVC 2 El Salvador Colon 222 SVC
|
||||||
|
SYP 2 Syrian Pound 760 £
|
||||||
|
SZL 2 Lilangeni 748 SZL
|
||||||
|
THB 2 Baht 764 ฿
|
||||||
|
TJS 2 Somoni 972 TJS
|
||||||
|
TMT 2 Turkmenistan New Manat 934 TMT
|
||||||
|
TND 3 Tunisian Dinar 788 TND
|
||||||
|
TOP 2 Pa’anga 776 T$
|
||||||
|
TRY 2 Turkish Lira 949 ₺
|
||||||
|
TTD 2 Trinidad and Tobago Dollar 780 $
|
||||||
|
TWD 2 New Taiwan Dollar 901 $
|
||||||
|
TZS 2 Tanzanian Shilling 834 TZS
|
||||||
|
UAH 2 Hryvnia 980 ₴
|
||||||
|
UGX 0 Uganda Shilling 800 UGX
|
||||||
|
USD 2 US Dollar 840 $
|
||||||
|
USN 2 US Dollar (Next day) 997 USN
|
||||||
|
UYI 0 Uruguay Peso en Unidades Indexadas (UI) 940 UYI
|
||||||
|
UYU 2 Peso Uruguayo 858 $
|
||||||
|
UYW 4 Unidad Previsional 927 UYW
|
||||||
|
UZS 2 Uzbekistan Sum 860 UZS
|
||||||
|
VED 2 Bolívar Soberano 926 VED
|
||||||
|
VES 2 Bolívar Soberano 928 VES
|
||||||
|
VND 0 Dong 704 ₫
|
||||||
|
VUV 0 Vatu 548 VUV
|
||||||
|
WST 2 Tala 882 WST
|
||||||
|
XAD 2 Arab Accounting Dinar 396 XAD
|
||||||
|
XAF 0 CFA Franc BEAC 950 FCFA
|
||||||
|
XCD 2 East Caribbean Dollar 951 $
|
||||||
|
XCG 2 Caribbean Guilder 532 Cg.
|
||||||
|
XOF 0 CFA Franc BCEAO 952 F CFA
|
||||||
|
XPF 0 CFP Franc 953 CFPF
|
||||||
|
YER 2 Yemeni Rial 886 YER
|
||||||
|
ZAR 2 Rand 710 R
|
||||||
|
ZMW 2 Zambian Kwacha 967 ZK
|
||||||
|
ZWG 2 Zimbabwe Gold 924 ZWG
|
||||||
|
@@ -1,18 +1 @@
|
|||||||
[
|
{ "format": "{code} {reason}", "rows": "httpstatus.tsv", "key": "code", "name": "reason" }
|
||||||
{ "format": "{code} {reason}", "code": "200", "reason": "OK" },
|
|
||||||
{ "format": "{code} {reason}", "code": "201", "reason": "Created" },
|
|
||||||
{ "format": "{code} {reason}", "code": "204", "reason": "No Content" },
|
|
||||||
{ "format": "{code} {reason}", "code": "301", "reason": "Moved Permanently" },
|
|
||||||
{ "format": "{code} {reason}", "code": "302", "reason": "Found" },
|
|
||||||
{ "format": "{code} {reason}", "code": "304", "reason": "Not Modified" },
|
|
||||||
{ "format": "{code} {reason}", "code": "400", "reason": "Bad Request" },
|
|
||||||
{ "format": "{code} {reason}", "code": "401", "reason": "Unauthorized" },
|
|
||||||
{ "format": "{code} {reason}", "code": "403", "reason": "Forbidden" },
|
|
||||||
{ "format": "{code} {reason}", "code": "404", "reason": "Not Found" },
|
|
||||||
{ "format": "{code} {reason}", "code": "409", "reason": "Conflict" },
|
|
||||||
{ "format": "{code} {reason}", "code": "422", "reason": "Unprocessable Entity" },
|
|
||||||
{ "format": "{code} {reason}", "code": "429", "reason": "Too Many Requests" },
|
|
||||||
{ "format": "{code} {reason}", "code": "500", "reason": "Internal Server Error" },
|
|
||||||
{ "format": "{code} {reason}", "code": "502", "reason": "Bad Gateway" },
|
|
||||||
{ "format": "{code} {reason}", "code": "503", "reason": "Service Unavailable" }
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
code reason
|
||||||
|
200 OK
|
||||||
|
201 Created
|
||||||
|
204 No Content
|
||||||
|
301 Moved Permanently
|
||||||
|
302 Found
|
||||||
|
304 Not Modified
|
||||||
|
400 Bad Request
|
||||||
|
401 Unauthorized
|
||||||
|
403 Forbidden
|
||||||
|
404 Not Found
|
||||||
|
409 Conflict
|
||||||
|
422 Unprocessable Entity
|
||||||
|
429 Too Many Requests
|
||||||
|
500 Internal Server Error
|
||||||
|
502 Bad Gateway
|
||||||
|
503 Service Unavailable
|
||||||
|
+1
-18
@@ -1,18 +1 @@
|
|||||||
[
|
{ "format": "{name}", "rows": "language.tsv", "key": "code", "name": "name" }
|
||||||
{ "format": "{name}", "name": "Arabic", "code": "ar" },
|
|
||||||
{ "format": "{name}", "name": "Chinese", "code": "zh" },
|
|
||||||
{ "format": "{name}", "name": "Danish", "code": "da" },
|
|
||||||
{ "format": "{name}", "name": "Dutch", "code": "nl" },
|
|
||||||
{ "format": "{name}", "name": "English", "code": "en" },
|
|
||||||
{ "format": "{name}", "name": "Finnish", "code": "fi" },
|
|
||||||
{ "format": "{name}", "name": "French", "code": "fr" },
|
|
||||||
{ "format": "{name}", "name": "German", "code": "de" },
|
|
||||||
{ "format": "{name}", "name": "Italian", "code": "it" },
|
|
||||||
{ "format": "{name}", "name": "Japanese", "code": "ja" },
|
|
||||||
{ "format": "{name}", "name": "Norwegian", "code": "no" },
|
|
||||||
{ "format": "{name}", "name": "Polish", "code": "pl" },
|
|
||||||
{ "format": "{name}", "name": "Portuguese", "code": "pt" },
|
|
||||||
{ "format": "{name}", "name": "Russian", "code": "ru" },
|
|
||||||
{ "format": "{name}", "name": "Spanish", "code": "es" },
|
|
||||||
{ "format": "{name}", "name": "Swedish", "code": "sv" }
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
code name
|
||||||
|
ar Arabic
|
||||||
|
zh Chinese
|
||||||
|
da Danish
|
||||||
|
nl Dutch
|
||||||
|
en English
|
||||||
|
fi Finnish
|
||||||
|
fr French
|
||||||
|
de German
|
||||||
|
it Italian
|
||||||
|
ja Japanese
|
||||||
|
no Norwegian
|
||||||
|
pl Polish
|
||||||
|
pt Portuguese
|
||||||
|
ru Russian
|
||||||
|
es Spanish
|
||||||
|
sv Swedish
|
||||||
|
+1
-17
@@ -1,17 +1 @@
|
|||||||
[
|
{ "format": "{type}", "rows": "mimetype.tsv", "key": "type", "name": "ext" }
|
||||||
{ "format": "{type}", "type": "application/gzip", "ext": ".gz" },
|
|
||||||
{ "format": "{type}", "type": "application/json", "ext": ".json" },
|
|
||||||
{ "format": "{type}", "type": "application/pdf", "ext": ".pdf" },
|
|
||||||
{ "format": "{type}", "type": "application/xml", "ext": ".xml" },
|
|
||||||
{ "format": "{type}", "type": "application/zip", "ext": ".zip" },
|
|
||||||
{ "format": "{type}", "type": "audio/mpeg", "ext": ".mp3" },
|
|
||||||
{ "format": "{type}", "type": "image/gif", "ext": ".gif" },
|
|
||||||
{ "format": "{type}", "type": "image/jpeg", "ext": ".jpg" },
|
|
||||||
{ "format": "{type}", "type": "image/png", "ext": ".png" },
|
|
||||||
{ "format": "{type}", "type": "image/svg+xml", "ext": ".svg" },
|
|
||||||
{ "format": "{type}", "type": "image/webp", "ext": ".webp" },
|
|
||||||
{ "format": "{type}", "type": "text/csv", "ext": ".csv" },
|
|
||||||
{ "format": "{type}", "type": "text/html", "ext": ".html" },
|
|
||||||
{ "format": "{type}", "type": "text/plain", "ext": ".txt" },
|
|
||||||
{ "format": "{type}", "type": "video/mp4", "ext": ".mp4" }
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext type
|
||||||
|
.gz application/gzip
|
||||||
|
.json application/json
|
||||||
|
.pdf application/pdf
|
||||||
|
.xml application/xml
|
||||||
|
.zip application/zip
|
||||||
|
.mp3 audio/mpeg
|
||||||
|
.gif image/gif
|
||||||
|
.jpg image/jpeg
|
||||||
|
.png image/png
|
||||||
|
.svg image/svg+xml
|
||||||
|
.webp image/webp
|
||||||
|
.csv text/csv
|
||||||
|
.html text/html
|
||||||
|
.txt text/plain
|
||||||
|
.mp4 video/mp4
|
||||||
|
+2
-2
@@ -40,7 +40,7 @@ func TestDatatypeAndNullSitOnlyInAColumn(t *testing.T) {
|
|||||||
|
|
||||||
func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) {
|
func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) {
|
||||||
tree := map[string]string{
|
tree := map[string]string{
|
||||||
"cat": `[{"format":"{code}","code":"200"},{"format":"{code}","code":"2x"}]`,
|
"cat": `[{"format":"{code}","code":"200"},{"format":"{code}","code":"2x","note":"n"}]`,
|
||||||
"src": `{"format":"","code":[null,"200","2x"],"flag":{"format":"{b}","b":["true","false"],"datatype":"boolean"},"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`,
|
"src": `{"format":"","code":[null,"200","2x"],"flag":{"format":"{b}","b":["true","false"],"datatype":"boolean"},"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`,
|
||||||
}
|
}
|
||||||
for _, c := range []struct{ name, column, want string }{
|
for _, c := range []struct{ name, column, want string }{
|
||||||
@@ -114,7 +114,7 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) {
|
|||||||
var jsonInteger = regexp.MustCompile(`^(0|-?[1-9][0-9]*)$`)
|
var jsonInteger = regexp.MustCompile(`^(0|-?[1-9][0-9]*)$`)
|
||||||
|
|
||||||
func TestDatatypeAcceptsAColumnThatAlwaysParses(t *testing.T) {
|
func TestDatatypeAcceptsAColumnThatAlwaysParses(t *testing.T) {
|
||||||
cat := `[{"format":"{code}","code":"200"},{"format":"{code}","code":"404"}]`
|
cat := `[{"format":"{code}","code":"200"},{"format":"{code}","code":"404","reason":"Not Found"}]`
|
||||||
for _, column := range []string{
|
for _, column := range []string{
|
||||||
`{"format":"{int(1,99)}","datatype":"integer"}`,
|
`{"format":"{int(1,99)}","datatype":"integer"}`,
|
||||||
`{"format":"{int(-9,-1)}","datatype":"integer"}`,
|
`{"format":"{int(-9,-1)}","datatype":"integer"}`,
|
||||||
|
|||||||
@@ -13,21 +13,23 @@ type drawSet struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// drawScope is where a render reads its reference paths: a draw set, in the draw group of the
|
// drawScope is where a render reads its reference paths: a draw set, in the draw group of the
|
||||||
// template rendering.
|
// template rendering, and the row a table's format is rendering, which its columns read.
|
||||||
type drawScope struct {
|
type drawScope struct {
|
||||||
set *drawSet
|
set *drawSet
|
||||||
group string
|
group string
|
||||||
|
t *table
|
||||||
|
row int
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDrawSet makes the unnamed group's maps where the set is declared, keeping them on that frame's
|
// newDrawSet makes the unnamed group's maps where the set is declared, keeping them on that frame's
|
||||||
// stack for a render that reads through them; a zero drawSet makes them on its first read instead.
|
// stack for a render that reads through them; a zero drawSet makes them on its first read instead.
|
||||||
func newDrawSet() drawSet {
|
func newDrawSet(s *session) drawSet {
|
||||||
return drawSet{unnamed: draws{variant: map[string]node{}, value: map[string]draw{}}}
|
return drawSet{unnamed: draws{variant: map[string]node{}, value: map[string]draw{}, s: s}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderOnce renders n as one render, over draws of its own.
|
// renderOnce renders n as one render, over draws of its own.
|
||||||
func renderOnce(s *session, n node) string {
|
func renderOnce(s *session, n node) string {
|
||||||
var set drawSet
|
set := drawSet{unnamed: draws{s: s}}
|
||||||
return render(s, n, drawScope{set: &set})
|
return render(s, n, drawScope{set: &set})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +41,9 @@ func (sc drawScope) in(t *template) drawScope {
|
|||||||
return sc
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
// draws is the set's draws for sc's draw group.
|
// draws is the set's draws for sc's draw group. The session is passed in rather than
|
||||||
func (sc drawScope) draws() *draws {
|
// read out of the set: copying it from there would leak the set's maps to the heap.
|
||||||
|
func (sc drawScope) draws(s *session) *draws {
|
||||||
if sc.group == "" {
|
if sc.group == "" {
|
||||||
return &sc.set.unnamed
|
return &sc.set.unnamed
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,7 @@ func (sc drawScope) draws() *draws {
|
|||||||
if sc.set.named == nil {
|
if sc.set.named == nil {
|
||||||
sc.set.named = map[string]*draws{}
|
sc.set.named = map[string]*draws{}
|
||||||
}
|
}
|
||||||
d = &draws{variant: map[string]node{}, value: map[string]draw{}}
|
d = &draws{variant: map[string]node{}, value: map[string]draw{}, s: s}
|
||||||
sc.set.named[strings.Clone(sc.group)] = d // a key from sc would leak sc, and with it every render's draw set, to the heap
|
sc.set.named[strings.Clone(sc.group)] = d // a key from sc would leak sc, and with it every render's draw set, to the heap
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
@@ -134,7 +137,13 @@ func (c *drawCheck) checkDrawGroup(path string, n node) error {
|
|||||||
|
|
||||||
func (c *drawCheck) checkDraws(path string, n node) error {
|
func (c *drawCheck) checkDraws(path string, n node) error {
|
||||||
t, ok := n.(*template)
|
t, ok := n.(*template)
|
||||||
if !ok || !c.readsPath(t) {
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := checkOwnFamily(t); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !c.readsPath(t) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
w := newDrawWalk()
|
w := newDrawWalk()
|
||||||
@@ -147,6 +156,43 @@ func (c *drawCheck) checkDraws(path string, n node) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOwnFamily refuses a table's format or cell that reads, however many templates
|
||||||
|
// away and through a repeat or a draw group too, a table of its own family: a row
|
||||||
|
// rendered whole draws its row without pinning it, so the family would draw apart
|
||||||
|
// from the row being rendered, whichever draws the reaching template holds.
|
||||||
|
func checkOwnFamily(t *template) error {
|
||||||
|
own := t.table
|
||||||
|
if own == nil {
|
||||||
|
own = t.cellOf
|
||||||
|
}
|
||||||
|
if own == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[node]bool{}
|
||||||
|
var find func(n node) (renderEdge, *table, bool)
|
||||||
|
find = func(n node) (renderEdge, *table, bool) {
|
||||||
|
if seen[n] {
|
||||||
|
return renderEdge{}, nil, false
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
for _, e := range renderEdges(n) {
|
||||||
|
if a, isRef := refRead(n, e.label); isRef {
|
||||||
|
if head, isTable := n.(*template).fields[a.key].(*table); isTable && head.family() == own.family() {
|
||||||
|
return e, head, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e, head, found := find(e.to); found {
|
||||||
|
return e, head, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderEdge{}, nil, false
|
||||||
|
}
|
||||||
|
if e, head, found := find(t); found {
|
||||||
|
return fmt.Errorf("%s reads %s, a table of its own family, which a row of %s rendered whole would draw apart from; read the family from a template beside it, or add the value as a column", e.reached(), head.category, own.category)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// checkRecordDraws fences the columns of a record — a template compiled at the top without a
|
// checkRecordDraws fences the columns of a record — a template compiled at the top without a
|
||||||
// repeat — so a load proves the record view of it as well as the string view.
|
// repeat — so a load proves the record view of it as well as the string view.
|
||||||
func (c *drawCheck) checkRecordDraws(path string, n node) error {
|
func (c *drawCheck) checkRecordDraws(path string, n node) error {
|
||||||
@@ -186,7 +232,7 @@ func (c *drawCheck) reads(n node, stopAtGroup bool) bool {
|
|||||||
}
|
}
|
||||||
r := false
|
r := false
|
||||||
for _, e := range renderEdges(n) {
|
for _, e := range renderEdges(n) {
|
||||||
if a, isRef := refRead(n, e.label); (isRef && len(a.tail) > 0) || (!repeats(e.to) && !(stopAtGroup && grouped(e.to)) && c.reads(e.to, stopAtGroup)) {
|
if a, isRef := refRead(n, e.label); (isRef && (len(a.tail) > 0 || readsTable(n, a))) || (!repeats(e.to) && !(stopAtGroup && grouped(e.to)) && c.reads(e.to, stopAtGroup)) {
|
||||||
r = true
|
r = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -208,6 +254,17 @@ func grouped(n node) bool {
|
|||||||
return isTemplate && t.drawGroupKey != ""
|
return isTemplate && t.drawGroupKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readsTable reports whether a reference of n names a table, whose draw the render's
|
||||||
|
// group answers for even when read whole.
|
||||||
|
func readsTable(n node, a arm) bool {
|
||||||
|
t, isTemplate := n.(*template)
|
||||||
|
if !isTemplate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, isTable := t.fields[a.key].(*table)
|
||||||
|
return isTable
|
||||||
|
}
|
||||||
|
|
||||||
// refRead is the reference an edge of n reads; false when the edge reads none.
|
// refRead is the reference an edge of n reads; false when the edge reads none.
|
||||||
func refRead(n node, label string) (arm, bool) {
|
func refRead(n node, label string) (arm, bool) {
|
||||||
t, isTemplate := n.(*template)
|
t, isTemplate := n.(*template)
|
||||||
@@ -234,10 +291,55 @@ type drawWalk struct {
|
|||||||
seen map[drawVisit]bool
|
seen map[drawVisit]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawAt is where a walk stands: the draw group it draws in, and how the render's root reached it.
|
// drawAt is where a walk stands: the draw group it draws in, how the render's root reached it, and
|
||||||
|
// the table rows it entered.
|
||||||
type drawAt struct {
|
type drawAt struct {
|
||||||
group string
|
group string
|
||||||
route drawRoute
|
route drawRoute
|
||||||
|
alt rowSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowSet is the rows a walk entered, one per table: only one row of a table renders, so reads in
|
||||||
|
// two rows of one table never meet, while reads in one row, across its columns and whatever they
|
||||||
|
// reach, do.
|
||||||
|
type rowSet []tablePin
|
||||||
|
|
||||||
|
func (s rowSet) rowOf(t *table) (int, bool) {
|
||||||
|
for _, p := range s {
|
||||||
|
if p.t == t {
|
||||||
|
return p.row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// enter is s with row r of t, where t is not in it yet; the set is copied, since walks branch.
|
||||||
|
func (s rowSet) enter(t *table, r int) rowSet {
|
||||||
|
if _, in := s.rowOf(t); in {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
out := append(append(make(rowSet, 0, len(s)+1), s...), tablePin{t, r})
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].t.category < out[j].t.category })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// key spells the set for a map, by the tables' identities.
|
||||||
|
func (s rowSet) key() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range s {
|
||||||
|
fmt.Fprintf(&b, "%p[%d]", p.t, p.row)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// alternatives reports whether two reads sit in different rows of one table.
|
||||||
|
func alternatives(a, b drawAt) bool {
|
||||||
|
for _, p := range a.alt {
|
||||||
|
if r, in := b.alt.rowOf(p.t); in && r != p.row {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label.
|
// drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label.
|
||||||
@@ -247,16 +349,19 @@ type drawRoute struct{ spelling, label string }
|
|||||||
type pathRead struct {
|
type pathRead struct {
|
||||||
at drawAt
|
at drawAt
|
||||||
a arm
|
a arm
|
||||||
|
tr *tableRead // set where the reference names a table
|
||||||
}
|
}
|
||||||
|
|
||||||
type drawVisit struct {
|
type drawVisit struct {
|
||||||
n node
|
n node
|
||||||
group string
|
group string
|
||||||
|
alt string
|
||||||
}
|
}
|
||||||
|
|
||||||
type drawKey struct {
|
type drawKey struct {
|
||||||
group string
|
group string
|
||||||
path string
|
path string
|
||||||
|
alt string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDrawWalk() *drawWalk {
|
func newDrawWalk() *drawWalk {
|
||||||
@@ -266,7 +371,7 @@ func newDrawWalk() *drawWalk {
|
|||||||
// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
|
// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
|
||||||
// there.
|
// there.
|
||||||
func (w *drawWalk) walk(n node, at drawAt) {
|
func (w *drawWalk) walk(n node, at drawAt) {
|
||||||
v := drawVisit{n, at.group}
|
v := drawVisit{n, at.group, at.alt.key()}
|
||||||
if w.seen[v] {
|
if w.seen[v] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -278,23 +383,50 @@ func (w *drawWalk) walk(n node, at drawAt) {
|
|||||||
at.group = t.drawGroupKey
|
at.group = t.drawGroupKey
|
||||||
}
|
}
|
||||||
for _, e := range renderEdges(n) {
|
for _, e := range renderEdges(n) {
|
||||||
|
if cell, isCell := e.to.(*template); isCell && cell.cellOf != nil {
|
||||||
|
if at.alt.excludes(cell) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.edge(n, e, drawAt{at.group, at.route, at.alt.enter(cell.cellOf, cell.cellRow)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
w.edge(n, e, at)
|
w.edge(n, e, at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excludes reports whether a cell's row cannot render with the rows entered: another
|
||||||
|
// row of its table, or a row outside an entered ancestor's.
|
||||||
|
func (s rowSet) excludes(cell *template) bool {
|
||||||
|
for _, p := range s {
|
||||||
|
if p.t == cell.cellOf {
|
||||||
|
return p.row != cell.cellRow
|
||||||
|
}
|
||||||
|
if cell.cellOf.descends(p.t) && !cell.cellOf.under(cell.cellRow, p.t, p.row) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge records the reference an edge reads, then walks on with every row the read
|
||||||
|
// pins entered, so a selected row renders only its own cells.
|
||||||
func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
|
func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
|
||||||
if a, reads := refRead(from, e.label); reads {
|
if a, reads := refRead(from, e.label); reads {
|
||||||
if k := (drawKey{at.group, a.path}); !w.read[k] {
|
tr := tableReadOf(from.(*template).fields[a.key], a, e.to)
|
||||||
|
if k := (drawKey{at.group, a.path, at.alt.key()}); !w.read[k] {
|
||||||
w.read[k] = true
|
w.read[k] = true
|
||||||
w.reads = append(w.reads, pathRead{at, a})
|
w.reads = append(w.reads, pathRead{at, a, tr})
|
||||||
|
}
|
||||||
|
if tr != nil {
|
||||||
|
tr.pins.each(func(t *table, r int) { at.alt = at.alt.enter(t, r) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.walk(e.to, at)
|
w.walk(e.to, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check refuses what one draw per reference path cannot answer for: a read of a level beside a path
|
// check refuses what one draw per reference path cannot answer for: a read of a level beside a path
|
||||||
// another read takes into it. Reads are compared in path order, so which pair is reported does not
|
// another read takes into it, and two reads of one table family that select different rows. Reads
|
||||||
// vary.
|
// are compared in path order, so which pair is reported does not vary.
|
||||||
func (w *drawWalk) check() error {
|
func (w *drawWalk) check() error {
|
||||||
sort.SliceStable(w.reads, func(i, j int) bool {
|
sort.SliceStable(w.reads, func(i, j int) bool {
|
||||||
if w.reads[i].at.group != w.reads[j].at.group {
|
if w.reads[i].at.group != w.reads[j].at.group {
|
||||||
@@ -304,12 +436,15 @@ func (w *drawWalk) check() error {
|
|||||||
})
|
})
|
||||||
for i, level := range w.reads {
|
for i, level := range w.reads {
|
||||||
for _, into := range w.reads[i+1:] {
|
for _, into := range w.reads[i+1:] {
|
||||||
if into.at.group == level.at.group && strings.HasPrefix(into.a.path, level.a.path+".") {
|
if into.at.group != level.at.group || alternatives(level.at, into.at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(into.a.path, level.a.path+".") && !(level.tr != nil && level.tr.whole) {
|
||||||
return overlapError(level.at.route, level.a.name, into)
|
return overlapError(level.at.route, level.a.name, into)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return checkFamilies(w.reads)
|
||||||
}
|
}
|
||||||
|
|
||||||
func overlapError(route drawRoute, ref string, into pathRead) error {
|
func overlapError(route drawRoute, ref string, into pathRead) error {
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package fejkdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tablePin is one table's pinned row.
|
||||||
|
type tablePin struct {
|
||||||
|
t *table
|
||||||
|
row int
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinned is the row the render pinned for t, if any.
|
||||||
|
func (d *draws) pinned(t *table) (int, bool) {
|
||||||
|
for _, p := range d.pins[:d.npins] {
|
||||||
|
if p.t == t {
|
||||||
|
return p.row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, ok := d.more[t]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRow is the row pinned for t, which the walk reaching a column pinned.
|
||||||
|
func (d *draws) mustRow(t *table) int {
|
||||||
|
r, ok := d.pinned(t)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("fejkdata: a column of %s is rendered with no row pinned", t.category))
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// pin pins row r of t, and the rows of t's ancestors it links to.
|
||||||
|
func (d *draws) pin(t *table, r int) {
|
||||||
|
for {
|
||||||
|
if _, done := d.pinned(t); done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.npins < len(d.pins) {
|
||||||
|
d.pins[d.npins] = tablePin{t, r}
|
||||||
|
d.npins++
|
||||||
|
} else {
|
||||||
|
if d.more == nil {
|
||||||
|
d.more = map[*table]int{}
|
||||||
|
}
|
||||||
|
d.more[t] = r
|
||||||
|
}
|
||||||
|
if t.parentT == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, r = t.parentT, t.parentRow(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// each calls fn for every pinned row, in pin order, the spilled ones by table name.
|
||||||
|
func (d *draws) each(fn func(t *table, r int)) {
|
||||||
|
for _, p := range d.pins[:d.npins] {
|
||||||
|
fn(p.t, p.row)
|
||||||
|
}
|
||||||
|
spilled := make([]*table, 0, len(d.more))
|
||||||
|
for t := range d.more {
|
||||||
|
spilled = append(spilled, t)
|
||||||
|
}
|
||||||
|
sort.Slice(spilled, func(i, j int) bool { return spilled[i].category < spilled[j].category })
|
||||||
|
for _, t := range spilled {
|
||||||
|
fn(t, d.more[t])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowOf is the render's row of t: the one pinned, else one drawn inside the
|
||||||
|
// nearest pinned ancestor — its parent drawn inside that first where the ancestor
|
||||||
|
// is further up — or over the whole table, and pinned with its ancestors.
|
||||||
|
func (d *draws) rowOf(t *table) int {
|
||||||
|
if r, ok := d.pinned(t); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r := -1
|
||||||
|
for a := t.parentT; a != nil && r < 0; a = a.parentT {
|
||||||
|
if _, ok := d.pinned(a); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.parentT != a {
|
||||||
|
d.rowOf(t.parentT)
|
||||||
|
}
|
||||||
|
pr, _ := d.pinned(t.parentT)
|
||||||
|
r = t.drawUnder(d.s, pr)
|
||||||
|
}
|
||||||
|
if r < 0 {
|
||||||
|
r = t.draw(d.s)
|
||||||
|
}
|
||||||
|
d.pin(t, r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinRow pins row r of t where it agrees with the rows pinned before it.
|
||||||
|
func (d *draws) pinRow(t *table, r int) error {
|
||||||
|
if pr, ok := d.pinned(t); ok && pr != r {
|
||||||
|
return fmt.Errorf("%s and %s are two rows of %s", t.selectorSpelling(pr), t.selectorSpelling(r), t.category)
|
||||||
|
}
|
||||||
|
for a := t.parentT; a != nil; a = a.parentT {
|
||||||
|
if pa, ok := d.pinned(a); ok && !t.under(r, a, pa) {
|
||||||
|
return fmt.Errorf("%s is not inside %s", t.selectorSpelling(r), a.selectorSpelling(pa))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.pin(t, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectRow pins the row a selector names.
|
||||||
|
func (d *draws) selectRow(t *table, sel string) error {
|
||||||
|
r, err := t.find(sel, d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return d.pinRow(t, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside keeps the rows of t that sit inside every pinned ancestor.
|
||||||
|
func (d *draws) inside(t *table, rows []int) []int {
|
||||||
|
for a := t.parentT; a != nil; a = a.parentT {
|
||||||
|
pa, ok := d.pinned(a)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kept []int
|
||||||
|
for _, r := range rows {
|
||||||
|
if t.under(r, a, pa) {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = kept
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableRead is what a reference path reads of a table family: the table its head
|
||||||
|
// names, the rows its selectors pin, the tables it draws — those it walks with no
|
||||||
|
// row pinned, and their unpinned ancestors — each selector's spelling, and whether
|
||||||
|
// it lands on a row rendered whole.
|
||||||
|
type tableRead struct {
|
||||||
|
head *table
|
||||||
|
pins draws
|
||||||
|
drawn map[*table]bool
|
||||||
|
sels []tableSel
|
||||||
|
whole bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableSel is one selector on the way: the table it selects a row of, and the path
|
||||||
|
// as written up to and including it.
|
||||||
|
type tableSel struct {
|
||||||
|
t *table
|
||||||
|
spelling string
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableReadOf replays a reference's selectors through the walk a render uses, so
|
||||||
|
// two paths pinning one row by different routes compare equal. checkPath proved
|
||||||
|
// each selector names a row.
|
||||||
|
func tableReadOf(head node, a arm, leaf node) *tableRead {
|
||||||
|
t, isTable := head.(*table)
|
||||||
|
if !isTable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr := &tableRead{head: t, drawn: map[*table]bool{}}
|
||||||
|
_, _ = walkPath(t, a.tail, pathWalk{pins: &tr.pins})
|
||||||
|
written := a.name[:len(a.name)-len(joinSegments(a.tail))]
|
||||||
|
cur := t
|
||||||
|
tr.draws(cur)
|
||||||
|
for i, seg := range a.tail {
|
||||||
|
switch d := cur.descendant(seg); {
|
||||||
|
case isSelector(seg):
|
||||||
|
tr.sels = append(tr.sels, tableSel{cur, written + joinSegments(a.tail[:i+1])})
|
||||||
|
case d != nil:
|
||||||
|
cur = d
|
||||||
|
tr.draws(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, tr.whole = leaf.(*row)
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// draws marks t and its ancestors drawn, up to the first the read pins.
|
||||||
|
func (r *tableRead) draws(t *table) {
|
||||||
|
for ; t != nil; t = t.parentT {
|
||||||
|
if _, pinned := r.pins.pinned(t); pinned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.drawn[t] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinSegments spells segments as a path: a selector attaches to the name before it.
|
||||||
|
func joinSegments(segs []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, s := range segs {
|
||||||
|
if b.Len() > 0 && !isSelector(s) {
|
||||||
|
b.WriteByte('.')
|
||||||
|
}
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// family is the table a chain of parents ends at.
|
||||||
|
func (t *table) family() *table {
|
||||||
|
for t.parentT != nil {
|
||||||
|
t = t.parentT
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// selected is the selector in r on t, or on the nearest ancestor of t it selects.
|
||||||
|
func (r *tableRead) selected(t *table) (tableSel, bool) {
|
||||||
|
for ; t != nil; t = t.parentT {
|
||||||
|
for _, s := range r.sels {
|
||||||
|
if s.t == t {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tableSel{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFamilies refuses reads of one table family in one draw group that cannot
|
||||||
|
// read one consistent draw: a table rendered whole beside a path into the family,
|
||||||
|
// a table one read draws that another pins, and two reads pinning different rows.
|
||||||
|
// The reads come sorted by group and path, so which pair is reported does not vary.
|
||||||
|
func checkFamilies(reads []pathRead) error {
|
||||||
|
for i, r := range reads {
|
||||||
|
if r.tr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range reads[:i] {
|
||||||
|
if o.tr == nil || o.at.group != r.at.group || alternatives(o.at, r.at) || o.tr.head.family() != r.tr.head.family() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := checkFamilyPair(o, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return replayPairs(reads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayPairs replays every two reads of one draw group that can render together into
|
||||||
|
// one draws, the earlier read first. A pin conflicts with one earlier pin, never with a
|
||||||
|
// combination, so pairs find every conflict a full replay would.
|
||||||
|
func replayPairs(reads []pathRead) error {
|
||||||
|
for i, r := range reads {
|
||||||
|
if r.tr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range reads[i+1:] {
|
||||||
|
if o.tr == nil || o.at.group != r.at.group || alternatives(r.at, o.at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var d draws
|
||||||
|
if err := r.tr.replay(&d); err != nil {
|
||||||
|
return conflict(r, err)
|
||||||
|
}
|
||||||
|
if err := o.tr.replay(&d); err != nil {
|
||||||
|
return conflict(o, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func conflict(r pathRead, err error) error {
|
||||||
|
return fmt.Errorf("%s: %w; select the same rows in every path into the family, or draw them apart with a drawGroup", r.at.route.spelled(r.a.name), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replay pins the read's rows into d, where they agree with the rows pinned before.
|
||||||
|
func (r *tableRead) replay(d *draws) error {
|
||||||
|
var err error
|
||||||
|
r.pins.each(func(t *table, row int) {
|
||||||
|
if err == nil {
|
||||||
|
err = d.pinRow(t, row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFamilyPair refuses a table read whole beside a path into its family, and a
|
||||||
|
// table one read draws that the other pins, since which token renders first would
|
||||||
|
// then decide the row.
|
||||||
|
func checkFamilyPair(a, b pathRead) error {
|
||||||
|
for _, pair := range [][2]pathRead{{a, b}, {b, a}} {
|
||||||
|
x, y := pair[0], pair[1]
|
||||||
|
if len(x.a.tail) == 0 && len(y.a.tail) > 0 {
|
||||||
|
return overlapError(x.at.route, x.a.name, y)
|
||||||
|
}
|
||||||
|
if drawn := x.tr.drawnOf(&y.tr.pins); drawn != nil {
|
||||||
|
if s, ok := y.tr.selected(x.tr.head); ok && len(x.tr.sels) == 0 {
|
||||||
|
tail := x.a.tail
|
||||||
|
if s.t != x.tr.head {
|
||||||
|
tail = append([]string{x.tr.head.category}, tail...)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s draws %s, which %s selects a row of; write {%s.%s}, or draw them apart with a drawGroup",
|
||||||
|
x.at.route.spelled(x.a.name), drawn.category, y.at.route.spelled(y.a.name), s.spelling, joinSegments(tail))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s draws %s, which %s selects a row of; select that row in both, or draw them apart with a drawGroup",
|
||||||
|
x.at.route.spelled(x.a.name), drawn.category, y.at.route.spelled(y.a.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawnOf is a table the read draws that pins holds a row of, if any.
|
||||||
|
func (r *tableRead) drawnOf(pins *draws) *table {
|
||||||
|
var found *table
|
||||||
|
pins.each(func(t *table, _ int) {
|
||||||
|
if found == nil && r.drawn[t] {
|
||||||
|
found = t
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
+24
@@ -46,6 +46,8 @@ type Generator struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
rand *session
|
rand *session
|
||||||
categories map[string]node
|
categories map[string]node
|
||||||
|
root folder // the categories as the node a path walks from, owned here so a walk allocates none
|
||||||
|
set drawSet // one Fake's draws, owned here so a walk pinning rows keeps them off the heap
|
||||||
records map[node]recordShape
|
records map[node]recordShape
|
||||||
structs map[reflect.Type]structResult
|
structs map[reflect.Type]structResult
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,10 @@ func paths(n node) []string {
|
|||||||
return out
|
return out
|
||||||
case *null:
|
case *null:
|
||||||
return []string{""}
|
return []string{""}
|
||||||
|
case *table:
|
||||||
|
return tablePaths(n)
|
||||||
|
case *column, *row:
|
||||||
|
return []string{""}
|
||||||
case *choice:
|
case *choice:
|
||||||
out := []string{""}
|
out := []string{""}
|
||||||
for p := range n.shared {
|
for p := range n.shared {
|
||||||
@@ -176,6 +182,24 @@ func paths(n node) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tablePaths is a table's columns, then each table linked to it under its name: the
|
||||||
|
// direct descents, a step at a time.
|
||||||
|
func tablePaths(t *table) []string {
|
||||||
|
out := append([]string{""}, t.columns...)
|
||||||
|
sort.Strings(out[1:])
|
||||||
|
children := make([]string, 0, len(t.children))
|
||||||
|
for name := range t.children {
|
||||||
|
children = append(children, name)
|
||||||
|
}
|
||||||
|
sort.Strings(children)
|
||||||
|
for _, name := range children {
|
||||||
|
for _, p := range paths(t.children[name]) {
|
||||||
|
out = append(out, join(name, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// sharedPaths is the sub-paths every item carries — the only ones a path may step
|
// sharedPaths is the sub-paths every item carries — the only ones a path may step
|
||||||
// through a choice to reach. It intersects, bailing as soon as the set
|
// through a choice to reach. It intersects, bailing as soon as the set
|
||||||
// is empty, which is immediate for a choice of plain strings.
|
// is empty, which is immediate for a choice of plain strings.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package fejkdata
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// walkNodes calls fn once per contained node, passing the dot path that reaches it,
|
// walkNodes calls fn once per contained node, passing the dot path that reaches it,
|
||||||
@@ -61,6 +60,16 @@ func contained(n node) []namedNode {
|
|||||||
return out
|
return out
|
||||||
case *template:
|
case *template:
|
||||||
return named(n.fields)
|
return named(n.fields)
|
||||||
|
case *table:
|
||||||
|
return append([]namedNode{{node: n.format}}, named(n.fields)...)
|
||||||
|
case *column:
|
||||||
|
var out []namedNode
|
||||||
|
for r := 0; r < n.t.rows(); r++ {
|
||||||
|
if cell := n.t.cellNode(r, n.i); cell != nil {
|
||||||
|
out = append(out, namedNode{node: cell})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -142,12 +151,22 @@ func renderEdges(n node) []renderEdge {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, name := range strings.Split(t.body, "|") {
|
for _, name := range splitOutside(t.body, '|') {
|
||||||
add(name, "")
|
add(name, "")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return es
|
return es
|
||||||
|
case *table:
|
||||||
|
return []renderEdge{{to: n.format, label: "format"}}
|
||||||
|
case *row:
|
||||||
|
return []renderEdge{{to: n.t.format, label: "format"}}
|
||||||
|
case *column:
|
||||||
|
var es []renderEdge
|
||||||
|
for _, c := range contained(n) {
|
||||||
|
es = append(es, renderEdge{to: c.node, label: n.t.columns[n.i]})
|
||||||
|
}
|
||||||
|
return es
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -158,7 +177,7 @@ func renderEdges(n node) []renderEdge {
|
|||||||
// already proved the tail resolves in every variant.
|
// already proved the tail resolves in every variant.
|
||||||
func pathLeaves(n node, tail []string) []node {
|
func pathLeaves(n node, tail []string) []node {
|
||||||
var out []node
|
var out []node
|
||||||
_ = walkPath(n, tail, pathWalk{
|
_, _ = walkPath(n, tail, pathWalk{
|
||||||
choice: func(c *choice, _ []string) ([]node, error) { return c.items, nil },
|
choice: func(c *choice, _ []string) ([]node, error) { return c.items, nil },
|
||||||
leaf: func(n node) error { out = append(out, n); return nil },
|
leaf: func(n node) error { out = append(out, n); return nil },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func operandReader(t *template, head string) string {
|
|||||||
// coverPath collects what holding one path pins: every choice level the path
|
// coverPath collects what holding one path pins: every choice level the path
|
||||||
// passes through, whole, and the leaf it renders.
|
// passes through, whole, and the leaf it renders.
|
||||||
func coverPath(n node, tail []string, into map[node]bool) {
|
func coverPath(n node, tail []string, into map[node]bool) {
|
||||||
_ = walkPath(n, tail, pathWalk{
|
_, _ = walkPath(n, tail, pathWalk{
|
||||||
choice: func(c *choice, _ []string) ([]node, error) { cover(c, into, false); return nil, nil },
|
choice: func(c *choice, _ []string) ([]node, error) { cover(c, into, false); return nil, nil },
|
||||||
leaf: func(n node) error { cover(n, into, false); return nil },
|
leaf: func(n node) error { cover(n, into, false); return nil },
|
||||||
})
|
})
|
||||||
@@ -262,6 +262,10 @@ func checkNoRepeatedRead(format string, c formatOps, refs map[string]refBinding)
|
|||||||
type draws struct {
|
type draws struct {
|
||||||
variant map[string]node
|
variant map[string]node
|
||||||
value map[string]draw
|
value map[string]draw
|
||||||
|
pins [8]tablePin // the rows pinned, inline so a render over a country's five-deep geo tree stays off the heap
|
||||||
|
npins int
|
||||||
|
more map[*table]int // the rows pinned past the inline eight
|
||||||
|
s *session // what draws a row; nil where a walk only proves selectors
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw is what one read drew: its text, and whether it landed on a null.
|
// draw is what one read drew: its text, and whether it landed on a null.
|
||||||
@@ -284,12 +288,11 @@ func readField(s *session, t *template, held *draws, sc drawScope, a arm) draw {
|
|||||||
}
|
}
|
||||||
return draw{text: render(s, t.fields[a.key], sc)}
|
return draw{text: render(s, t.fields[a.key], sc)}
|
||||||
}
|
}
|
||||||
d := readScope(held, sc, a)
|
d := readScope(s, held, sc, a)
|
||||||
if r, done := d.value[a.path]; done {
|
if r, done := d.value[a.path]; done {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
var r draw
|
leaf, err := walkPath(t.fields[a.key], a.tail, pathWalk{
|
||||||
_ = walkPath(t.fields[a.key], a.tail, pathWalk{
|
|
||||||
// Hold the draw at every level passed through, so two paths sharing a
|
// Hold the draw at every level passed through, so two paths sharing a
|
||||||
// prefix share it.
|
// prefix share it.
|
||||||
choice: func(c *choice, rest []string) ([]node, error) {
|
choice: func(c *choice, rest []string) ([]node, error) {
|
||||||
@@ -307,8 +310,12 @@ func readField(s *session, t *template, held *draws, sc drawScope, a arm) draw {
|
|||||||
}
|
}
|
||||||
return []node{n}, nil
|
return []node{n}, nil
|
||||||
},
|
},
|
||||||
leaf: func(n node) error { r = renderLeaf(s, n, sc); return nil },
|
pins: d,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("fejkdata: %q: %v; a fence should have refused this at New", a.name, err))
|
||||||
|
}
|
||||||
|
r := renderLeaf(s, leaf, sc)
|
||||||
if d.value == nil {
|
if d.value == nil {
|
||||||
d.value = map[string]draw{}
|
d.value = map[string]draw{}
|
||||||
}
|
}
|
||||||
@@ -319,9 +326,9 @@ func readField(s *session, t *template, held *draws, sc drawScope, a arm) draw {
|
|||||||
// readScope is the draws a held read keeps its draw in: for a reference that reads a path,
|
// readScope is the draws a held read keeps its draw in: for a reference that reads a path,
|
||||||
// the render's draws for its group, so its draw spans the render; for a sibling, or a
|
// the render's draws for its group, so its draw spans the render; for a sibling, or a
|
||||||
// reference read whole, held.
|
// reference read whole, held.
|
||||||
func readScope(held *draws, sc drawScope, a arm) *draws {
|
func readScope(s *session, held *draws, sc drawScope, a arm) *draws {
|
||||||
if isRef(a.key) && len(a.tail) > 0 {
|
if isRef(a.key) && len(a.tail) > 0 {
|
||||||
return sc.draws()
|
return sc.draws(s)
|
||||||
}
|
}
|
||||||
return held
|
return held
|
||||||
}
|
}
|
||||||
@@ -335,7 +342,7 @@ func renderLeaf(s *session, n node, sc drawScope) draw {
|
|||||||
}
|
}
|
||||||
r := draw{text: render(s, n, sc)}
|
r := draw{text: render(s, n, sc)}
|
||||||
if t, _ := n.(*template); t != nil && t.readsColumn != nil {
|
if t, _ := n.(*template); t != nil && t.readsColumn != nil {
|
||||||
r.null = sc.in(t).draws().value[t.readsColumn.a.path].null
|
r.null = sc.in(t).draws(s).value[t.readsColumn.a.path].null
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,9 +69,15 @@ func isTemplate(arg string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if i := strings.IndexAny(arg, `[]}"`); i >= 0 {
|
if i := strings.IndexAny(arg, `}"`); i >= 0 {
|
||||||
return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
|
return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(arg), "[") {
|
||||||
|
return false, fmt.Errorf(`%q starts with "[" and is not valid JSON, so it names no template; a path starts with a name`, arg)
|
||||||
|
}
|
||||||
|
if _, err := splitPath(arg); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
if path := strings.TrimLeft(arg, "/"); path != arg && path != "" {
|
if path := strings.TrimLeft(arg, "/"); path != arg && path != "" {
|
||||||
return false, pathAdvice(path, fmt.Sprintf("path %s starts with /, and every path starts at the root already", arg))
|
return false, pathAdvice(path, fmt.Sprintf("path %s starts with /, and every path starts at the root already", arg))
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -55,7 +55,7 @@ func TestFakeTemplateJSONArray(t *testing.T) {
|
|||||||
|
|
||||||
func TestFakeTemplateCorrelatedReferences(t *testing.T) {
|
func TestFakeTemplateCorrelatedReferences(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
|
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1990"}]`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
@@ -166,15 +166,19 @@ func TestIsTemplate(t *testing.T) {
|
|||||||
"{uppercase(/a)}": true,
|
"{uppercase(/a)}": true,
|
||||||
"{{/a}}": true,
|
"{{/a}}": true,
|
||||||
`"{/a} x"`: true,
|
`"{/a} x"`: true,
|
||||||
|
"x[1]": false, // a row selected by key or name
|
||||||
|
"x[St. Louis].y": false,
|
||||||
} {
|
} {
|
||||||
if got, err := IsTemplate(arg); err != nil || got != want {
|
if got, err := IsTemplate(arg); err != nil || got != want {
|
||||||
t.Errorf("IsTemplate(%q) = %v, %v; want %v", arg, got, err, want)
|
t.Errorf("IsTemplate(%q) = %v, %v; want %v", arg, got, err, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for arg, want := range map[string]string{
|
for arg, want := range map[string]string{
|
||||||
"[abc]": `holds a "["`,
|
"[abc]": `starts with "["`,
|
||||||
"[abc].field": `holds a "["`,
|
"[abc].field": `starts with "["`,
|
||||||
"x[1]": `holds a "["`,
|
"x[1]y": `"]"`,
|
||||||
|
"x[[1]]": `"["`,
|
||||||
|
"x[]": "empty",
|
||||||
"a]b": `holds a "]"`,
|
"a]b": `holds a "]"`,
|
||||||
"a}b": `holds a "}"`,
|
"a}b": `holds a "}"`,
|
||||||
`"abc`: `holds a "\""`,
|
`"abc`: `holds a "\""`,
|
||||||
|
|||||||
+1
-1
@@ -154,7 +154,7 @@ func TestListedPathsAllRender(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range []string{"misc.car.maker", "misc.country.alpha2", "misc.currency.symbol", "misc.httpstatus.code", "misc.mimetype.ext"} {
|
for _, p := range []string{"misc.car.maker", "misc.country.alpha2", "misc.country.numeric", "misc.currency.symbol", "misc.httpstatus.code", "misc.mimetype.ext"} {
|
||||||
if !slices.Contains(paths, p) {
|
if !slices.Contains(paths, p) {
|
||||||
t.Errorf("List() omits %q, which the README advertises and Fake renders", p)
|
t.Errorf("List() omits %q, which the README advertises and Fake renders", p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -59,9 +60,13 @@ type template struct {
|
|||||||
// held is every name drawn once per expansion: the bound levels above, plus the
|
// held is every name drawn once per expansion: the bound levels above, plus the
|
||||||
// siblings a {calc()} reads. nil when the format holds nothing (see expand).
|
// siblings a {calc()} reads. nil when the format holds nothing (see expand).
|
||||||
held map[string]bool
|
held map[string]bool
|
||||||
|
heldLocal bool // some held name is kept by the expansion itself, so expand makes its draws
|
||||||
fromString bool // written as a JSON string rather than an object
|
fromString bool // written as a JSON string rather than an object
|
||||||
readsColumn *columnRead // set when the format is one reference alone reading a record's column
|
readsColumn *columnRead // set when the format is one reference alone reading a record's column
|
||||||
record bool // compiled at the top without a repeat, so its fields are record columns
|
record bool // compiled at the top without a repeat, so its fields are record columns
|
||||||
|
table *table // the table whose format this is, whose columns are the fields
|
||||||
|
cellOf *table // the table whose cell this is
|
||||||
|
cellRow int // the row the cell sits in
|
||||||
drawGroup string // the draw group it draws in, as written; "" keeps its caller's
|
drawGroup string // the draw group it draws in, as written; "" keeps its caller's
|
||||||
drawGroupKey string // its draw group keyed by its category once linked: what a render reads its reference paths under
|
drawGroupKey string // its draw group keyed by its category once linked: what a render reads its reference paths under
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,70 @@ func compile(v any) (node, error) {
|
|||||||
return compileAt(v, atTop)
|
return compileAt(v, atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compileCategory compiles a data file's value, which may be a table over a rows
|
||||||
|
// file beside it, and refuses a choice that is a table written as templates.
|
||||||
|
func compileCategory(v any, name string, files *categoryFiles) (node, error) {
|
||||||
|
if m, ok := v.(map[string]any); ok {
|
||||||
|
if _, isTable := m["rows"]; isTable {
|
||||||
|
return compileTable(m, name, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items, ok := v.([]any); ok {
|
||||||
|
if err := checkNotRows(items, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compile(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNotRows refuses a choice of templates sharing one format and one set of
|
||||||
|
// string fields: each item is a row, and the rows file is the spelling for that.
|
||||||
|
func checkNotRows(items []any, name string) error {
|
||||||
|
var format string
|
||||||
|
var fields []string
|
||||||
|
weighted := false
|
||||||
|
for i, raw := range items {
|
||||||
|
f, keys, w, isRow := rowShape(raw)
|
||||||
|
if !isRow || i > 0 && (f != format || !slices.Equal(keys, fields)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
format, fields, weighted = f, keys, weighted || w
|
||||||
|
}
|
||||||
|
if len(items) < 2 || len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
weight := ""
|
||||||
|
if weighted {
|
||||||
|
weight = `, a weight column, and "weight" naming it`
|
||||||
|
}
|
||||||
|
return fmt.Errorf("a choice of %d templates with one format and the fields %v is a table; write the rows in %s.tsv with the header %q%s, and the category as {\"format\": %q, \"rows\": \"%s.tsv\"}",
|
||||||
|
len(items), fields, name, strings.Join(fields, "\t"), weight, format, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowShape reads a choice item as a row: a template whose every field is a string,
|
||||||
|
// with its format, its sorted field names, and whether it carries a weight.
|
||||||
|
func rowShape(raw any) (format string, fields []string, weighted, isRow bool) {
|
||||||
|
m, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", nil, false, false
|
||||||
|
}
|
||||||
|
format, _ = m["format"].(string)
|
||||||
|
for k, v := range m {
|
||||||
|
if k == "weight" {
|
||||||
|
weighted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, isString := v.(string); !isString || isOption(k) && k != "format" {
|
||||||
|
return "", nil, false, false
|
||||||
|
}
|
||||||
|
if k != "format" {
|
||||||
|
fields = append(fields, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
return format, fields, weighted, true
|
||||||
|
}
|
||||||
|
|
||||||
// compileAt compiles a node that is no choice's item. Only a choice's items carry a
|
// compileAt compiles a node that is no choice's item. Only a choice's items carry a
|
||||||
// weight, so one here would be inert whatever its type.
|
// weight, so one here would be inert whatever its type.
|
||||||
func compileAt(v any, pos position) (node, error) {
|
func compileAt(v any, pos position) (node, error) {
|
||||||
@@ -140,7 +209,7 @@ func compileString(s string) (node, error) {
|
|||||||
// applies the fences that need the compiled reads.
|
// applies the fences that need the compiled reads.
|
||||||
func (t *template) compileFormat() error {
|
func (t *template) compileFormat() error {
|
||||||
c := compileOps(t.format, t.refs)
|
c := compileOps(t.format, t.refs)
|
||||||
t.ops, t.grow, t.bound, t.held = c.ops, c.grow, c.bound, c.held
|
t.ops, t.grow, t.bound, t.held, t.heldLocal = c.ops, c.grow, c.bound, c.held, c.heldLocal
|
||||||
t.fixed = true
|
t.fixed = true
|
||||||
for _, o := range t.ops {
|
for _, o := range t.ops {
|
||||||
if o.kind != 'l' {
|
if o.kind != 'l' {
|
||||||
@@ -231,6 +300,9 @@ func checkNoRepeatedItem(items []any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func compileTemplate(m map[string]any, pos position) (node, error) {
|
func compileTemplate(m map[string]any, pos position) (node, error) {
|
||||||
|
if _, isTable := m["rows"]; isTable {
|
||||||
|
return nil, fmt.Errorf("rows names a TSV beside a category's file, so only a category is a table; an inline template has no file beside it")
|
||||||
|
}
|
||||||
o, err := readOptions(m, pos)
|
o, err := readOptions(m, pos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -407,7 +479,11 @@ func checkName(name string) error {
|
|||||||
|
|
||||||
// checkPathNames rejects a dotted path with a segment no name may be.
|
// checkPathNames rejects a dotted path with a segment no name may be.
|
||||||
func checkPathNames(path string) error {
|
func checkPathNames(path string) error {
|
||||||
for _, seg := range strings.Split(path, ".") {
|
segs, err := splitPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, seg := range names(segs) {
|
||||||
if err := checkName(seg); err != nil {
|
if err := checkName(seg); err != nil {
|
||||||
return fmt.Errorf("path %w", err)
|
return fmt.Errorf("path %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,62 +6,281 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// splitPath splits a dotted path into segments, a selector — [key or name] after a
|
||||||
|
// table's name — becoming a segment of its own, brackets kept. A dot inside a
|
||||||
|
// selector is part of the key or name.
|
||||||
|
func splitPath(path string) ([]string, error) {
|
||||||
|
segs := make([]string, 0, strings.Count(path, ".")+2*strings.Count(path, "[")+1)
|
||||||
|
start, open := 0, -1
|
||||||
|
for i := 0; i < len(path); i++ {
|
||||||
|
switch path[i] {
|
||||||
|
case '[':
|
||||||
|
if err := checkOpen(path, i, start, open); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
segs = append(segs, path[start:i])
|
||||||
|
start, open = i, i
|
||||||
|
case ']':
|
||||||
|
if err := checkClose(path, i, open); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
segs = append(segs, path[start:i+1])
|
||||||
|
start, open = i+2, -1
|
||||||
|
i++
|
||||||
|
case '.':
|
||||||
|
if open < 0 {
|
||||||
|
segs = append(segs, path[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open >= 0 {
|
||||||
|
return nil, fmt.Errorf(`%q opens a selector with "[" and never closes it with "]"`, path)
|
||||||
|
}
|
||||||
|
if start <= len(path) {
|
||||||
|
segs = append(segs, path[start:])
|
||||||
|
}
|
||||||
|
return segs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOpen refuses a "[" at i that opens no selector: one inside a selector, or one
|
||||||
|
// following no name.
|
||||||
|
func checkOpen(path string, i, start, open int) error {
|
||||||
|
switch {
|
||||||
|
case open >= 0:
|
||||||
|
return fmt.Errorf(`%q holds a "[" inside a selector, which no key or name may`, path)
|
||||||
|
case i == 0:
|
||||||
|
return fmt.Errorf(`%q starts with "["; a path starts with a name, and a selector follows a table's name`, path)
|
||||||
|
case i == start:
|
||||||
|
return fmt.Errorf(`%q: a selector follows its table's name; write %s`, path, path[:i-1]+path[i:])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkClose refuses a "]" at i that closes no selector, closes an empty one, or is
|
||||||
|
// followed by anything but a dot or the end.
|
||||||
|
func checkClose(path string, i, open int) error {
|
||||||
|
switch {
|
||||||
|
case open < 0:
|
||||||
|
return fmt.Errorf(`%q holds a "]" that closes no "["`, path)
|
||||||
|
case i == open+1:
|
||||||
|
return fmt.Errorf("%q holds an empty selector; a selector names a row by key or name", path)
|
||||||
|
case i+1 < len(path) && path[i+1] != '.':
|
||||||
|
return fmt.Errorf(`%q: a "]" ends its selector, so a dot or the end must follow it`, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexOutside is the first c in s outside a [selector], or -1.
|
||||||
|
func indexOutside(s string, c byte) int {
|
||||||
|
depth := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
switch {
|
||||||
|
case s[i] == '[':
|
||||||
|
depth++
|
||||||
|
case s[i] == ']' && depth > 0:
|
||||||
|
depth--
|
||||||
|
case s[i] == c && depth == 0:
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitOutside splits s on c outside any [selector].
|
||||||
|
func splitOutside(s string, c byte) []string {
|
||||||
|
var parts []string
|
||||||
|
for {
|
||||||
|
i := indexOutside(s, c)
|
||||||
|
if i < 0 {
|
||||||
|
return append(parts, s)
|
||||||
|
}
|
||||||
|
parts, s = append(parts, s[:i]), s[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSelector reports whether a segment is a [key or name] rather than a name.
|
||||||
|
func isSelector(seg string) bool { return strings.HasPrefix(seg, "[") }
|
||||||
|
|
||||||
|
func hasSelector(segs []string) bool {
|
||||||
|
for _, s := range segs {
|
||||||
|
if isSelector(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectorOf is the key or name a selector segment holds.
|
||||||
|
func selectorOf(seg string) string { return seg[1 : len(seg)-1] }
|
||||||
|
|
||||||
|
// names is the segments of a path that are names, its selectors left out.
|
||||||
|
func names(segs []string) []string {
|
||||||
|
out := segs[:0:0]
|
||||||
|
for _, s := range segs {
|
||||||
|
if !isSelector(s) {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// pathWalk is what one walk of a dotted path does at each kind of level: choice
|
// pathWalk is what one walk of a dotted path does at each kind of level: choice
|
||||||
// returns the variants to continue into (none stops the walk); level runs at each
|
// returns the variants to continue into (none stops the walk), and where it is nil
|
||||||
// template a segment descends into; leaf runs where the tail ends. A nil action
|
// the walk draws one with the pins' session, or stops; level runs at each template
|
||||||
// is skipped.
|
// a segment descends into; leaf runs where the tail ends; pins is where the walk
|
||||||
|
// pins the table rows it selects and draws, nil skipping tables. A nil action is
|
||||||
|
// skipped. A render's walk sets only pins, so it allocates nothing.
|
||||||
type pathWalk struct {
|
type pathWalk struct {
|
||||||
choice func(c *choice, rest []string) ([]node, error)
|
choice func(c *choice, rest []string) ([]node, error)
|
||||||
level func(t *template, rest []string) error
|
level func(t *template, rest []string) error
|
||||||
leaf func(n node) error
|
leaf func(n node) error
|
||||||
|
pins *draws
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkPath descends tail from n: a folder or template by its next segment, a
|
// walkPath descends tail from n and returns the node it ends at: a folder or
|
||||||
// choice by w.choice, which consumes no segment. A missing segment is an error,
|
// template by its next segment, a choice by w.choice, which consumes no segment, a
|
||||||
// so no walk reaches past what the data holds. A table-shaped dispatch, one case
|
// table by walkTable. A missing segment is an error, so no walk reaches past what
|
||||||
// per node kind, kept whole on purpose.
|
// the data holds.
|
||||||
func walkPath(n node, tail []string, w pathWalk) error {
|
func walkPath(n node, tail []string, w pathWalk) (node, error) {
|
||||||
if len(tail) == 0 {
|
if len(tail) == 0 {
|
||||||
if w.leaf != nil {
|
return n, w.atLeaf(n)
|
||||||
return w.leaf(n)
|
|
||||||
}
|
}
|
||||||
return nil
|
if t, isTable := n.(*table); isTable {
|
||||||
|
return walkTable(t, tail, w, false)
|
||||||
|
}
|
||||||
|
if isSelector(tail[0]) {
|
||||||
|
return nil, fmt.Errorf("%s is not a table, so it has no row to select", selectorOf(tail[0]))
|
||||||
}
|
}
|
||||||
switch n := n.(type) {
|
switch n := n.(type) {
|
||||||
case *folder:
|
case *folder:
|
||||||
child, ok := n.children[tail[0]]
|
child, ok := n.children[tail[0]]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no entry %q", tail[0])
|
return nil, fmt.Errorf("no entry %q", tail[0])
|
||||||
}
|
}
|
||||||
return walkPath(child, tail[1:], w)
|
return walkPath(child, tail[1:], w)
|
||||||
case *template:
|
case *template:
|
||||||
if w.level != nil {
|
if w.level != nil {
|
||||||
if err := w.level(n, tail); err != nil {
|
if err := w.level(n, tail); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
child, ok := n.field(tail[0])
|
child, ok := n.field(tail[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no field %q", tail[0])
|
return nil, fmt.Errorf("no field %q", tail[0])
|
||||||
}
|
}
|
||||||
return walkPath(child, tail[1:], w)
|
return walkPath(child, tail[1:], w)
|
||||||
case *choice:
|
case *choice:
|
||||||
|
return walkChoice(n, tail, w)
|
||||||
|
case *column:
|
||||||
|
return nil, fmt.Errorf("no field %q: %q is a column, and a cell holds no fields", tail[0], n.t.columns[n.i])
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no field %q", tail[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w pathWalk) atLeaf(n node) error {
|
||||||
|
if w.leaf != nil {
|
||||||
|
return w.leaf(n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkChoice continues a walk into the variants w.choice returns, or into one drawn
|
||||||
|
// by the pins' session where the walk names no choice action.
|
||||||
|
func walkChoice(c *choice, tail []string, w pathWalk) (node, error) {
|
||||||
if w.choice == nil {
|
if w.choice == nil {
|
||||||
return nil
|
if w.pins == nil {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
next, err := w.choice(n, tail)
|
if err := carriedByAll(c, tail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if w.pins.s == nil { // a probe: every variant carries the tail, so any one proves it
|
||||||
|
return walkPath(c.items[0], tail, w)
|
||||||
|
}
|
||||||
|
return walkPath(pick(w.pins.s, c), tail, w)
|
||||||
|
}
|
||||||
|
next, err := w.choice(c, tail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var last node
|
||||||
for _, item := range next {
|
for _, item := range next {
|
||||||
if err := walkPath(item, tail, w); err != nil {
|
if last, err = walkPath(item, tail, w); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkTable descends tail from a table: a selector first, then a column or a
|
||||||
|
// linked table by name. The walk reads a row wherever a segment follows or the
|
||||||
|
// table was reached from another; a table reached whole, with no selector, is left
|
||||||
|
// to a render's own draw.
|
||||||
|
func walkTable(t *table, tail []string, w pathWalk, descended bool) (node, error) {
|
||||||
|
sel, tail, err := t.selector(tail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
column, child, err := t.step(tail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A selector further down pins this table by ancestry, so the walk draws only
|
||||||
|
// where none follows; drawing first could pick a row the selector is not inside.
|
||||||
|
if w.pins != nil {
|
||||||
|
if err := readRow(w.pins, t, sel, (descended || len(tail) > 0) && !hasSelector(tail)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(tail) == 0 && sel == "" && !descended:
|
||||||
|
return walkPath(t, nil, w)
|
||||||
|
case len(tail) == 0:
|
||||||
|
return walkPath(t.whole, nil, w)
|
||||||
|
case child != nil:
|
||||||
|
return walkTable(child, tail[1:], w, true)
|
||||||
|
}
|
||||||
|
return walkPath(column, tail[1:], w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selector splits the selector a tail starts with from the rest of it.
|
||||||
|
func (t *table) selector(tail []string) (sel string, rest []string, err error) {
|
||||||
|
if len(tail) == 0 || !isSelector(tail[0]) {
|
||||||
|
return "", tail, nil
|
||||||
|
}
|
||||||
|
sel, rest = selectorOf(tail[0]), tail[1:]
|
||||||
|
if len(rest) > 0 && isSelector(rest[0]) {
|
||||||
|
return "", nil, fmt.Errorf("%s[%s] is selected twice; one selector names its row", t.category, sel)
|
||||||
|
}
|
||||||
|
return sel, rest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// step is what a tail's first segment names in t: a column, or a table linked to it.
|
||||||
|
func (t *table) step(tail []string) (column node, child *table, err error) {
|
||||||
|
if len(tail) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
if i, ok := t.col[tail[0]]; ok {
|
||||||
|
return t.fields[t.columns[i]], nil, nil
|
||||||
|
}
|
||||||
|
if child = t.descendant(tail[0]); child == nil {
|
||||||
|
return nil, nil, fmt.Errorf("no column or linked table %q in %s", tail[0], t.category)
|
||||||
|
}
|
||||||
|
return nil, child, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRow pins the row a path reads of t: the one its selector names, or, where the
|
||||||
|
// walk draws, one drawn where the path reads into the table.
|
||||||
|
func readRow(d *draws, t *table, sel string, draw bool) error {
|
||||||
|
if sel != "" {
|
||||||
|
return d.selectRow(t, sel)
|
||||||
|
}
|
||||||
|
if draw && d.s != nil {
|
||||||
|
d.rowOf(t)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no field %q", tail[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// carriedByAll is the choice rule a path that must resolve on every call obeys:
|
// carriedByAll is the choice rule a path that must resolve on every call obeys:
|
||||||
// the rest of the tail must be one every variant carries.
|
// the rest of the tail must be one every variant carries.
|
||||||
@@ -88,12 +307,13 @@ func unreachableInChoice(c *choice, want string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkPath proves a dotted tail resolves whichever way the draws go — a choice
|
// checkPath proves a dotted tail resolves whichever way the draws go — a choice
|
||||||
// must carry the rest of the path in the set every variant shares — and that no
|
// must carry the rest of the path in the set every variant shares, a selector must
|
||||||
// level a path reads carries a repeat or a drawGroup, which one draw of it could not
|
// name one row inside the rows selected before it — and that no level a path reads
|
||||||
// apply. So a path that validates here resolves on every render, and a typo is a
|
// carries a repeat or a drawGroup, which one draw of it could not apply. So a path
|
||||||
// New-time error.
|
// that validates here resolves on every render, and a typo is a New-time error.
|
||||||
func checkPath(n node, tail []string, level string) error {
|
func checkPath(n node, tail []string, level string) error {
|
||||||
return walkPath(n, tail, pathWalk{
|
var pins draws
|
||||||
|
_, err := walkPath(n, tail, pathWalk{
|
||||||
choice: func(c *choice, rest []string) ([]node, error) {
|
choice: func(c *choice, rest []string) ([]node, error) {
|
||||||
if err := carriedByAll(c, rest); err != nil {
|
if err := carriedByAll(c, rest); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -110,5 +330,7 @@ func checkPath(n node, tail []string, level string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
pins: &pins,
|
||||||
})
|
})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -13,14 +13,14 @@ func TestWalkPathStopsAtAMissingSegment(t *testing.T) {
|
|||||||
level: func(tm *template, rest []string) error { seen = append(seen, "level:"+rest[0]); return nil },
|
level: func(tm *template, rest []string) error { seen = append(seen, "level:"+rest[0]); return nil },
|
||||||
leaf: func(n node) error { seen = append(seen, "leaf"); return nil },
|
leaf: func(n node) error { seen = append(seen, "leaf"); return nil },
|
||||||
}
|
}
|
||||||
if err := walkPath(n, []string{"a", "b"}, walk); err != nil {
|
if _, err := walkPath(n, []string{"a", "b"}, walk); err != nil {
|
||||||
t.Fatalf("walkPath(a.b) = %v", err)
|
t.Fatalf("walkPath(a.b) = %v", err)
|
||||||
}
|
}
|
||||||
if want := []string{"level:a", "level:b", "leaf"}; !slices.Equal(seen, want) {
|
if want := []string{"level:a", "level:b", "leaf"}; !slices.Equal(seen, want) {
|
||||||
t.Errorf("walk visited %v, want %v", seen, want)
|
t.Errorf("walk visited %v, want %v", seen, want)
|
||||||
}
|
}
|
||||||
seen = nil
|
seen = nil
|
||||||
err := walkPath(n, []string{"a", "nope", "deeper"}, walk)
|
_, err := walkPath(n, []string{"a", "nope", "deeper"}, walk)
|
||||||
if err == nil || !strings.Contains(err.Error(), `no field "nope"`) {
|
if err == nil || !strings.Contains(err.Error(), `no field "nope"`) {
|
||||||
t.Errorf("walkPath(a.nope.deeper) = %v, want the missing segment named", err)
|
t.Errorf("walkPath(a.nope.deeper) = %v, want the missing segment named", err)
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestWalkPathStopsAtAMissingSegment(t *testing.T) {
|
|||||||
func TestWalkPathChoiceConsumesNoSegment(t *testing.T) {
|
func TestWalkPathChoiceConsumesNoSegment(t *testing.T) {
|
||||||
n := compiled(t, `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`)
|
n := compiled(t, `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`)
|
||||||
var leaves []node
|
var leaves []node
|
||||||
err := walkPath(n, []string{"f"}, pathWalk{
|
_, err := walkPath(n, []string{"f"}, pathWalk{
|
||||||
choice: func(c *choice, rest []string) ([]node, error) {
|
choice: func(c *choice, rest []string) ([]node, error) {
|
||||||
if len(rest) != 1 || rest[0] != "f" {
|
if len(rest) != 1 || rest[0] != "f" {
|
||||||
t.Errorf("choice saw rest %v, want [f]", rest)
|
t.Errorf("choice saw rest %v, want [f]", rest)
|
||||||
@@ -74,7 +74,7 @@ func TestDescendIntoStringErrors(t *testing.T) {
|
|||||||
// one call and failing on the next: every variant must carry the rest of the path.
|
// one call and failing on the next: every variant must carry the rest of the path.
|
||||||
func TestPathThroughChoice(t *testing.T) {
|
func TestPathThroughChoice(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`,
|
"every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","g":"x"}]`,
|
||||||
"notall": `[{"format":"{f}","f":"1"},"plain"]`,
|
"notall": `[{"format":"{f}","f":"1"},"plain"]`,
|
||||||
"some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`,
|
"some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,6 +74,49 @@ func TestNoReferenceAllocRegression(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A table read pins a row in the render's draws and reads its cells in place.
|
||||||
|
func TestNoTableAllocRegression(t *testing.T) {
|
||||||
|
f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{
|
||||||
|
"region.json": {Data: []byte(`{"format":"{name}","rows":"region.tsv","key":"code","weight":"population"}`)},
|
||||||
|
"region.tsv": {Data: []byte("code\tname\tpopulation\n01\tStockholms län\t2400000\n12\tSkåne län\t1400000\n14\tVästra Götalands län\t1750000\n")},
|
||||||
|
"x.json": {Data: []byte(`"{/region.name}, {/region.code}"`)},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range []struct {
|
||||||
|
name, path string
|
||||||
|
base float64
|
||||||
|
}{
|
||||||
|
{"a table drawn", "region", 2},
|
||||||
|
{"a row selected", "region[12]", 2},
|
||||||
|
{"two columns of one draw", "x", 10},
|
||||||
|
} {
|
||||||
|
if allocs := testing.AllocsPerRun(10000, func() { f.Fake(s.path) }); allocs > s.base*1.10 {
|
||||||
|
t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a row index built per draw is the usual cause", s.name, allocs, s.base*1.10, s.base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Five linked tables, the depth a country's geo tree has: every pin must stay inline.
|
||||||
|
deep := fstest.MapFS{"addr.json": {Data: []byte(`"{/e.v} {/d.v} {/c.v} {/b.v} {/a.v}"`)}}
|
||||||
|
for i, name := range []string{"a", "b", "c", "d", "e"} {
|
||||||
|
rows, category := "k\tv\n1\tx\n2\ty\n", `{"format":"{v}","rows":"`+name+`.tsv","key":"k"}`
|
||||||
|
if i > 0 {
|
||||||
|
parent := string(rune('a' + i - 1))
|
||||||
|
rows = "k\tv\t" + parent + "\n1\tx\t1\n2\ty\t2\n"
|
||||||
|
category = `{"format":"{v}","rows":"` + name + `.tsv","key":"k","parent":"` + parent + `"}`
|
||||||
|
}
|
||||||
|
deep[name+".json"], deep[name+".tsv"] = &fstest.MapFile{Data: []byte(category)}, &fstest.MapFile{Data: []byte(rows)}
|
||||||
|
}
|
||||||
|
f, err = New(WithoutShippedData(), WithDataFS(deep))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const base = 13.0
|
||||||
|
if allocs := testing.AllocsPerRun(10000, func() { f.Fake("addr") }); allocs > base*1.10 {
|
||||||
|
t.Errorf("five linked tables: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a pin spilling past the inline set is the usual cause", allocs, base*1.10, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A record's fences read the compiled tree, so they belong to New, not to a draw.
|
// A record's fences read the compiled tree, so they belong to New, not to a draw.
|
||||||
func TestNoRecordAllocRegression(t *testing.T) {
|
func TestNoRecordAllocRegression(t *testing.T) {
|
||||||
for _, s := range []struct{ name, json string }{
|
for _, s := range []struct{ name, json string }{
|
||||||
|
|||||||
+67
-4
@@ -8,7 +8,43 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jsonBlock = regexp.MustCompile("(?s)```json\n(.*?)```")
|
var (
|
||||||
|
jsonBlock = regexp.MustCompile("(?s)```json\n(.*?)```")
|
||||||
|
tsvBlock = regexp.MustCompile("(?s)```tsv\n(.*?)```")
|
||||||
|
rowsFile = regexp.MustCompile(`"rows":\s*"([^"]+)"`)
|
||||||
|
parentName = regexp.MustCompile(`"parent":\s*"([^"]+)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// exampleFiles is a README json block as a data directory's files: the category, the
|
||||||
|
// rows TSV it names, taken from the nearest tsv block above it, and the parent table it
|
||||||
|
// names, taken from the nearest json block above it whose rows file is the parent's.
|
||||||
|
func exampleFiles(t *testing.T, src string, at int, body string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
files := map[string]string{"example.json": body}
|
||||||
|
if m := rowsFile.FindStringSubmatch(body); m != nil {
|
||||||
|
tsv := tsvBlock.FindAllStringSubmatch(src[:at], -1)
|
||||||
|
if tsv == nil {
|
||||||
|
t.Fatalf("README example names %s with no tsv block above it", m[1])
|
||||||
|
}
|
||||||
|
files[m[1]] = tsv[len(tsv)-1][1]
|
||||||
|
}
|
||||||
|
if m := parentName.FindStringSubmatch(body); m != nil {
|
||||||
|
blocks := jsonBlock.FindAllStringSubmatchIndex(src[:at], -1)
|
||||||
|
for i := len(blocks) - 1; i >= 0; i-- {
|
||||||
|
parent := src[blocks[i][2]:blocks[i][3]]
|
||||||
|
if strings.Contains(parent, `"rows": "`+m[1]+`.tsv"`) {
|
||||||
|
for name, content := range exampleFiles(t, src, blocks[i][0], parent) {
|
||||||
|
if name == "example.json" {
|
||||||
|
name = m[1] + ".json"
|
||||||
|
}
|
||||||
|
files[name] = content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
func readme(t *testing.T) string {
|
func readme(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -21,12 +57,13 @@ func readme(t *testing.T) string {
|
|||||||
|
|
||||||
func TestReadmeExamplesLoadAndRender(t *testing.T) {
|
func TestReadmeExamplesLoadAndRender(t *testing.T) {
|
||||||
n := 0
|
n := 0
|
||||||
for _, m := range jsonBlock.FindAllStringSubmatch(readme(t), -1) {
|
src := readme(t)
|
||||||
body := m[1]
|
for _, at := range jsonBlock.FindAllStringSubmatchIndex(src, -1) {
|
||||||
|
body := src[at[2]:at[3]]
|
||||||
if strings.Contains(body, "…") {
|
if strings.Contains(body, "…") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
f, err := New(WithDataPath(writeData(t, map[string]string{"example": body})), WithSeed(1))
|
f, err := New(WithDataPath(writeFiles(t, exampleFiles(t, src, at[0], body))), WithSeed(1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("README example does not load: %v\n%s", err, body)
|
t.Errorf("README example does not load: %v\n%s", err, body)
|
||||||
continue
|
continue
|
||||||
@@ -128,3 +165,29 @@ func TestReadmeDatatypeExample(t *testing.T) {
|
|||||||
t.Errorf("README Datatype example shows %v, want an integer, a number and a boolean column", shown)
|
t.Errorf("README Datatype example shows %v, want an integer, a number and a boolean column", shown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadmeTableExample(t *testing.T) {
|
||||||
|
src := readme(t)
|
||||||
|
i := strings.Index(src, "### Table")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("README lost the Table section")
|
||||||
|
}
|
||||||
|
at := jsonBlock.FindStringSubmatchIndex(src[i:])
|
||||||
|
if at == nil {
|
||||||
|
t.Fatal("README Table section has no json block")
|
||||||
|
}
|
||||||
|
body := src[i:][at[2]:at[3]]
|
||||||
|
f, err := New(WithDataPath(writeFiles(t, exampleFiles(t, src, i+at[0], body))), WithSeed(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{"example[SE]": "Sweden (SE)", "example[Norway].alpha2": "NO"} {
|
||||||
|
if got := fake(t, f, path); got != want {
|
||||||
|
t.Errorf("README table example: Fake(%q) = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := f.FakeRecord("example")
|
||||||
|
if err != nil || r.CSVHeader() != "alpha2,name,population" {
|
||||||
|
t.Fatalf("README table example as a record: %q, %v", r.CSVHeader(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,18 +122,47 @@ func literal(c Column, quote func(string) string, nullText string) string {
|
|||||||
func (f *Generator) FakeRecord(path string) (*Record, error) {
|
func (f *Generator) FakeRecord(path string) (*Record, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
_, n, tail, err := resolveCategory(f.categories, strings.Split(path, "."))
|
segments, err := splitPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fejkdata: %w", err)
|
||||||
|
}
|
||||||
|
_, n, tail, err := resolveCategory(f.categories, segments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
|
return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
|
||||||
}
|
}
|
||||||
if len(tail) > 0 {
|
set := newDrawSet(f.rand)
|
||||||
|
sc := drawScope{set: &set}
|
||||||
|
if t, isTable := n.(*table); isTable {
|
||||||
|
if n, err = tableRecord(f.rand, t, tail, sc); err != nil {
|
||||||
|
return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
} else if len(tail) > 0 {
|
||||||
return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0])
|
return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0])
|
||||||
}
|
}
|
||||||
shape := f.recordShapeOf(n)
|
shape := f.recordShapeOf(n)
|
||||||
if shape.err != nil {
|
if shape.err != nil {
|
||||||
return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
|
return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
|
||||||
}
|
}
|
||||||
return renderRecord(f.rand, shape.t, shape.columns), nil
|
return renderRecord(f.rand, shape.t, shape.columns, sc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableRecord walks a path's tail from a table to the table whose row is the record,
|
||||||
|
// pinning the rows it selects or draws.
|
||||||
|
func tableRecord(s *session, t *table, tail []string, sc drawScope) (node, error) {
|
||||||
|
n, err := descend(s, t, tail, sc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *table:
|
||||||
|
sc.draws(s).rowOf(n)
|
||||||
|
return n, nil
|
||||||
|
case *row:
|
||||||
|
return n.t, nil
|
||||||
|
case *column:
|
||||||
|
return nil, fmt.Errorf("descends into %q, a column; a record is a table's row", n.t.columns[n.i])
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordShape is what recordOf settled about a node: the template to project, its
|
// recordShape is what recordOf settled about a node: the template to project, its
|
||||||
@@ -171,7 +200,8 @@ type RecordTemplate struct {
|
|||||||
func (t *RecordTemplate) Fake() *Record {
|
func (t *RecordTemplate) Fake() *Record {
|
||||||
t.g.mu.Lock()
|
t.g.mu.Lock()
|
||||||
defer t.g.mu.Unlock()
|
defer t.g.mu.Unlock()
|
||||||
return renderRecord(t.g.rand, t.t, t.columns)
|
set := newDrawSet(t.g.rand)
|
||||||
|
return renderRecord(t.g.rand, t.t, t.columns, drawScope{set: &set})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecordTemplate compiles an inline record — a JSON object with a format and
|
// NewRecordTemplate compiles an inline record — a JSON object with a format and
|
||||||
@@ -200,6 +230,9 @@ func (f *Generator) FakeRecordTemplate(input string) (*Record, error) {
|
|||||||
// recordOf is the fence both record entry points pass. The columns come back with
|
// recordOf is the fence both record entry points pass. The columns come back with
|
||||||
// the template, fixed for every draw the caller goes on to make.
|
// the template, fixed for every draw the caller goes on to make.
|
||||||
func recordOf(n node) (*template, []Column, error) {
|
func recordOf(n node) (*template, []Column, error) {
|
||||||
|
if tb, isTable := n.(*table); isTable {
|
||||||
|
n = tb.format
|
||||||
|
}
|
||||||
t, ok := n.(*template)
|
t, ok := n.(*template)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns")
|
return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns")
|
||||||
@@ -219,10 +252,13 @@ func recordOf(n node) (*template, []Column, error) {
|
|||||||
return t, columns, nil
|
return t, columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderRecord draws each column once, in the name order recordOf fixed, as one render.
|
// renderRecord draws each column once, in the name order recordOf fixed, as one render
|
||||||
func renderRecord(s *session, t *template, columns []Column) *Record {
|
// over sc's draws; a table's columns read the row pinned there.
|
||||||
set := newDrawSet()
|
func renderRecord(s *session, t *template, columns []Column, sc drawScope) *Record {
|
||||||
sc := drawScope{set: &set}.in(t)
|
sc = sc.in(t)
|
||||||
|
if t.table != nil {
|
||||||
|
sc.t, sc.row = t.table, sc.draws(s).mustRow(t.table)
|
||||||
|
}
|
||||||
r := &Record{columns: append([]Column(nil), columns...)}
|
r := &Record{columns: append([]Column(nil), columns...)}
|
||||||
for i := range r.columns {
|
for i := range r.columns {
|
||||||
column := renderLeaf(s, t.fields[r.columns[i].Name], sc)
|
column := renderLeaf(s, t.fields[r.columns[i].Name], sc)
|
||||||
|
|||||||
+3
-3
@@ -257,7 +257,7 @@ func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) {
|
|||||||
|
|
||||||
func TestRecordBareReferenceStaysIndependentAsAnOperand(t *testing.T) {
|
func TestRecordBareReferenceStaysIndependentAsAnOperand(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"cur": `[{"format":"{code}","code":"aud"},{"format":"{code}","code":"eur"}]`,
|
"cur": `[{"format":"{code}","code":"aud"},{"format":"{code}","code":"eur","symbol":"€"}]`,
|
||||||
"row": `{"format":"","up":"{uppercase(/cur)}","low":"{lowercase(/cur)}"}`,
|
"row": `{"format":"","up":"{uppercase(/cur)}","low":"{lowercase(/cur)}"}`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
@@ -305,7 +305,7 @@ func TestRecordSQLInsert(t *testing.T) {
|
|||||||
func TestRecordRejectsFieldDescent(t *testing.T) {
|
func TestRecordRejectsFieldDescent(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"cat": `{"format":"{sub}","sub":{"format":"{x}","x":"1"}}`,
|
"cat": `{"format":"{sub}","sub":{"format":"{x}","x":"1"}}`,
|
||||||
"row": `[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]`,
|
"row": `[{"format":"{x}","x":"1"},{"format":"{x}","x":"2","y":"b"}]`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
for _, path := range []string{"cat.sub", "row.x"} {
|
for _, path := range []string{"cat.sub", "row.x"} {
|
||||||
@@ -317,7 +317,7 @@ func TestRecordRejectsFieldDescent(t *testing.T) {
|
|||||||
|
|
||||||
func TestRecordSharesAReferenceAcrossColumns(t *testing.T) {
|
func TestRecordSharesAReferenceAcrossColumns(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€"}]`,
|
"currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€","name":"Euro"}]`,
|
||||||
"price": `{"format":"{code} {symbol}","code":"{/currency.code}","symbol":"{/currency.symbol}"}`,
|
"price": `{"format":"{code} {symbol}","code":"{/currency.code}","symbol":"{/currency.symbol}"}`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
|
|||||||
+10
-2
@@ -35,7 +35,11 @@ func refShape(name string) (sigil, rest string, err error) {
|
|||||||
if strings.HasPrefix(rest, ".") {
|
if strings.HasPrefix(rest, ".") {
|
||||||
return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)")
|
return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)")
|
||||||
}
|
}
|
||||||
for _, seg := range strings.Split(rest, ".") {
|
segs, err := splitPath(rest)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
for _, seg := range segs {
|
||||||
if seg == "" {
|
if seg == "" {
|
||||||
return "", "", fmt.Errorf("path has an empty segment")
|
return "", "", fmt.Errorf("path has an empty segment")
|
||||||
}
|
}
|
||||||
@@ -59,7 +63,8 @@ func refSegments(name string, folder []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
base = folder[:len(folder)-1]
|
base = folder[:len(folder)-1]
|
||||||
}
|
}
|
||||||
return append(append([]string{}, base...), strings.Split(rest, ".")...), nil
|
segs, _ := splitPath(rest) // refShape proved it splits
|
||||||
|
return append(append([]string{}, base...), segs...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkRefs resolves every reference in the assembled tree. The head of the path —
|
// linkRefs resolves every reference in the assembled tree. The head of the path —
|
||||||
@@ -199,6 +204,9 @@ func resolveCategory(root map[string]node, segments []string) (head []string, ta
|
|||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if isSelector(segments[i]) {
|
||||||
|
return nil, nil, nil, fmt.Errorf("%s is a folder, not a table, so it has no row to select", strings.Join(segments[:i], "."))
|
||||||
|
}
|
||||||
child, ok := g.children[segments[i]]
|
child, ok := g.children[segments[i]]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, nil, fmt.Errorf("no entry %q", segments[i])
|
return nil, nil, nil, fmt.Errorf("no entry %q", segments[i])
|
||||||
|
|||||||
+4
-4
@@ -220,7 +220,7 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
|
|||||||
|
|
||||||
func TestReferencePathIsHeld(t *testing.T) {
|
func TestReferencePathIsHeld(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
|
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg","born":"1980"}]`,
|
||||||
"card": `"{/person.first} {/person.last}"`,
|
"card": `"{/person.first} {/person.last}"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(3))
|
f := newGenerator(t, dir, WithSeed(3))
|
||||||
@@ -291,7 +291,7 @@ func TestReferenceOverlapIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawPeople = `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"},{"format":"{first} {last}","first":"Cy","last":"Young"}]`
|
const drawPeople = `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"},{"format":"{first} {last}","first":"Cy","last":"Young","born":"1867"}]`
|
||||||
|
|
||||||
// onePerson reports whether name is the first name and surname of one drawPeople row.
|
// onePerson reports whether name is the first name and surname of one drawPeople row.
|
||||||
func onePerson(name string) bool {
|
func onePerson(name string) bool {
|
||||||
@@ -381,7 +381,7 @@ func TestRelativeReferences(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) {
|
func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
|
"sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg","born":"1980"}]`,
|
||||||
"sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`,
|
"sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(3))
|
f := newGenerator(t, dir, WithSeed(3))
|
||||||
@@ -408,7 +408,7 @@ func TestReferenceSigilErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSpellingsOfOneReferenceAreOneLevel(t *testing.T) {
|
func TestSpellingsOfOneReferenceAreOneLevel(t *testing.T) {
|
||||||
person := `[{"format":"{first} {last}","first":"Ada","last":"Byron"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`
|
person := `[{"format":"{first} {last}","first":"Ada","last":"Byron"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1990"}]`
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"sv_SE/person": person,
|
"sv_SE/person": person,
|
||||||
"sv_SE/mail": `"{.person} <{/sv_SE.person.first}>"`,
|
"sv_SE/mail": `"{.person} <{/sv_SE.person.first}>"`,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package fejkdata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,33 +19,36 @@ type rng interface {
|
|||||||
func (f *Generator) Fake(path string) (string, error) {
|
func (f *Generator) Fake(path string) (string, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
n, err := descend(f.rand, &folder{children: f.categories}, strings.Split(path, "."))
|
segments, err := splitPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fejkdata: %w", err)
|
||||||
|
}
|
||||||
|
f.set = drawSet{unnamed: draws{s: f.rand}}
|
||||||
|
sc := drawScope{set: &f.set}
|
||||||
|
f.root.children = f.categories
|
||||||
|
n, err := descend(f.rand, &f.root, segments, sc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fejkdata: %s: %w", path, err)
|
return "", fmt.Errorf("fejkdata: %s: %w", path, err)
|
||||||
}
|
}
|
||||||
if _, ok := n.(*folder); ok {
|
if _, ok := n.(*folder); ok {
|
||||||
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)
|
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)
|
||||||
}
|
}
|
||||||
return renderOnce(f.rand, n), nil
|
return render(f.rand, n, sc), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// descend walks named fields to the node a path names. It is the one render-side
|
// descend walks named fields to the node a path names, pinning the table rows it
|
||||||
// step that can fail, because the path comes from the caller and may name a field
|
// selects or draws in sc. It is the one render-side step that can fail, because the
|
||||||
// that does not exist. A choice consumes no segment, so the rest of the path must
|
// path comes from the caller and may name a field or a row that does not exist. A
|
||||||
// be one every variant carries before a variant is picked — a path that resolves
|
// choice consumes no segment, so the rest of the path must be one every variant
|
||||||
// at all resolves on every call.
|
// carries before a variant is picked — a path that resolves at all resolves on
|
||||||
func descend(s *session, root node, segments []string) (node, error) {
|
// every call.
|
||||||
var found node
|
func descend(s *session, root node, segments []string, sc drawScope) (node, error) {
|
||||||
err := walkPath(root, segments, pathWalk{
|
// Walked once without drawing first, so a path that fails moves no seeded stream.
|
||||||
choice: func(c *choice, rest []string) ([]node, error) {
|
var probe draws
|
||||||
if err := carriedByAll(c, rest); err != nil {
|
if _, err := walkPath(root, segments, pathWalk{pins: &probe}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []node{pick(s, c)}, nil
|
return walkPath(root, segments, pathWalk{pins: sc.draws(s)})
|
||||||
},
|
|
||||||
leaf: func(n node) error { found = n; return nil },
|
|
||||||
})
|
|
||||||
return found, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// render evaluates a compiled node to a string. compile validates every node up
|
// render evaluates a compiled node to a string. compile validates every node up
|
||||||
@@ -58,6 +60,20 @@ func render(s *session, n node, sc drawScope) string {
|
|||||||
return render(s, pick(s, n), sc)
|
return render(s, pick(s, n), sc)
|
||||||
case *null:
|
case *null:
|
||||||
return ""
|
return ""
|
||||||
|
case *table:
|
||||||
|
sc.t, sc.row = n, n.draw(s)
|
||||||
|
return expand(s, n.format, sc)
|
||||||
|
case *row:
|
||||||
|
sc.t, sc.row = n.t, sc.draws(s).mustRow(n.t)
|
||||||
|
return expand(s, n.t.format, sc)
|
||||||
|
case *column:
|
||||||
|
if sc.t != n.t {
|
||||||
|
sc.t, sc.row = n.t, sc.draws(s).mustRow(n.t)
|
||||||
|
}
|
||||||
|
if cell := n.t.cellNode(sc.row, n.i); cell != nil {
|
||||||
|
return render(s, cell, sc)
|
||||||
|
}
|
||||||
|
return n.t.cell(sc.row, n.i)
|
||||||
case *template:
|
case *template:
|
||||||
sc = sc.in(n)
|
sc = sc.in(n)
|
||||||
if n.repeat == 1 {
|
if n.repeat == 1 {
|
||||||
@@ -85,20 +101,20 @@ func render(s *session, n node, sc drawScope) string {
|
|||||||
//
|
//
|
||||||
//go:noinline
|
//go:noinline
|
||||||
func expandAnew(s *session, t *template) string {
|
func expandAnew(s *session, t *template) string {
|
||||||
var set drawSet
|
set := drawSet{unnamed: draws{s: s}}
|
||||||
return expand(s, t, drawScope{set: &set})
|
return expand(s, t, drawScope{set: &set})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick selects one item. Uniform choices are O(1); weighted choices are an
|
// pick selects one item. Uniform choices are O(1); weighted choices are an
|
||||||
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
||||||
// non-empty choice and a finite positive total, so the index is always in range.
|
// non-empty choice and a finite positive total, so the index is always in range.
|
||||||
func pick(r rng, c *choice) node {
|
// The session is concrete rather than the rng interface, which would make the
|
||||||
|
// walk that draws through it leak its draw set to the heap.
|
||||||
|
func pick(s *session, c *choice) node {
|
||||||
if c.cum == nil {
|
if c.cum == nil {
|
||||||
return c.items[r.IntN(len(c.items))]
|
return c.items[s.IntN(len(c.items))]
|
||||||
}
|
}
|
||||||
x := r.Float64() * c.cum[len(c.cum)-1]
|
return c.items[pickCum(s, c.cum)]
|
||||||
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > x })
|
|
||||||
return c.items[i]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// expand renders a template's compiled ops. compile validated every token, so this
|
// expand renders a template's compiled ops. compile validated every token, so this
|
||||||
@@ -110,7 +126,7 @@ func expand(s *session, t *template, sc drawScope) string {
|
|||||||
// repeat iteration get their own, since each is its own expansion. A reference
|
// repeat iteration get their own, since each is its own expansion. A reference
|
||||||
// path reads the render's draws in sc instead.
|
// path reads the render's draws in sc instead.
|
||||||
var held *draws
|
var held *draws
|
||||||
if len(t.held) > 0 {
|
if t.heldLocal {
|
||||||
held = &draws{
|
held = &draws{
|
||||||
variant: make(map[string]node, len(t.held)),
|
variant: make(map[string]node, len(t.held)),
|
||||||
value: make(map[string]draw, len(t.held)),
|
value: make(map[string]draw, len(t.held)),
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ func shippedShape(f *Generator) string {
|
|||||||
}
|
}
|
||||||
case *choice:
|
case *choice:
|
||||||
facts[prefix] = reads(n)
|
facts[prefix] = reads(n)
|
||||||
|
case *table:
|
||||||
|
facts[prefix] = "\tformat " + strconv.Quote(n.format.format) + tableFacts(n) + reads(n)
|
||||||
|
for _, name := range n.columns {
|
||||||
|
facts[join(prefix, name)] = "\tstring"
|
||||||
|
}
|
||||||
case *template:
|
case *template:
|
||||||
facts[prefix] = "\tformat " + strconv.Quote(n.format) + reads(n)
|
facts[prefix] = "\tformat " + strconv.Quote(n.format) + reads(n)
|
||||||
if _, columns, err := recordOf(n); err == nil {
|
if _, columns, err := recordOf(n); err == nil {
|
||||||
@@ -66,6 +71,20 @@ func shippedShape(f *Generator) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tableFacts names the columns a table's options read.
|
||||||
|
func tableFacts(t *table) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, o := range []struct {
|
||||||
|
name string
|
||||||
|
col int
|
||||||
|
}{{"key", t.key}, {"name", t.name}, {"weight", t.weight}, {"parent", t.parent}} {
|
||||||
|
if o.col >= 0 {
|
||||||
|
b.WriteString("\t" + o.name + " " + t.columns[o.col])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// reads names the categories any template under n references, sorted.
|
// reads names the categories any template under n references, sorted.
|
||||||
func reads(n node) string {
|
func reads(n node) string {
|
||||||
set := map[string]bool{}
|
set := map[string]bool{}
|
||||||
@@ -76,6 +95,11 @@ func reads(n node) string {
|
|||||||
for _, it := range n.items {
|
for _, it := range n.items {
|
||||||
collect(it)
|
collect(it)
|
||||||
}
|
}
|
||||||
|
case *table:
|
||||||
|
collect(n.format)
|
||||||
|
for _, cell := range n.tokens {
|
||||||
|
collect(cell)
|
||||||
|
}
|
||||||
case *template:
|
case *template:
|
||||||
for _, b := range n.refs {
|
for _, b := range n.refs {
|
||||||
set[strings.TrimPrefix(b.key, "/")] = true
|
set[strings.TrimPrefix(b.key, "/")] = true
|
||||||
@@ -112,3 +136,17 @@ func TestShippedShapeNamesReads(t *testing.T) {
|
|||||||
t.Fatalf("shippedShape =\n%s\nwant\n%s", got, want)
|
t.Fatalf("shippedShape =\n%s\nwant\n%s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShippedShapeNamesTables(t *testing.T) {
|
||||||
|
f := newGenerator(t, writeFiles(t, map[string]string{
|
||||||
|
"w.json": `"x"`,
|
||||||
|
"r.json": `{"format":"{name}","rows":"r.tsv","key":"code","name":"name","weight":"w"}`,
|
||||||
|
"r.tsv": "code\tname\tw\n1\ta\t2\n2\tb\t3\n",
|
||||||
|
"m.json": `{"format":"{name} {/w}","rows":"m.tsv","key":"code","parent":"r"}`,
|
||||||
|
"m.tsv": "code\tname\tr\n10\tc\t1\n20\td\t2\n",
|
||||||
|
}))
|
||||||
|
want := "m\tformat \"{name} {/w}\"\tkey code\tparent r\treads w\nm.code\tstring\nm.name\tstring\nm.r\tstring\nr\tformat \"{name}\"\tkey code\tname name\tweight w\nr.code\tstring\nr.m\nr.m.code\nr.m.name\nr.m.r\nr.name\tstring\nr.w\tstring\nw\tformat \"x\"\n"
|
||||||
|
if got := shippedShape(f); got != want {
|
||||||
|
t.Fatalf("shippedShape =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -349,7 +349,8 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro
|
|||||||
// fill draws the record into v's tagged fields, then each nested struct as a record of its own.
|
// fill draws the record into v's tagged fields, then each nested struct as a record of its own.
|
||||||
func (s *structShape) fill(sess *session, v reflect.Value) {
|
func (s *structShape) fill(sess *session, v reflect.Value) {
|
||||||
if s.record != nil {
|
if s.record != nil {
|
||||||
for i, c := range renderRecord(sess, s.record, s.columns).columns {
|
set := newDrawSet(sess)
|
||||||
|
for i, c := range renderRecord(sess, s.record, s.columns, drawScope{set: &set}).columns {
|
||||||
setColumn(fieldAt(v, s.fields[i]), c)
|
setColumn(fieldAt(v, s.fields[i]), c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -52,8 +52,8 @@ type structLink struct {
|
|||||||
func structData(t *testing.T) *Generator {
|
func structData(t *testing.T) *Generator {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return newGenerator(t, writeData(t, map[string]string{
|
return newGenerator(t, writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
|
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1815"}]`,
|
||||||
"place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31"}]`,
|
"place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31","region":"F"}]`,
|
||||||
"mid": `{"format":"","score":["{/src.score}",{"format":"5","datatype":"integer"}]}`,
|
"mid": `{"format":"","score":["{/src.score}",{"format":"5","datatype":"integer"}]}`,
|
||||||
"src": `{"format":"","code":[null,"200","404"],"del":null,"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`,
|
"src": `{"format":"","code":[null,"200","404"],"del":null,"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`,
|
||||||
"trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`,
|
"trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`,
|
||||||
@@ -275,7 +275,7 @@ func TestFakeStructErrors(t *testing.T) {
|
|||||||
}{}, "is empty"},
|
}{}, "is empty"},
|
||||||
{&struct {
|
{&struct {
|
||||||
A string `fake:"[abc]"`
|
A string `fake:"[abc]"`
|
||||||
}{}, `holds a "["`},
|
}{}, `"["`},
|
||||||
{&struct {
|
{&struct {
|
||||||
A string `fake:"{.person.first} x"`
|
A string `fake:"{.person.first} x"`
|
||||||
}{}, "write {/person.first}"},
|
}{}, "write {/person.first}"},
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
package fejkdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// table is a category whose rows come from a TSV beside it: the header names the
|
||||||
|
// columns, each row is one draw, and the format renders the drawn row. A column is a
|
||||||
|
// cell of the row a render pinned; a cell carrying tokens compiles to a string node.
|
||||||
|
type table struct {
|
||||||
|
category string
|
||||||
|
file string
|
||||||
|
format *template // fields are the column nodes
|
||||||
|
columns []string
|
||||||
|
col map[string]int
|
||||||
|
fields map[string]node // column nodes, the format's fields
|
||||||
|
whole *row // the pinned row rendered by the format
|
||||||
|
cells []string // rows × columns, flat
|
||||||
|
tokens map[int]*template
|
||||||
|
key int // column index, or -1
|
||||||
|
name int
|
||||||
|
weight int
|
||||||
|
parent int
|
||||||
|
cum []float64 // cumulative weights, nil when uniform
|
||||||
|
byKey map[string]int
|
||||||
|
parentT *table
|
||||||
|
children map[string]*table
|
||||||
|
once sync.Once
|
||||||
|
index tableIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableIndex is what selection and linked draws look up, built on the first draw
|
||||||
|
// that needs it.
|
||||||
|
type tableIndex struct {
|
||||||
|
byName map[string][]int
|
||||||
|
children map[string][]int // rows by parent key
|
||||||
|
childCum map[string][]float64 // cumulative weights per parent key, nil when uniform
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*table) isNode() {}
|
||||||
|
|
||||||
|
// column is one column of a table, rendered as the cell of the row the render pinned.
|
||||||
|
type column struct {
|
||||||
|
t *table
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*column) isNode() {}
|
||||||
|
|
||||||
|
// row is the row of a table the render pinned, rendered by the table's format.
|
||||||
|
type row struct{ t *table }
|
||||||
|
|
||||||
|
func (*row) isNode() {}
|
||||||
|
|
||||||
|
func (t *table) rows() int { return len(t.cells) / len(t.columns) }
|
||||||
|
|
||||||
|
func (t *table) cell(row, col int) string { return t.cells[row*len(t.columns)+col] }
|
||||||
|
|
||||||
|
// cellNode is what a cell renders: its compiled template where it carries tokens,
|
||||||
|
// else nil for its text.
|
||||||
|
func (t *table) cellNode(row, col int) *template { return t.tokens[row*len(t.columns)+col] }
|
||||||
|
|
||||||
|
// tableOptions are the keys a table object takes; every other key is refused.
|
||||||
|
var tableOptions = []string{"format", "key", "name", "parent", "rows", "weight"}
|
||||||
|
|
||||||
|
func isTableOption(name string) bool {
|
||||||
|
for _, o := range tableOptions {
|
||||||
|
if o == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// inSelector is what a key or name cell may not contain: the path grammar reserves
|
||||||
|
// brackets and braces, and a quote or a bar would make the selector no path and no
|
||||||
|
// token arm.
|
||||||
|
const inSelector = `[]{}"|`
|
||||||
|
|
||||||
|
// compileTable compiles a category object naming a rows file into a table.
|
||||||
|
func compileTable(m map[string]any, category string, files *categoryFiles) (*table, error) {
|
||||||
|
o, err := readTableOptions(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := files.rows(o.rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t := &table{category: category, file: o.rows, key: -1, name: -1, weight: -1, parent: -1}
|
||||||
|
if err := t.parseRows(data); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", o.rows, err)
|
||||||
|
}
|
||||||
|
if err := t.bindOptions(o); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.checkCells(); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", o.rows, err)
|
||||||
|
}
|
||||||
|
if err := t.compileFormat(o.format); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableOptionValues struct {
|
||||||
|
format, key, name, parent, rows, weight string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTableOptions(m map[string]any) (tableOptionValues, error) {
|
||||||
|
var o tableOptionValues
|
||||||
|
for k := range m {
|
||||||
|
if !isTableOption(k) {
|
||||||
|
return o, fmt.Errorf("a table takes %s; %q is none of them", strings.Join(tableOptions, ", "), k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, into := range map[string]*string{"format": &o.format, "key": &o.key, "name": &o.name, "parent": &o.parent, "rows": &o.rows, "weight": &o.weight} {
|
||||||
|
v, ok := m[k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, isString := v.(string)
|
||||||
|
if !isString {
|
||||||
|
return o, fmt.Errorf("%s must be a string, got %T", k, v)
|
||||||
|
}
|
||||||
|
if k != "format" && s == "" {
|
||||||
|
return o, fmt.Errorf("%s names a column, so it cannot be empty; drop it", k)
|
||||||
|
}
|
||||||
|
*into = s
|
||||||
|
}
|
||||||
|
if _, ok := m["format"]; !ok {
|
||||||
|
return o, fmt.Errorf("template object missing string \"format\"")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(o.rows, ".tsv") || strings.Contains(o.rows, "/") {
|
||||||
|
return o, fmt.Errorf("rows names a .tsv file beside the category, not %q", o.rows)
|
||||||
|
}
|
||||||
|
if o.key != "" && o.key == o.name {
|
||||||
|
return o, fmt.Errorf("name names the key column %q, which a selector already reads; drop it", o.name)
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRows reads the TSV: the header line names the columns, each following line
|
||||||
|
// is a row of as many cells. Cells are substrings of data, so the file is held once.
|
||||||
|
func (t *table) parseRows(data string) error {
|
||||||
|
data = strings.TrimSuffix(strings.TrimPrefix(data, "\xEF\xBB\xBF"), "\n")
|
||||||
|
header, rest, _ := strings.Cut(data, "\n")
|
||||||
|
header = strings.TrimSuffix(header, "\r")
|
||||||
|
if header == "" {
|
||||||
|
return fmt.Errorf("has no header line naming its columns")
|
||||||
|
}
|
||||||
|
t.columns = strings.Split(header, "\t")
|
||||||
|
t.col = make(map[string]int, len(t.columns))
|
||||||
|
t.fields = make(map[string]node, len(t.columns))
|
||||||
|
for i, name := range t.columns {
|
||||||
|
if err := checkName(name); err != nil {
|
||||||
|
return fmt.Errorf("column %w", err)
|
||||||
|
}
|
||||||
|
if _, dup := t.col[name]; dup {
|
||||||
|
return fmt.Errorf("column %q is named twice", name)
|
||||||
|
}
|
||||||
|
t.col[name] = i
|
||||||
|
t.fields[name] = &column{t, i}
|
||||||
|
}
|
||||||
|
if header == data {
|
||||||
|
return fmt.Errorf("has no rows below its header; a table is at least two rows")
|
||||||
|
}
|
||||||
|
m := len(t.columns)
|
||||||
|
t.cells = make([]string, 0, m*(strings.Count(rest, "\n")+1))
|
||||||
|
for line := 2; ; line++ {
|
||||||
|
row, more, found := strings.Cut(rest, "\n")
|
||||||
|
row = strings.TrimSuffix(row, "\r")
|
||||||
|
if err := t.appendRow(row, line); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest = more
|
||||||
|
}
|
||||||
|
if t.rows() < 2 {
|
||||||
|
return fmt.Errorf("has one row, which is a template; write it as one")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendRow splits one line into as many cells as the header has columns.
|
||||||
|
func (t *table) appendRow(row string, line int) error {
|
||||||
|
if row == "" {
|
||||||
|
return fmt.Errorf("line %d is empty; every line below the header is a row", line)
|
||||||
|
}
|
||||||
|
m := len(t.columns)
|
||||||
|
for n := 0; n < m; n++ {
|
||||||
|
cell, next, tab := strings.Cut(row, "\t")
|
||||||
|
if !tab && n < m-1 || tab && n == m-1 {
|
||||||
|
return fmt.Errorf("line %d has %s cells than the %d columns", line, map[bool]string{true: "more", false: "fewer"}[tab], m)
|
||||||
|
}
|
||||||
|
t.cells = append(t.cells, cell)
|
||||||
|
row = next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindOptions resolves each option to its column and proves what it claims of the
|
||||||
|
// cells: a key is unique, a weight a positive number.
|
||||||
|
func (t *table) bindOptions(o tableOptionValues) error {
|
||||||
|
for _, opt := range []struct {
|
||||||
|
name, value string
|
||||||
|
into *int
|
||||||
|
}{{"key", o.key, &t.key}, {"name", o.name, &t.name}, {"parent", o.parent, &t.parent}, {"weight", o.weight, &t.weight}} {
|
||||||
|
if opt.value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i, ok := t.col[opt.value]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s names no column %q of %s; the columns are %v", opt.name, opt.value, o.rows, t.columns)
|
||||||
|
}
|
||||||
|
*opt.into = i
|
||||||
|
}
|
||||||
|
if t.name >= 0 && t.key < 0 {
|
||||||
|
return fmt.Errorf("name selects a row as a key does, and lists the rows it matches by their keys, so it needs a key column; add key")
|
||||||
|
}
|
||||||
|
if err := t.indexKeys(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return t.sumWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexKeys proves every key names one row, and keeps the index a link is proved by.
|
||||||
|
func (t *table) indexKeys() error {
|
||||||
|
if t.key < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.byKey = make(map[string]int, t.rows())
|
||||||
|
for r := 0; r < t.rows(); r++ {
|
||||||
|
k := t.cell(r, t.key)
|
||||||
|
if k == "" {
|
||||||
|
return fmt.Errorf("%s line %d: the key is empty", t.file, r+2)
|
||||||
|
}
|
||||||
|
if first, dup := t.byKey[k]; dup {
|
||||||
|
return fmt.Errorf("%s line %d: key %q repeats line %d; a key selects one row", t.file, r+2, k, first+2)
|
||||||
|
}
|
||||||
|
t.byKey[k] = r
|
||||||
|
}
|
||||||
|
for r := 0; r < t.rows() && t.name >= 0; r++ {
|
||||||
|
if n := t.cell(r, t.name); t.byKey[n] != r {
|
||||||
|
if other, isKey := t.byKey[n]; isKey {
|
||||||
|
return fmt.Errorf("%s line %d: name %q is the key of line %d, which a selector reads first, so the name could never select this row", t.file, r+2, n, other+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumWeights proves every weight is a positive number and builds the cumulative table.
|
||||||
|
func (t *table) sumWeights() error {
|
||||||
|
if t.weight < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.cum = make([]float64, t.rows())
|
||||||
|
total := 0.0
|
||||||
|
for r := range t.cum {
|
||||||
|
w, err := strconv.ParseFloat(t.cell(r, t.weight), 64)
|
||||||
|
if err != nil || math.IsInf(w, 0) || math.IsNaN(w) || w <= 0 {
|
||||||
|
return fmt.Errorf("%s line %d: weight %q is not a positive number", t.file, r+2, t.cell(r, t.weight))
|
||||||
|
}
|
||||||
|
total += w
|
||||||
|
t.cum[r] = total
|
||||||
|
}
|
||||||
|
if math.IsInf(total, 0) {
|
||||||
|
return fmt.Errorf("%s: the weights sum past the largest number", t.file)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCells compiles every cell carrying a token, and fences what a selector reads.
|
||||||
|
func (t *table) checkCells() error {
|
||||||
|
for i, cell := range t.cells {
|
||||||
|
row, col := i/len(t.columns), i%len(t.columns)
|
||||||
|
if (col == t.key || col == t.name) && strings.ContainsAny(cell, inSelector) {
|
||||||
|
return fmt.Errorf("line %d: %s %q contains %q, which a selector cannot spell", row+2, t.columns[col], cell, cell[strings.IndexAny(cell, inSelector):][:1])
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(cell, "{}") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := compileString(cell)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("line %d, %s: %w", row+2, t.columns[col], err)
|
||||||
|
}
|
||||||
|
if t.tokens == nil {
|
||||||
|
t.tokens = map[int]*template{}
|
||||||
|
}
|
||||||
|
t.tokens[i] = n.(*template)
|
||||||
|
t.tokens[i].cellOf, t.tokens[i].cellRow = t, row
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileFormat compiles the format over the columns as its fields.
|
||||||
|
func (t *table) compileFormat(format string) error {
|
||||||
|
for _, name := range fieldTokens(format) {
|
||||||
|
a := splitArm(name, nil)
|
||||||
|
if _, ok := t.col[a.key]; !ok && !isRef(a.key) && a.key != "" {
|
||||||
|
return fmt.Errorf("format names no column %q of %s; the columns are %v", a.key, t.file, t.columns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := checkTokens(format, t.fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.format = &template{format: format, fields: t.fields, repeat: 1, record: true, table: t}
|
||||||
|
t.whole = &row{t}
|
||||||
|
return t.format.compileFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkTables binds every table's parent to the table beside it, and proves the
|
||||||
|
// links: a parent has a key, every link cell is one, every parent row is linked
|
||||||
|
// to, no chain of parents closes, and no child is named like a parent's column.
|
||||||
|
func linkTables(root map[string]node) error {
|
||||||
|
var walk func(dir string, children map[string]node) error
|
||||||
|
walk = func(dir string, children map[string]node) error {
|
||||||
|
for _, name := range sortedNames(children) {
|
||||||
|
path := join(dir, name)
|
||||||
|
switch n := children[name].(type) {
|
||||||
|
case *folder:
|
||||||
|
if err := walk(path, n.children); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case *table:
|
||||||
|
if n.parent < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := n.linkParent(path, children); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return walk("", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *table) linkParent(path string, siblings map[string]node) error {
|
||||||
|
name := t.columns[t.parent]
|
||||||
|
p, ok := siblings[name].(*table)
|
||||||
|
switch {
|
||||||
|
case siblings[name] == nil:
|
||||||
|
return fmt.Errorf("parent %q names no table beside it", name)
|
||||||
|
case !ok:
|
||||||
|
return fmt.Errorf("parent %q is not a table; a link column reads a table's key", name)
|
||||||
|
case p.key < 0:
|
||||||
|
return fmt.Errorf("parent %q has no key column to link to", name)
|
||||||
|
}
|
||||||
|
var ancestors []*table
|
||||||
|
for q, seen := p, map[*table]bool{t: true}; q != nil; q, _ = siblings[q.columns[q.parent]].(*table) {
|
||||||
|
if seen[q] {
|
||||||
|
return fmt.Errorf("parent cycle: %s reaches itself through its parents", q.category)
|
||||||
|
}
|
||||||
|
seen[q] = true
|
||||||
|
ancestors = append(ancestors, q)
|
||||||
|
if q.parent < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, q := range ancestors {
|
||||||
|
if _, clash := q.col[t.category]; clash {
|
||||||
|
return fmt.Errorf("%q is named like a column of %q, its ancestor, so %s.%s could read either; rename one", t.category, q.category, q.category, t.category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linked := make(map[string]bool, p.rows())
|
||||||
|
for r := 0; r < t.rows(); r++ {
|
||||||
|
k := t.cell(r, t.parent)
|
||||||
|
if _, ok := p.byKey[k]; !ok {
|
||||||
|
return fmt.Errorf("%s line %d: %s %q is no key of %s", t.file, r+2, name, k, p.file)
|
||||||
|
}
|
||||||
|
linked[k] = true
|
||||||
|
}
|
||||||
|
for r := 0; r < p.rows(); r++ {
|
||||||
|
if k := p.cell(r, p.key); !linked[k] {
|
||||||
|
return fmt.Errorf("%s links no row to %s %q; every %s row needs one, or drop line %d of %s", t.file, name, k, name, r+2, p.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.parentT = p
|
||||||
|
if p.children == nil {
|
||||||
|
p.children = map[string]*table{}
|
||||||
|
}
|
||||||
|
p.children[t.category] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *table) indexed() *tableIndex {
|
||||||
|
t.once.Do(func() {
|
||||||
|
if t.name >= 0 {
|
||||||
|
t.index.byName = make(map[string][]int, t.rows())
|
||||||
|
for r := 0; r < t.rows(); r++ {
|
||||||
|
n := t.cell(r, t.name)
|
||||||
|
t.index.byName[n] = append(t.index.byName[n], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.parent >= 0 {
|
||||||
|
t.index.children = map[string][]int{}
|
||||||
|
for r := 0; r < t.rows(); r++ {
|
||||||
|
k := t.cell(r, t.parent)
|
||||||
|
t.index.children[k] = append(t.index.children[k], r)
|
||||||
|
}
|
||||||
|
if t.cum != nil {
|
||||||
|
t.index.childCum = make(map[string][]float64, len(t.index.children))
|
||||||
|
for k, rows := range t.index.children {
|
||||||
|
cum, total := make([]float64, len(rows)), 0.0
|
||||||
|
for i, r := range rows {
|
||||||
|
w, _ := strconv.ParseFloat(t.cell(r, t.weight), 64) // sumWeights proved it
|
||||||
|
total += w
|
||||||
|
cum[i] = total
|
||||||
|
}
|
||||||
|
t.index.childCum[k] = cum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return &t.index
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw picks a row over the whole table. The session is concrete rather than the rng
|
||||||
|
// interface so that a walk holding the draws allocates nothing.
|
||||||
|
func (t *table) draw(s *session) int {
|
||||||
|
if t.cum == nil {
|
||||||
|
return s.IntN(t.rows())
|
||||||
|
}
|
||||||
|
return pickCum(s, t.cum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickCum(s *session, cum []float64) int {
|
||||||
|
x := s.Float64() * cum[len(cum)-1]
|
||||||
|
return min(sort.Search(len(cum), func(i int) bool { return cum[i] > x }), len(cum)-1) // x can round up to the total
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawUnder picks a row among those linked to parent row pr.
|
||||||
|
func (t *table) drawUnder(s *session, pr int) int {
|
||||||
|
ix := t.indexed()
|
||||||
|
k := t.parentT.cell(pr, t.parentT.key)
|
||||||
|
rows := ix.children[k]
|
||||||
|
if ix.childCum == nil {
|
||||||
|
return rows[s.IntN(len(rows))]
|
||||||
|
}
|
||||||
|
return rows[pickCum(s, ix.childCum[k])]
|
||||||
|
}
|
||||||
|
|
||||||
|
// descendant is the table named name among those linked to t, at any depth.
|
||||||
|
func (t *table) descendant(name string) *table {
|
||||||
|
if c, ok := t.children[name]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
for _, c := range t.children {
|
||||||
|
if d := c.descendant(name); d != nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentRow is the row of t's parent that row r links to.
|
||||||
|
func (t *table) parentRow(r int) int { return t.parentT.byKey[t.cell(r, t.parent)] }
|
||||||
|
|
||||||
|
// descends reports whether a is an ancestor of t.
|
||||||
|
func (t *table) descends(a *table) bool {
|
||||||
|
for p := t.parentT; p != nil; p = p.parentT {
|
||||||
|
if p == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// under reports whether row r of t sits inside row pr of ancestor a.
|
||||||
|
func (t *table) under(r int, a *table, pr int) bool {
|
||||||
|
for c, row := t, r; c.parentT != nil; c, row = c.parentT, c.parentRow(row) {
|
||||||
|
if c.parentT == a {
|
||||||
|
return c.parentRow(row) == pr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// find is the row a selector names: by key first, then by name, where a name
|
||||||
|
// naming several rows resolves inside the ancestors pinned in d.
|
||||||
|
func (t *table) find(sel string, d *draws) (int, error) {
|
||||||
|
if t.key < 0 {
|
||||||
|
return 0, fmt.Errorf("%s has no key column to select a row by", t.category)
|
||||||
|
}
|
||||||
|
if r, ok := t.byKey[sel]; ok {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
rows := t.indexed().byName[sel]
|
||||||
|
if len(rows) > 1 {
|
||||||
|
rows = d.inside(t, rows)
|
||||||
|
}
|
||||||
|
switch len(rows) {
|
||||||
|
case 1:
|
||||||
|
return rows[0], nil
|
||||||
|
case 0:
|
||||||
|
return 0, fmt.Errorf("no row of %s has key or name %q", t.category, sel)
|
||||||
|
}
|
||||||
|
keys := make([]string, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
keys[i] = t.cell(r, t.key)
|
||||||
|
}
|
||||||
|
inside := ""
|
||||||
|
if t.parentT != nil {
|
||||||
|
inside = fmt.Sprintf(", or select it inside its %s", t.parentT.category)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%q names %d rows of %s; select one by key, one of %v%s", sel, len(rows), t.category, keys, inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectorSpelling is how a path writes a selected row, for messages.
|
||||||
|
func (t *table) selectorSpelling(r int) string {
|
||||||
|
return t.category + "[" + t.cell(r, t.key) + "]"
|
||||||
|
}
|
||||||
+662
@@ -0,0 +1,662 @@
|
|||||||
|
package fejkdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFiles writes a data directory from a map of relative file name, extension
|
||||||
|
// included, to content.
|
||||||
|
func writeFiles(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// geo is three linked tables: region <- municipality <- locality.
|
||||||
|
func geo() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"region.json": `{"format":"{name}","rows":"region.tsv","key":"code","name":"name","weight":"population"}`,
|
||||||
|
"region.tsv": "code\tname\tpopulation\ttimezone\n01\tStockholms län\t2400000\tEurope/Stockholm\n12\tSkåne län\t1400000\tEurope/Stockholm\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\n",
|
||||||
|
"municipality.json": `{"format":"{name}","rows":"municipality.tsv","key":"code","name":"name","parent":"region","weight":"population"}`,
|
||||||
|
"municipality.tsv": "code\tname\tregion\tpopulation\n0180\tStockholm\t01\t980000\n0184\tSolna\t01\t85000\n1280\tMalmö\t12\t360000\n1281\tLund\t12\t130000\n1480\tGöteborg\t14\t590000\n",
|
||||||
|
"locality.json": `{"format":"{name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`,
|
||||||
|
"locality.tsv": "code\tname\tmunicipality\nL1\tStockholm\t0180\nL2\tSolna\t0184\nL3\tMalmö\t1280\nL4\tLund\t1281\nL5\tGöteborg\t1480\nL6\tHisingen\t1480\nL7\tSandby\t1281\nL8\tSandby\t0184\n",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
municipalityOf = map[string]string{"L1": "0180", "L2": "0184", "L3": "1280", "L4": "1281", "L5": "1480", "L6": "1480", "L7": "1281", "L8": "0184"}
|
||||||
|
regionOf = map[string]string{"0180": "01", "0184": "01", "1280": "12", "1281": "12", "1480": "14"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func with(files map[string]string, more map[string]string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range files {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range more {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableRendersRowsAndColumns(t *testing.T) {
|
||||||
|
f := newGenerator(t, writeFiles(t, geo()), WithSeed(1))
|
||||||
|
names := map[string]bool{"Stockholms län": true, "Skåne län": true, "Västra Götalands län": true}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if v := fake(t, f, "region"); !names[v] {
|
||||||
|
t.Fatalf("region = %q, want a row's format", v)
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "region.timezone"); v != "Europe/Stockholm" {
|
||||||
|
t.Fatalf("region.timezone = %q", v)
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "region.code"); v != "01" && v != "12" && v != "14" {
|
||||||
|
t.Fatalf("region.code = %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"locality", "locality.code", "locality.municipality", "locality.name",
|
||||||
|
"municipality", "municipality.code", "municipality.locality", "municipality.locality.code", "municipality.locality.municipality", "municipality.locality.name", "municipality.name", "municipality.population", "municipality.region",
|
||||||
|
"region", "region.code", "region.municipality", "region.municipality.code", "region.municipality.locality", "region.municipality.locality.code", "region.municipality.locality.municipality", "region.municipality.locality.name", "region.municipality.name", "region.municipality.population", "region.municipality.region", "region.name", "region.population", "region.timezone",
|
||||||
|
}
|
||||||
|
if got := f.List(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("List() = %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
r, err := f.FakeRecord("region")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.CSVHeader() != "code,name,population,timezone" {
|
||||||
|
t.Fatalf("CSVHeader = %q", r.CSVHeader())
|
||||||
|
}
|
||||||
|
line := strings.Split(r.CSVLine(), ",")
|
||||||
|
if len(line) != 4 || !names[line[1]] || line[3] != "Europe/Stockholm" {
|
||||||
|
t.Fatalf("CSVLine = %q, want one row's cells", r.CSVLine())
|
||||||
|
}
|
||||||
|
for _, c := range r.Columns() {
|
||||||
|
if c.DataType != DataTypeString || c.Null {
|
||||||
|
t.Fatalf("column %q is %s null=%v, want a string", c.Name, c.DataType, c.Null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableWeightSkewsTheDraw(t *testing.T) {
|
||||||
|
f := newGenerator(t, writeFiles(t, geo()), WithSeed(3))
|
||||||
|
count := map[string]int{}
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
count[fake(t, f, "region.code")]++
|
||||||
|
}
|
||||||
|
if count["01"] < 1200 || count["12"] > 900 {
|
||||||
|
t.Fatalf("region draws %v, want weighted by population (01 ≈ 43%%, 12 ≈ 25%%)", count)
|
||||||
|
}
|
||||||
|
count = map[string]int{}
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
count[fake(t, f, "region[01].municipality.code")]++
|
||||||
|
}
|
||||||
|
if count["0180"] < 2500 || count["0184"] == 0 || len(count) != 2 {
|
||||||
|
t.Fatalf("municipality draws inside 01 %v, want Stockholm ≈ 92%% and Solna the rest", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableCellsAreStringNodes(t *testing.T) {
|
||||||
|
dir := writeFiles(t, map[string]string{
|
||||||
|
"w.json": `"x"`,
|
||||||
|
"place.json": `{"format":"{zip} {name}","rows":"place.tsv","key":"name"}`,
|
||||||
|
"place.tsv": "name\tzip\ttag\nStockholm\t1{digits(2)} {digits(2)}\t{/w}\nTranås\t573 {digits(2)}\tplain\n",
|
||||||
|
})
|
||||||
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
|
zip := regexp.MustCompile(`^(1\d\d \d\d Stockholm|573 \d\d Tranås)$`)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if v := fake(t, f, "place"); !zip.MatchString(v) {
|
||||||
|
t.Fatalf("place = %q, want the cell's tokens expanded", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "place[Stockholm].tag"); v != "x" {
|
||||||
|
t.Fatalf("place[Stockholm].tag = %q, want the reference rendered", v)
|
||||||
|
}
|
||||||
|
bad := writeFiles(t, map[string]string{
|
||||||
|
"place.json": `{"format":"{name}","rows":"place.tsv"}`,
|
||||||
|
"place.tsv": "name\tzip\nA\t{name}\nB\t2\n",
|
||||||
|
})
|
||||||
|
if _, err := New(WithoutShippedData(), WithDataPath(bad)); err == nil || !strings.Contains(err.Error(), "place.tsv") || !strings.Contains(err.Error(), `no field "name"`) {
|
||||||
|
t.Fatalf("New = %v, want a cell reading a column refused, naming the file", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableSelectsARowByKeyOrName(t *testing.T) {
|
||||||
|
files := with(geo(), map[string]string{
|
||||||
|
"city.json": `{"format":"{name}","rows":"city.tsv","key":"code","name":"name"}`,
|
||||||
|
"city.tsv": "code\tname\nSTL\tSt. Louis\nSPI\tSpringfield\n",
|
||||||
|
})
|
||||||
|
f := newGenerator(t, writeFiles(t, files), WithSeed(1))
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"region[12]": "Skåne län",
|
||||||
|
"region[Skåne län].code": "12",
|
||||||
|
"municipality[1281].locality[Sandby]": "Sandby",
|
||||||
|
"municipality[1281].locality[Sandby].code": "L7",
|
||||||
|
"municipality[0184].locality[Sandby].code": "L8",
|
||||||
|
"city[St. Louis].code": "STL",
|
||||||
|
} {
|
||||||
|
if got := fake(t, f, path); got != want {
|
||||||
|
t.Errorf("Fake(%q) = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"region[99]": `"99"`,
|
||||||
|
"locality[Sandby]": "L7 L8",
|
||||||
|
"region[12].code[1]": "not a table",
|
||||||
|
"region[]": "empty",
|
||||||
|
"region[12": "]",
|
||||||
|
"region[12].municipality[1480]": "not inside",
|
||||||
|
} {
|
||||||
|
if _, err := f.Fake(path); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("Fake(%q) = %v, want an error mentioning %s", path, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "municipality[0180].region"); v != "01" {
|
||||||
|
t.Fatalf("municipality[0180].region = %q, want the link column's cell", v)
|
||||||
|
}
|
||||||
|
noKey := writeFiles(t, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"})
|
||||||
|
if _, err := newGenerator(t, noKey).Fake("t[x]"); err == nil || !strings.Contains(err.Error(), "no key") {
|
||||||
|
t.Fatalf("Fake(t[x]) = %v, want no column to select by", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableDescendsToALinkedTable(t *testing.T) {
|
||||||
|
f := newGenerator(t, writeFiles(t, geo()), WithSeed(2))
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if m := fake(t, f, "region[12].municipality.code"); regionOf[m] != "12" {
|
||||||
|
t.Fatalf("region[12].municipality.code = %q, outside 12", m)
|
||||||
|
}
|
||||||
|
if l := fake(t, f, "region[12].locality.code"); regionOf[municipalityOf[l]] != "12" {
|
||||||
|
t.Fatalf("region[12].locality.code = %q, outside 12", l)
|
||||||
|
}
|
||||||
|
if l := fake(t, f, "municipality[1480].locality"); l != "Göteborg" && l != "Hisingen" {
|
||||||
|
t.Fatalf("municipality[1480].locality = %q", l)
|
||||||
|
}
|
||||||
|
if l := fake(t, f, "region.municipality.locality.name"); l == "" {
|
||||||
|
t.Fatal("region.municipality.locality.name rendered empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := f.FakeRecord("region[14].municipality")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if line := r.CSVLine(); line != "1480,Göteborg,590000,14" {
|
||||||
|
t.Fatalf("record inside region 14 = %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedTablesDrawConsistently(t *testing.T) {
|
||||||
|
spellings := map[string]string{
|
||||||
|
"leaf first": `{"format":"{l}|{m}|{r}","l":"{/locality.code}","m":"{/municipality.code}","r":"{/region.code}"}`,
|
||||||
|
"root first": `{"format":"{r}|{m}|{l}","r":"{/region.code}","m":"{/municipality.code}","l":"{/locality.code}"}`,
|
||||||
|
"skipping one": `{"format":"{r}|{l}","r":"{/region.code}","l":"{/locality.code}"}`,
|
||||||
|
"nested": `{"format":"{a}|{l}","a":{"format":"{r}|{m}","r":"{/region.code}","m":"{/municipality.code}"},"l":"{/locality.code}"}`,
|
||||||
|
"a record": `{"format":"","l":"{/locality.code}","m":"{/municipality.code}","r":"{/region.code}"}`,
|
||||||
|
}
|
||||||
|
for name, addr := range spellings {
|
||||||
|
f := newGenerator(t, writeFiles(t, with(geo(), map[string]string{"addr.json": addr})), WithSeed(5))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
var parts []string
|
||||||
|
if name == "a record" {
|
||||||
|
r, err := f.FakeRecord("addr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range r.Columns() {
|
||||||
|
parts = append(parts, c.Value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts = strings.Split(fake(t, f, "addr"), "|")
|
||||||
|
}
|
||||||
|
var l, m, r string
|
||||||
|
for _, p := range parts {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "L"):
|
||||||
|
l = p
|
||||||
|
case len(p) == 4:
|
||||||
|
m = p
|
||||||
|
default:
|
||||||
|
r = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m == "" {
|
||||||
|
m = municipalityOf[l]
|
||||||
|
}
|
||||||
|
if municipalityOf[l] != m || regionOf[m] != r {
|
||||||
|
t.Fatalf("%s: %v is not one consistent draw", name, parts)
|
||||||
|
}
|
||||||
|
seen[l] = true
|
||||||
|
}
|
||||||
|
if len(seen) < 4 {
|
||||||
|
t.Fatalf("%s: only %v drawn in 300 renders", name, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedTablesDrawApartAcrossGroupsAndRepeats(t *testing.T) {
|
||||||
|
files := with(geo(), map[string]string{
|
||||||
|
"groups.json": `{"format":"{a}|{b}","a":{"format":"{/locality.code}","drawGroup":"g"},"b":"{/locality.code}"}`,
|
||||||
|
"many.json": `{"format":"{x}","x":{"format":"{/locality.code}","repeat":8,"separator":"|"}}`,
|
||||||
|
})
|
||||||
|
f := newGenerator(t, writeFiles(t, files), WithSeed(9))
|
||||||
|
for _, path := range []string{"groups", "many"} {
|
||||||
|
differ := false
|
||||||
|
for i := 0; i < 100 && !differ; i++ {
|
||||||
|
parts := strings.Split(fake(t, f, path), "|")
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
differ = differ || p != parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !differ {
|
||||||
|
t.Fatalf("%s: every draw agreed in 100 renders, want groups and repeat iterations drawn apart", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableSelectorInAReference(t *testing.T) {
|
||||||
|
files := with(geo(), map[string]string{
|
||||||
|
"x.json": `{"format":"{a} {b} {c}","a":"{/region[12].name}","b":"{/region[12].municipality.code}","c":"{/region[12].locality.code}"}`,
|
||||||
|
})
|
||||||
|
f := newGenerator(t, writeFiles(t, files), WithSeed(1))
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
parts := strings.Fields(fake(t, f, "x"))
|
||||||
|
m, l := parts[len(parts)-2], parts[len(parts)-1]
|
||||||
|
if !strings.HasPrefix(fake(t, f, "x"), "Skåne län") || regionOf[m] != "12" || municipalityOf[l] != m {
|
||||||
|
t.Fatalf("x = %v, want one draw inside region 12", parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, c := range map[string]struct{ json, want string }{
|
||||||
|
"unknown row": {`"{/region[99].name}"`, `"99"`},
|
||||||
|
"ambiguous name": {`"{/locality[Sandby].name}"`, "L7 L8"},
|
||||||
|
"not inside": {`"{/region[12].municipality[0180].name}"`, "not inside"},
|
||||||
|
"selector on template": {`"{/x[1].a}"`, "not a table"},
|
||||||
|
} {
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, map[string]string{"bad.json": c.json}))))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: New = %v, want an error mentioning %q", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableSelectionFences(t *testing.T) {
|
||||||
|
rejected := map[string]struct{ json, want string }{
|
||||||
|
"bare beside a path": {`"{/region} {/region.name}"`, "reads a path into"},
|
||||||
|
"bare beside a linked path": {`"{/region} {/locality.name}"`, "drawGroup"},
|
||||||
|
"selected beside unselected": {`"{/region[12].municipality.name} {/municipality.code}"`, "{/region[12].municipality.code}"},
|
||||||
|
"two selections": {`"{/region[12].name} {/region[14].name}"`, "drawGroup"},
|
||||||
|
"selection beside a descendant": {`"{/region[12].name} {/locality.code}"`, "{/region[12].locality.code}"},
|
||||||
|
}
|
||||||
|
for name, c := range rejected {
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": c.json}))))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: New = %v, want it rejected mentioning %q", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accepted := map[string]string{
|
||||||
|
"a descent beside a path into it": `"{/region.municipality} {/region.municipality.name}"`,
|
||||||
|
"selections in two groups": `{"format":"{a} {b}","a":{"format":"{/region[12].name}","drawGroup":"a"},"b":"{/region[14].name}"}`,
|
||||||
|
"one selection twice": `"{/region[12].name} {/region[12].timezone} {/region[12].locality.name}"`,
|
||||||
|
"a bare table in another group": `{"format":"{a} {b}","a":{"format":"{/region}","drawGroup":"a"},"b":"{/locality.name}"}`,
|
||||||
|
}
|
||||||
|
for name, json := range accepted {
|
||||||
|
f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": json}))), WithSeed(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fake(t, f, "x")
|
||||||
|
}
|
||||||
|
f := newGenerator(t, writeFiles(t, with(geo(), map[string]string{"x.json": accepted["a descent beside a path into it"]})), WithSeed(1))
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if parts := strings.Fields(fake(t, f, "x")); parts[0] != parts[1] {
|
||||||
|
t.Fatalf("x = %v, want the descended row and the path into it to agree", parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The family fence replays each read's selectors through the walk the render uses, so
|
||||||
|
// spellings that pin the same rows by different routes agree, and two rows of one
|
||||||
|
// table are refused at load rather than found at render.
|
||||||
|
func TestTableFamilyFenceReplaysPins(t *testing.T) {
|
||||||
|
accepted := map[string]string{
|
||||||
|
"skip-level selectors": `"{/region[12].locality[L4].name}|{/region[12].name}"`,
|
||||||
|
"descendant then ancestor": `"{/locality[L4].name}|{/region[12].name}"`,
|
||||||
|
"two selected levels": `"{/municipality[1281].name}|{/locality[L4].name}"`,
|
||||||
|
"nested prefixes": `"{/region[12].municipality[1281].name}|{/municipality[1281].locality[L4].name}"`,
|
||||||
|
"a draw under the selection": `"{/region[12].name}|{/region[12].municipality.locality[L4].name}"`,
|
||||||
|
}
|
||||||
|
for name, json := range accepted {
|
||||||
|
f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": json}))), WithSeed(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
v := fake(t, f, "x")
|
||||||
|
if strings.Contains(v, "Sandby") || strings.Contains(v, "Malmö") || strings.Contains(v, "1280") || !strings.Contains(v, "Lund") && !strings.Contains(v, "Skåne") {
|
||||||
|
t.Fatalf("%s: x = %q, want every part inside region 12 and Lund", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rejected := map[string]struct{ json, want string }{
|
||||||
|
"two rows of one table": {`"{/region[12].locality[L4].name} {/region[12].locality[L7].name}"`, "drawGroup"},
|
||||||
|
"a row outside a selected ancestor": {`"{/region[14].name} {/locality[L4].name}"`, "drawGroup"},
|
||||||
|
}
|
||||||
|
for name, c := range rejected {
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": c.json}))))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: New = %v, want it rejected mentioning %q", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableFences(t *testing.T) {
|
||||||
|
base := map[string]string{
|
||||||
|
"region.json": `{"format":"{name}","rows":"region.tsv","key":"code","name":"name"}`,
|
||||||
|
"region.tsv": "code\tname\n01\tA\n12\tB\n",
|
||||||
|
}
|
||||||
|
rejected := map[string]struct {
|
||||||
|
files map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"rows names a missing file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`}, "t.tsv"},
|
||||||
|
"a TSV nothing names": {with(base, map[string]string{"stray.tsv": "a\nx\n"}), "stray.tsv"},
|
||||||
|
"rows outside its folder": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"../region.tsv"}`}), "beside"},
|
||||||
|
"rows not a tsv": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"region.txt"}`, "region.txt": "code\n1\n"}), ".tsv"},
|
||||||
|
"key names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`},
|
||||||
|
"name names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`},
|
||||||
|
"weight names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`},
|
||||||
|
"parent names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`},
|
||||||
|
"key equals name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"a"}`, "t.tsv": "a\nx\ny\n"}, "drop"},
|
||||||
|
"duplicate key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx\nx\n"}, `"x"`},
|
||||||
|
"empty key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\tb\n\ty\nx\tz\n"}, "empty"},
|
||||||
|
"a bracket in a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx[1]\ny\n"}, `"["`},
|
||||||
|
"a brace in a name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\tx{1}\ny\ty\n"}, `"{"`},
|
||||||
|
"name without a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"a"}`, "t.tsv": "a\nx\nx\n"}, "key"},
|
||||||
|
"a name that is another row's key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\ty\ny\tz\n"}, `"y"`},
|
||||||
|
"a cell reading its family": {with(geo(), map[string]string{"locality.tsv": "code\tname\tmunicipality\tnote\nL1\tStockholm\t0180\t{/municipality.code}\nL2\tSolna\t0184\t-\nL3\tMalmö\t1280\t-\nL4\tLund\t1281\t-\nL5\tGöteborg\t1480\t-\n"}), "family"},
|
||||||
|
"a format reading its family": {with(geo(), map[string]string{"locality.json": `{"format":"{name} {/region.name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`}), "family"},
|
||||||
|
"a descendant named like an ancestor's column": {with(geo(), map[string]string{"region.tsv": "code\tname\tpopulation\tlocality\n01\tStockholms län\t2400000\tx\n12\tSkåne län\t1400000\ty\n14\tVästra Götalands län\t1750000\tz\n"}), `"locality"`},
|
||||||
|
"weight not a number": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\tmany\ny\t2\n"}, `"many"`},
|
||||||
|
"weight zero": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t0\ny\t2\n"}, "0"},
|
||||||
|
"weight negative": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t-1\ny\t2\n"}, "-1"},
|
||||||
|
"reserved column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb.c\nx\ty\n"}, `"b.c"`},
|
||||||
|
"duplicate column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\ta\nx\ty\n"}, `"a"`},
|
||||||
|
"empty column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\t\nx\ty\n"}, "empty"},
|
||||||
|
"short row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\ty\nz\n"}, "line 3"},
|
||||||
|
"no rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n"}, "no rows"},
|
||||||
|
"a blank line after the rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\t1\ny\t2\n\n"}, "line 4"},
|
||||||
|
"a blank line in one column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n\ny\n"}, "line 3"},
|
||||||
|
"a table reaching its family through a template": {with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"},
|
||||||
|
"a table reaching its family through a repeat": {with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"},
|
||||||
|
"a table reaching its family through a group": {with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"},
|
||||||
|
"one row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n"}, "one row"},
|
||||||
|
"empty file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": ""}, "header"},
|
||||||
|
"parent is not a table": {map[string]string{"p.json": `"x"`, "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, "not a table"},
|
||||||
|
"parent does not exist": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, `"p"`},
|
||||||
|
"parent in another folder": {map[string]string{"g/p.json": `{"format":"{k}","rows":"p.tsv","key":"k"}`, "g/p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, `"p"`},
|
||||||
|
"parent has no key": {map[string]string{"p.json": `{"format":"{k}","rows":"p.tsv"}`, "p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, "key"},
|
||||||
|
"dangling link": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t99\n"}), `"99"`},
|
||||||
|
"childless parent row": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t01\n"}), `"12"`},
|
||||||
|
"parent cycle": {map[string]string{"a.json": `{"format":"{k}","rows":"a.tsv","key":"k","parent":"b"}`, "a.tsv": "k\tb\nx\tx\ny\ty\n", "b.json": `{"format":"{k}","rows":"b.tsv","key":"k","parent":"a"}`, "b.tsv": "k\ta\nx\tx\ny\ty\n"}, "cycle"},
|
||||||
|
"child named like a column": {with(base, map[string]string{"name.json": `{"format":"{a}","rows":"name.tsv","parent":"region"}`, "name.tsv": "a\tregion\nx\t01\ny\t12\n"}), `"name"`},
|
||||||
|
"rows nested in a field": {map[string]string{"t.json": `{"format":"{x}","x":{"format":"{a}","rows":"x.tsv"}}`, "x.tsv": "a\nx\ny\n"}, "category"},
|
||||||
|
"rows in a choice item": {map[string]string{"t.json": `[{"format":"{a}","rows":"t.tsv"},"y"]`, "t.tsv": "a\nx\ny\n"}, "category"},
|
||||||
|
"unknown table option": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","fields":"a"}`, "t.tsv": "a\nx\ny\n"}, "a table takes"},
|
||||||
|
"repeat on a table": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","repeat":2}`, "t.tsv": "a\nx\ny\n"}, "a table takes"},
|
||||||
|
"drawGroup on a table": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","drawGroup":"g"}`, "t.tsv": "a\nx\ny\n"}, "a table takes"},
|
||||||
|
"option not a string": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":1}`, "t.tsv": "a\nx\ny\n"}, "string"},
|
||||||
|
"format names no column": {map[string]string{"t.json": `{"format":"{b}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `no column "b"`},
|
||||||
|
"format reads into a column": {map[string]string{"t.json": `{"format":"{a.x}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `"a"`},
|
||||||
|
"a reference into a column": {with(base, map[string]string{"t.json": `"{/region.name.x}"`}), "column"},
|
||||||
|
"a category referencing itself": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n{/t.a}\ny\n"}, "names the category it sits in"},
|
||||||
|
}
|
||||||
|
for name, c := range rejected {
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, c.files)))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: New = nil error, want it rejected at load", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := New(WithoutShippedData(), WithDataFS(os.DirFS(writeFiles(t, base)))); err != nil {
|
||||||
|
t.Fatalf("New(WithDataFS) = %v, want a table loaded from any fs.FS", err)
|
||||||
|
}
|
||||||
|
inline := newGenerator(t, writeFiles(t, base))
|
||||||
|
if _, err := inline.NewTemplate(`{"format":"{a}","rows":"t.tsv"}`); err == nil || !strings.Contains(err.Error(), "inline") {
|
||||||
|
t.Fatalf("NewTemplate(rows) = %v, want a table refused inline", err)
|
||||||
|
}
|
||||||
|
bom := newGenerator(t, writeFiles(t, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "\xEF\xBB\xBFa\tb\nx\t1\ny\t2\n"}), WithSeed(1))
|
||||||
|
if v := fake(t, bom, "t[x].b"); v != "1" {
|
||||||
|
t.Fatalf("t[x].b under a BOM header = %q, want the mark stripped", v)
|
||||||
|
}
|
||||||
|
crlf := newGenerator(t, writeFiles(t, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\tb\r\nx\t1\r\ny\t2\r\n"}), WithSeed(1))
|
||||||
|
if v := fake(t, crlf, "t[y].b"); v != "2" {
|
||||||
|
t.Fatalf("t[y].b in a CRLF file = %q, want the carriage return stripped", v)
|
||||||
|
}
|
||||||
|
a, b := newGenerator(t, writeFiles(t, geo()), WithSeed(1)), newGenerator(t, writeFiles(t, geo()), WithSeed(1))
|
||||||
|
for _, path := range []string{"region.nope", "region.locality.nope", "region.municipality[9999]", "region[12].locality[L1]"} {
|
||||||
|
if _, err := a.Fake(path); err == nil {
|
||||||
|
t.Fatalf("Fake(%s) = nil error", path)
|
||||||
|
}
|
||||||
|
if _, err := a.FakeRecord(path); err == nil {
|
||||||
|
t.Fatalf("FakeRecord(%s) = nil error", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x, y := fake(t, a, "region.locality.name"), fake(t, b, "region.locality.name"); x != y {
|
||||||
|
t.Fatalf("a failed Fake shifted the seeded stream: %q != %q", x, y)
|
||||||
|
}
|
||||||
|
two := newGenerator(t, writeFiles(t, with(geo(), map[string]string{"capital.json": `{"format":"{timezone}","rows":"region.tsv","key":"code"}`})), WithSeed(1))
|
||||||
|
if v := fake(t, two, "capital[12]"); v != "Europe/Stockholm" {
|
||||||
|
t.Fatalf("capital[12] over region.tsv = %q, want a second category over one file", v)
|
||||||
|
}
|
||||||
|
empty := newGenerator(t, writeFiles(t, map[string]string{"t.json": `{"format":"","rows":"t.tsv","key":"a"}`, "t.tsv": "a\tb\nx\t1\ny\t2\n"}), WithSeed(1))
|
||||||
|
if v := fake(t, empty, "t"); v != "" {
|
||||||
|
t.Fatalf("a record-only table renders %q, want \"\"", v)
|
||||||
|
}
|
||||||
|
if v := fake(t, empty, "t[y].b"); v != "2" {
|
||||||
|
t.Fatalf("t[y].b = %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameShapedChoiceIsATable(t *testing.T) {
|
||||||
|
rows := `[{"format":"{name}","name":"Sweden","alpha2":"SE"},{"format":"{name}","name":"Norway","alpha2":"NO"},{"format":"{name}","name":"Denmark","alpha2":"DK"}]`
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"country": rows})))
|
||||||
|
for _, want := range []string{"country.tsv", `"rows"`, `alpha2\tname`, "3 "} {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("New(same-shaped choice) = %v, want it refused mentioning %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weighted := `[{"format":"{name}","name":"Sweden","weight":2},{"format":"{name}","name":"Norway"}]`
|
||||||
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"country": weighted}))); err == nil || !strings.Contains(err.Error(), "weight") {
|
||||||
|
t.Fatalf("New(weighted same-shaped choice) = %v, want it refused naming a weight column", err)
|
||||||
|
}
|
||||||
|
accepted := map[string]string{
|
||||||
|
"nested": `{"format":"{c}","c":` + rows + `}`,
|
||||||
|
"a choice field": `[{"format":"{maker} {model}","maker":"BMW","model":["X3","X5"]},{"format":"{maker} {model}","maker":"Ford","model":["Focus","Fiesta"]}]`,
|
||||||
|
"different formats": `[{"format":"{a}-{b}","a":"1","b":"2"},{"format":"{b}-{a}","a":"3","b":"4"}]`,
|
||||||
|
"different fields": `[{"format":"{a}","a":"1","b":"2"},{"format":"{a}","a":"3"}]`,
|
||||||
|
"a string item": `[{"format":"{a}","a":"1"},"x"]`,
|
||||||
|
"strings": `["a","b","c"]`,
|
||||||
|
}
|
||||||
|
for name, json := range accepted {
|
||||||
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"x": json}))); err != nil {
|
||||||
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := newGenerator(t, writeData(t, map[string]string{"w": `"x"`}))
|
||||||
|
if _, err := f.NewTemplate(rows); err != nil {
|
||||||
|
t.Fatalf("NewTemplate(same-shaped choice) = %v, want an inline template exempt", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableInAStructTag(t *testing.T) {
|
||||||
|
f := newGenerator(t, writeFiles(t, geo()), WithSeed(1))
|
||||||
|
var v struct {
|
||||||
|
Region string `fake:"region[12].name"`
|
||||||
|
Locality string `fake:"region[12].locality.code"`
|
||||||
|
Any string `fake:"{/region[Skåne län].timezone}-x"` // the row region[12] names, by name
|
||||||
|
}
|
||||||
|
if err := f.FakeStruct(&v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v.Region != "Skåne län" || regionOf[municipalityOf[v.Locality]] != "12" || v.Any != "Europe/Stockholm-x" {
|
||||||
|
t.Fatalf("FakeStruct = %+v, want the selected rows", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableRowsAreAlternatives(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"cur.json": `{"format":"{code}","rows":"cur.tsv","key":"code"}`,
|
||||||
|
"cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\nUSD\t$\n",
|
||||||
|
"y.json": `{"format":"{sym}","rows":"y.tsv","key":"k"}`,
|
||||||
|
"y.tsv": "k\tsym\n1\t{/cur[SEK].sym}\n2\t{/cur[USD].sym}\n",
|
||||||
|
"country.json": `{"format":"{name} {money}","rows":"country.tsv","key":"alpha2"}`,
|
||||||
|
"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n",
|
||||||
|
}
|
||||||
|
f := newGenerator(t, writeFiles(t, files), WithSeed(1))
|
||||||
|
if v := fake(t, f, "country[SE]"); v != "Sweden kr" {
|
||||||
|
t.Fatalf("country[SE] = %q", v)
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "country[FI].money"); v != "€" {
|
||||||
|
t.Fatalf("country[FI].money = %q", v)
|
||||||
|
}
|
||||||
|
accepted := map[string]map[string]string{
|
||||||
|
"a cell reaching a table whose rows are alternatives": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y.sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
|
||||||
|
"a cell agreeing with the row it selects": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y[1].sym} {/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
|
||||||
|
"a selected row's cell beside the row it agrees with": {"z.json": `"{/country[SE].money} {/cur[SEK].sym}"`},
|
||||||
|
"a selected row whole beside the row it agrees with": {"z.json": `"{/country[SE]} {/cur[SEK].sym}"`},
|
||||||
|
"a column under a selected ancestor": {
|
||||||
|
"city.json": `{"format":"{name} {money}","rows":"city.tsv","key":"name","parent":"country"}`,
|
||||||
|
"city.tsv": "name\tcountry\tmoney\nHelsinki\tFI\t{/cur[EUR].sym}\nMalmö\tSE\t{/cur[SEK].sym}\nLund\tSE\t{/cur[SEK].sym}\n",
|
||||||
|
"z.json": `"{/country[SE].city.money} {/cur[SEK].sym}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
symbols := regexp.MustCompile(`€|kr|\$`)
|
||||||
|
for name, more := range accepted {
|
||||||
|
g, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, more))), WithSeed(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths := []string{"country", "country[FI]"}
|
||||||
|
if _, defined := more["z.json"]; defined {
|
||||||
|
paths = append(paths, "z")
|
||||||
|
}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
for _, path := range paths {
|
||||||
|
v := fake(t, g, path)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range symbols.FindAllString(v, -1) {
|
||||||
|
seen[s] = true
|
||||||
|
}
|
||||||
|
if len(seen) > 1 {
|
||||||
|
t.Fatalf("%s: %s = %q, want one currency per render", name, path, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rejected := map[string]map[string]string{
|
||||||
|
"one cell selecting two rows": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}{/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
|
||||||
|
"two tables' cells in one render": {"z.json": `"{/country.money} {/y.sym}"`},
|
||||||
|
"the format beside a cell": {"country.json": `{"format":"{money} {/cur[SEK].sym}","rows":"country.tsv","key":"alpha2"}`},
|
||||||
|
"two columns of one row": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur[SEK].sym}\t{/cur[EUR].sym}\nSE\t{/cur[EUR].sym}\t{/cur[SEK].sym}\n"},
|
||||||
|
"a whole read beside a path": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur}\t{/cur[EUR].sym}\nSE\t{/cur}\t{/cur[SEK].sym}\n"},
|
||||||
|
"a cell reaching another's cells": {"y.json": `{"format":"{sym}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tsym\n1\t{/cur[SEK].sym}\n2\t{/cur[USD].sym}\n", "cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\nUSD\t$\n", "country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y[1].sym} {/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
|
||||||
|
}
|
||||||
|
for name, more := range rejected {
|
||||||
|
_, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, more))))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "drawGroup") {
|
||||||
|
t.Errorf("%s: New = %v, want it refused", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableOverridesByLayering(t *testing.T) {
|
||||||
|
mine := writeFiles(t, map[string]string{
|
||||||
|
"misc/country.json": `{"format":"{name}","rows":"country.tsv","key":"alpha2"}`,
|
||||||
|
"misc/country.tsv": "alpha2\tname\nXX\tNowhere\nYY\tElsewhere\n",
|
||||||
|
})
|
||||||
|
f, err := New(WithDataPath(mine), WithSeed(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v := fake(t, f, "misc.country[XX]"); v != "Nowhere" {
|
||||||
|
t.Fatalf("misc.country[XX] = %q, want the layered table", v)
|
||||||
|
}
|
||||||
|
if paths := f.List(); slices.Contains(paths, "misc.country.alpha3") {
|
||||||
|
t.Fatal("List() still offers the shipped table's columns under an overridden category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShippedTables(t *testing.T) {
|
||||||
|
f := newGenerator(t, "data/misc", WithSeed(1))
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"country[SE]": "Sweden",
|
||||||
|
"country[Sweden].alpha3": "SWE",
|
||||||
|
"country[SE].numeric": "752",
|
||||||
|
"country[SE].tld": ".se",
|
||||||
|
"country[SE].calling-code": "46",
|
||||||
|
"country[SE].capital": "Stockholm",
|
||||||
|
"country[SE].currency": "SEK",
|
||||||
|
"country[SE].flag": "🇸🇪",
|
||||||
|
"currency[SEK].name": "Swedish Krona",
|
||||||
|
"currency[SEK].symbol": "kr",
|
||||||
|
"currency[SEK].numeric": "752",
|
||||||
|
"currency[SEK].decimals": "2",
|
||||||
|
"currency[Euro].code": "EUR",
|
||||||
|
"language[sv]": "Swedish",
|
||||||
|
"httpstatus[404]": "404 Not Found",
|
||||||
|
"httpstatus[404].reason": "Not Found",
|
||||||
|
"mimetype[application/json].ext": ".json",
|
||||||
|
} {
|
||||||
|
if got := fake(t, f, path); got != want {
|
||||||
|
t.Errorf("Fake(%q) = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
re := map[string]*regexp.Regexp{
|
||||||
|
"country.numeric": regexp.MustCompile(`^\d{3}$`),
|
||||||
|
"country.tld": regexp.MustCompile(`^\.[a-z]{2}$`),
|
||||||
|
"country.calling-code": regexp.MustCompile(`^\d{1,4}(-\d{3})?$`),
|
||||||
|
"country.capital": regexp.MustCompile(`\p{L}`),
|
||||||
|
"country.currency": regexp.MustCompile(`^[A-Z]{3}$`),
|
||||||
|
"country.flag": regexp.MustCompile(`^[\x{1F1E6}-\x{1F1FF}]{2}$`),
|
||||||
|
"country.languages": regexp.MustCompile(`^[a-z]{2,3}(-[A-Z]{2})?(,[a-z]{2,3}(-[A-Z]{2})?)*$`),
|
||||||
|
"currency.numeric": regexp.MustCompile(`^\d{3}$`),
|
||||||
|
"currency.decimals": regexp.MustCompile(`^[0-4]$`),
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
for p, rx := range re {
|
||||||
|
if v := fake(t, f, p); !rx.MatchString(v) {
|
||||||
|
t.Fatalf("misc %s = %q, want %s", p, v, rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count := map[string]bool{}
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
count[fake(t, f, "country.alpha2")] = true
|
||||||
|
}
|
||||||
|
if len(count) < 200 {
|
||||||
|
t.Fatalf("country draws %d distinct rows in 5000, want the full register", len(count))
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-17
@@ -81,19 +81,20 @@ type builtin struct {
|
|||||||
// for a plain field or alternation body. A '(' without a trailing ')' yields
|
// for a plain field or alternation body. A '(' without a trailing ')' yields
|
||||||
// ok=false; checkFunc reports it as malformed at compile time.
|
// ok=false; checkFunc reports it as malformed at compile time.
|
||||||
func funcCall(body string) (name string, args []string, ok bool) {
|
func funcCall(body string) (name string, args []string, ok bool) {
|
||||||
lp := strings.IndexByte(body, '(')
|
lp := indexOutside(body, '(')
|
||||||
if lp < 0 || !strings.HasSuffix(body, ")") {
|
if lp < 0 || !strings.HasSuffix(body, ")") {
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
}
|
}
|
||||||
return body[:lp], splitArgs(body[lp+1 : len(body)-1]), true
|
return body[:lp], splitArgs(body[lp+1 : len(body)-1]), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitArgs parses a function arg list: comma-separated, trimmed; empty -> none.
|
// splitArgs parses a function arg list: comma-separated outside a selector,
|
||||||
|
// trimmed; empty -> none.
|
||||||
func splitArgs(s string) []string {
|
func splitArgs(s string) []string {
|
||||||
if strings.TrimSpace(s) == "" {
|
if strings.TrimSpace(s) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
args := strings.Split(s, ",")
|
args := splitOutside(s, ',')
|
||||||
for i := range args {
|
for i := range args {
|
||||||
args[i] = strings.TrimSpace(args[i])
|
args[i] = strings.TrimSpace(args[i])
|
||||||
}
|
}
|
||||||
@@ -132,10 +133,10 @@ func checkTokens(format string, fields map[string]node) error {
|
|||||||
if t.kind != 'b' {
|
if t.kind != 'b' {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.IndexByte(t.body, '(') >= 0 { // a function token, not a field
|
if indexOutside(t.body, '(') >= 0 { // a function token, not a field
|
||||||
return checkFunc(t.body, fields)
|
return checkFunc(t.body, fields)
|
||||||
}
|
}
|
||||||
names := strings.Split(t.body, "|")
|
names := splitOutside(t.body, '|')
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if isRef(name) {
|
if isRef(name) {
|
||||||
if _, _, err := refShape(name); err != nil {
|
if _, _, err := refShape(name); err != nil {
|
||||||
@@ -187,12 +188,7 @@ func checkArm(name string, fields map[string]node, wholeToken bool) error {
|
|||||||
// hintableRef reports whether {/name} is a reference the grammar accepts, so the
|
// hintableRef reports whether {/name} is a reference the grammar accepts, so the
|
||||||
// hint never names a spelling that fails too.
|
// hint never names a spelling that fails too.
|
||||||
func hintableRef(name string) bool {
|
func hintableRef(name string) bool {
|
||||||
for _, seg := range strings.Split(name, ".") {
|
return checkPathNames(name) == nil
|
||||||
if checkName(seg) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenOperands lists the fields one {token} body reads as operands, empty for a
|
// tokenOperands lists the fields one {token} body reads as operands, empty for a
|
||||||
@@ -244,8 +240,8 @@ func checkNoRepeatedArm(body string, names []string) error {
|
|||||||
func fieldTokens(format string) []string {
|
func fieldTokens(format string) []string {
|
||||||
var names []string
|
var names []string
|
||||||
_ = eachToken(format, func(t ftoken) error {
|
_ = eachToken(format, func(t ftoken) error {
|
||||||
if t.kind == 'b' && strings.IndexByte(t.body, '(') < 0 {
|
if t.kind == 'b' && indexOutside(t.body, '(') < 0 {
|
||||||
names = append(names, strings.Split(t.body, "|")...)
|
names = append(names, splitOutside(t.body, '|')...)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -279,11 +275,11 @@ func splitArm(name string, refs map[string]refBinding) arm {
|
|||||||
}
|
}
|
||||||
return pathArm(name, b.key, b.tail)
|
return pathArm(name, b.key, b.tail)
|
||||||
}
|
}
|
||||||
head, tail, dotted := strings.Cut(name, ".")
|
segs, err := splitPath(name)
|
||||||
if !dotted {
|
if err != nil || len(segs) == 1 {
|
||||||
return arm{name: name, key: name, path: name}
|
return arm{name: name, key: name, path: name}
|
||||||
}
|
}
|
||||||
return pathArm(name, head, strings.Split(tail, "."))
|
return pathArm(name, segs[0], segs[1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathArm(name, key string, segs []string) arm {
|
func pathArm(name, key string, segs []string) arm {
|
||||||
@@ -296,7 +292,7 @@ func pathArm(name, key string, segs []string) arm {
|
|||||||
|
|
||||||
// splitArms splits a token body's '|' alternatives.
|
// splitArms splits a token body's '|' alternatives.
|
||||||
func splitArms(body string, refs map[string]refBinding) []arm {
|
func splitArms(body string, refs map[string]refBinding) []arm {
|
||||||
parts := strings.Split(body, "|")
|
parts := splitOutside(body, '|')
|
||||||
arms := make([]arm, len(parts))
|
arms := make([]arm, len(parts))
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
arms[i] = splitArm(p, refs)
|
arms[i] = splitArm(p, refs)
|
||||||
@@ -352,6 +348,7 @@ type formatOps struct {
|
|||||||
bound map[string]string
|
bound map[string]string
|
||||||
held map[string]bool
|
held map[string]bool
|
||||||
holder map[string]string
|
holder map[string]string
|
||||||
|
heldLocal bool // some held name is kept by the expansion itself rather than the render's draws
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *formatOps) hold(a arm, label string) {
|
func (c *formatOps) hold(a arm, label string) {
|
||||||
@@ -360,6 +357,9 @@ func (c *formatOps) hold(a arm, label string) {
|
|||||||
c.holder = map[string]string{}
|
c.holder = map[string]string{}
|
||||||
}
|
}
|
||||||
c.held[a.key] = true
|
c.held[a.key] = true
|
||||||
|
if !isRef(a.key) || len(a.tail) == 0 {
|
||||||
|
c.heldLocal = true
|
||||||
|
}
|
||||||
if _, named := c.holder[a.key]; !named {
|
if _, named := c.holder[a.key]; !named {
|
||||||
c.holder[a.key] = label
|
c.holder[a.key] = label
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+26
-17
@@ -58,27 +58,36 @@ misc.car.model
|
|||||||
misc.coordinate format "{lat}, {lon}"
|
misc.coordinate format "{lat}, {lon}"
|
||||||
misc.coordinate.lat string
|
misc.coordinate.lat string
|
||||||
misc.coordinate.lon string
|
misc.coordinate.lon string
|
||||||
misc.country
|
misc.country format "{name}" key alpha2 name name
|
||||||
misc.country.alpha2
|
misc.country.alpha2 string
|
||||||
misc.country.alpha3
|
misc.country.alpha3 string
|
||||||
misc.country.name
|
misc.country.calling-code string
|
||||||
|
misc.country.capital string
|
||||||
|
misc.country.currency string
|
||||||
|
misc.country.flag string
|
||||||
|
misc.country.languages string
|
||||||
|
misc.country.name string
|
||||||
|
misc.country.numeric string
|
||||||
|
misc.country.tld string
|
||||||
misc.creditcard
|
misc.creditcard
|
||||||
misc.creditcard.d
|
misc.creditcard.d
|
||||||
misc.currency
|
misc.currency format "{code}" key code name name
|
||||||
misc.currency.code
|
misc.currency.code string
|
||||||
misc.currency.name
|
misc.currency.decimals string
|
||||||
misc.currency.symbol
|
misc.currency.name string
|
||||||
|
misc.currency.numeric string
|
||||||
|
misc.currency.symbol string
|
||||||
misc.emoji
|
misc.emoji
|
||||||
misc.httpstatus
|
misc.httpstatus format "{code} {reason}" key code name reason
|
||||||
misc.httpstatus.code
|
misc.httpstatus.code string
|
||||||
misc.httpstatus.reason
|
misc.httpstatus.reason string
|
||||||
misc.language
|
misc.language format "{name}" key code name name
|
||||||
misc.language.code
|
misc.language.code string
|
||||||
misc.language.name
|
misc.language.name string
|
||||||
misc.mac format "{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}"
|
misc.mac format "{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}"
|
||||||
misc.mimetype
|
misc.mimetype format "{type}" key type name ext
|
||||||
misc.mimetype.ext
|
misc.mimetype.ext string
|
||||||
misc.mimetype.type
|
misc.mimetype.type string
|
||||||
misc.objectid format "{hex(24)}"
|
misc.objectid format "{hex(24)}"
|
||||||
misc.timezone
|
misc.timezone
|
||||||
misc.useragent
|
misc.useragent
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ func TestTransformReadsTheHeldDraw(t *testing.T) {
|
|||||||
|
|
||||||
func TestTransformOverAReference(t *testing.T) {
|
func TestTransformOverAReference(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
|
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1990"}]`,
|
||||||
"email": `"{/person.first} {/person.last} <{lowercase(ascii(/person.first))}.{lowercase(ascii(/person.last))}@example.com>"`,
|
"email": `"{/person.first} {/person.last} <{lowercase(ascii(/person.first))}.{lowercase(ascii(/person.last))}@example.com>"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(5))
|
f := newGenerator(t, dir, WithSeed(5))
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ func (p *valueProof) of(n node) proven {
|
|||||||
v = p.unite(n.items)
|
v = p.unite(n.items)
|
||||||
case *template:
|
case *template:
|
||||||
v = p.template(n)
|
v = p.template(n)
|
||||||
|
case *column:
|
||||||
|
v = p.cells(n)
|
||||||
|
case *row:
|
||||||
|
v = unproven(fmt.Sprintf("%q renders a row of %s, which is composed text", n.t.format.format, n.t.category))
|
||||||
default:
|
default:
|
||||||
v = unproven(`it reads a null, which renders "" outside its own column`)
|
v = unproven(`it reads a null, which renders "" outside its own column`)
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,25 @@ func (p *valueProof) of(n node) proven {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cells proves a table column over every cell it may render.
|
||||||
|
func (p *valueProof) cells(c *column) proven {
|
||||||
|
var v proven
|
||||||
|
for r := 0; r < c.t.rows(); r++ {
|
||||||
|
var w proven
|
||||||
|
if cell := c.t.cellNode(r, c.i); cell != nil {
|
||||||
|
w = p.of(cell)
|
||||||
|
} else {
|
||||||
|
w = literalValue(c.t.cell(r, c.i))
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
v = w
|
||||||
|
} else {
|
||||||
|
v = v.or(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (p *valueProof) unite(nodes []node) proven {
|
func (p *valueProof) unite(nodes []node) proven {
|
||||||
v := p.of(nodes[0])
|
v := p.of(nodes[0])
|
||||||
for _, n := range nodes[1:] {
|
for _, n := range nodes[1:] {
|
||||||
|
|||||||
Reference in New Issue
Block a user