# Text

The `text` operations count, search, cut, pad and change the case of
text, and turn other values into text and back. Each is called as
`text.<name>`, such as `text.trim(name)`.

Text is counted, cut and padded in Unicode code points, as `max_length`
counts it ([Constraints](named-types.md#constraints)) and as
PostgreSQL's `char_length` and Python do. A code point isn't always what a
person sees as one character: an emoji with a skin tone is two. No
operation counts those characters (graphemes).

None of these operations takes a `PresentationText`, the text Jadpo
formats for people, because it is a representation of its own
([Showing dates and times to people](showing-dates-and-times-to-people.md)).

| Operation | Gives | Fails | SQL form |
|---|---|---|---|
| `length(value)` | `Int`, at least 0, in code points | | `char_length(x)::bigint` |
| `trim(value)` | `Text` without Unicode white space at either end; never longer | | `btrim(x, chars)`, `chars` the Unicode `White_Space` characters |
| `lower(value, locale:)`, `upper(value, locale:)` | `Text` in Unicode's default case mapping, or a locale's (`İ` in Turkish) with `locale:` | | none |
| `contains(value, part)` | `Bool` | | `strpos(x, a) > 0` |
| `starts_with(value, prefix)`, `ends_with(value, suffix)` | `Bool` | | `starts_with(x, a)`; `right(x, char_length(a)) = a COLLATE "C"` |
| `replace(value, find, with)` | `Text` with every `find` replaced, left to right; `find` never empty | | `replace(x, a, b)` |
| `split(value, separator)` | `List<Text>`, at least one item; `separator` never empty; `""` gives `[""]`, and separators side by side give `""` between them | | none |
| `join(items, separator)` | `Text`, the items with `separator` between them | | none |
| `slice(value, from, count)` | `Text`, up to `count` code points from position `from`, cut short at the end; both never negative; never longer | | `substr(x, a + 1, b)` |
| `keep_first(value, n)`, `keep_last(value, n)` | `Text`, at most `n` code points from that end; `n` never negative; never longer | | `left(x, n)`; `right(x, n)` |
| `drop_first(value, n)`, `drop_last(value, n)` | `Text` without `n` code points from that end; never longer | | `substr(x, n + 1)`; `CASE WHEN n = 0 THEN x ELSE left(x, -n) END` |
| `pad_start(value, length, fill:)` | `Text` of at least `length` code points, `fill` added at the start; never cut; `fill:` one code point written out | | `CASE` on `char_length(x)` with `lpad` |
| `from(value)` | `Text`, the value as JSON carries it: an `Int`'s digits, a `Decimal` with its scale's places, a `Uuid` in lowercase, `true` or `false`, a time in its wire form | | `x::text` for `Int`, `Decimal`, `Uuid` and `Bool`; times none |
| `parse(raw, as: T)` | `T`, any standard type or type built on a representation, from its canonical text ([the contract](every-standard-type-meets-one-contract.md)) | `malformed`, or the rule it breaks | none |
| `format(value, locale:)` | `PresentationText` for people, any standard type, with the type's own options (`unit:`, `precision:`) ([the contract](every-standard-type-meets-one-contract.md)) | | none |
| `pick(value, locale:)` | `Text` from a `LocalisedText`: the locale's entry, else its language's, else the default | | none |
| `slug(value)` | `Text` of lowercase ASCII letters, digits and single `-`: accents dropped, every other run of characters one `-`, none at either end; may be empty | | none |

A bakery's product names, through every text operation. It sorts in
German and Swedish, so it declares those locales:

```jadpo
locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.de_de, Locale.sv_se]
}

type ProductName = Text {
    min_length: 1
    max_length: 60
}

type Caption = Text {
    max_length: 60
}

type TagName = Text {
    min_length: 1
    max_length: 20
}

type Divider = Text {
    min_length: 1
    max_length: 3
}

type ProductSlug = Text {}

type ShelfLabel = Text {}

type TagLine = Text {}

type OrderReference = Text {}

type OrderNumber = Int {
    min: 0
}

type PieceCount = Int {
    min: 0
}

type Product = Object {
    name: ProductName
    tags: List<TagName>
}

type NameBlank = Failure {
    kind: FailureKind.invalid_value
}

/// A product name as it was typed, without the spaces around it. Trimming
/// never lengthens text, so `max_length` can't break; it can leave
/// nothing, so `min_length` can.
function tidy(typed_name: ProductName) fails NameBlank -> ProductName {
    return attempt ProductName(text.trim(typed_name)) {
        min_length: NameBlank
    }
}

/// The first twelve code points of a name, for a narrow card: never
/// longer than the name, so building `Caption` needs no arm.
function caption(name: ProductName) -> Caption {
    return Caption(text.keep_first(name, 12))
}

/// The name in a web address: lowercase ASCII letters, digits and `-`.
function product_slug(name: ProductName) -> ProductSlug {
    return ProductSlug(text.slug(name))
}

/// The name for a shelf edge, in capitals by the shopper's own rules.
function shelf_label(name: ProductName, shopper: Locale) -> ShelfLabel {
    return ShelfLabel(text.upper(name, locale: shopper))
}

/// An order's number as six digits, `42` written `000042`.
function order_reference(number: OrderNumber) -> OrderReference {
    return OrderReference(text.pad_start(text.from(number), 6, fill: "0"))
}

/// A product's tags on one line.
function tag_line(product: Product) -> TagLine {
    return TagLine(text.join(product.tags, ", "))
}

/// How many pieces a line holds between separators. A separator is
/// never empty, which its type's `min_length` proves.
function count_pieces(line: ProductName, spacer: Divider) -> PieceCount {
    return PieceCount(list.count(text.split(line, spacer)))
}

/// The products in the order the shopper's alphabet puts their names.
function in_name_order(products: List<Product>, shopper: Locale) -> List<Product> {
    return list.sort_by(products, product -> product.name, locale: shopper)
}
```

Text is counted and cut in code points, so a mark written on its own and
each half of an emoji with a skin tone count as one each. Text in another
script is cut the same way.

```jadpo
test "text operations give what the page says" {
    var tidied: ProductName = attempt tidy("  Rye loaf ")
    assert tidied == "Rye loaf"
    assert tidy("   ") fails NameBlank
    assert caption("Seeded sourdough boule") == "Seeded sourd"
    assert product_slug("Crème Brûlée & Co.") == "creme-brulee-co"
    assert order_reference(42) == "000042"
    assert order_reference(1234567) == "1234567"  // never cut
    assert text.length("Rye") == 3
    assert text.lower("RYE Loaf") == "rye loaf"
    assert text.contains("Rye loaf", "loaf")
    assert text.starts_with("Rye loaf", "Rye")
    assert text.ends_with("Rye loaf", "loaf")
    assert not text.ends_with("Rye loaf", "Rye")
    assert text.replace("salt & pepper & oil", " & ", ", ") == "salt, pepper, oil"
    assert text.split("flour,sugar,,eggs", ",") == ["flour", "sugar", "", "eggs"]
    assert text.split("", ",") == [""]
    assert text.join(["flour", "sugar"], " and ") == "flour and sugar"
    assert text.slice("sourdough", 4, 3) == "dou"
    assert text.slice("sourdough", 7, 10) == "gh"  // cut short at the end
    assert text.keep_last("sourdough", 5) == "dough"
    assert text.drop_first("sourdough", 4) == "dough"
    assert text.drop_last("sourdough", 5) == "sour"
    assert text.drop_last("rye", 9) == ""
}

test "text is counted and cut in code points" {
    assert text.length("café") == 4  // é as one code point
    assert text.length("café") == 5  // e and a combining acute: two
    assert text.drop_last("café", 1) == "cafe"  // the mark goes on its own
    assert text.length("面包") == 2
    assert text.keep_first("面包店", 2) == "面包"
    assert text.length("👍🏽") == 2  // a thumb and a skin tone
    assert text.keep_first("👍🏽", 1) == "👍"
    assert text.trim("\t rye\n") == "rye"
    assert text.upper("straße") == "STRASSE"  // longer than it was
    assert product_slug("面包 Bakery") == "bakery"
}

test "a locale's case and order" {
    assert text.upper("istanbul") == "ISTANBUL"
    assert text.upper("istanbul", locale: Locale.tr_tr) == "İSTANBUL"
    assert text.lower("DİYARBAKIR", locale: Locale.tr_tr) == "diyarbakır"
    assert shelf_label("içli köfte", Locale.tr_tr) == "İÇLİ KÖFTE"
    var oat: Product = Product {
        name: "Oat"
        tags: []
    }
    var zopf: Product = Product {
        name: "Zopf"
        tags: ["Swiss", "plaited"]
    }
    var oland: Product = Product {
        name: "Ölandsbröd"
        tags: []
    }
    assert list.sort_by([zopf, oland, oat], product -> product.name) == [oat, zopf, oland]  // by code point
    assert in_name_order([zopf, oland, oat], Locale.de_de) == [oat, oland, zopf]
    assert in_name_order([zopf, oland, oat], Locale.sv_se) == [oat, zopf, oland]
    assert tag_line(zopf) == "Swiss, plaited"
    assert count_pieces("Swiss,plaited", ",") == 2
}
```

## Checking text against a pattern

There is no `text.matches`, and no replacing or capturing by regular
expression. To check whether text looks like a postcode, build the type
whose `pattern` says what a postcode is:
`attempt Postcode(raw) { pattern: ... }`
([Creating values](creating-values.md)). A pattern is
a type's rule, and it is checked in linear time.

## Joining text

To join two pieces of text, use `+`
([Expressions and operators](expressions-and-operators.md)).
`text.join` is for a list.

`join` takes a list of any type built on `Text`, such as
`List<TagName>`, as `list.sum` takes a list of any number type.
Collections stay invariant everywhere else.

## Cutting and padding

`trim`, `slice` and the four cuts (`keep_first`, `keep_last`,
`drop_first` and `drop_last`) never give text longer than they were
given. The compiler knows this (`at_most_length_of`), so a type built
from the result keeps the text's `max_length` without an `attempt` arm.
It reads the bound from the cut text's type's `max_length`, from a
literal's length, or from a cut inside a cut. A count written out, as in
`keep_first(name, 12)`, bounds nothing, because `at_most_length_of`
records the argument only. `split`'s "at least one item" doesn't prove a
`min_length` either.

`lower` and `upper` can make text longer (`ß` becomes `SS`), so they
carry no such bound.

`keep_first` can split what a person sees as one character, such as an
emoji with a skin tone, which is two code points. The preview it cuts is
still valid text.

`pad_start`'s `length` is any `Int`. One at or below the text's own
length gives the text back. `fill:` is required, and is one code point
written as a text literal. A value, or a literal of any other length, is
`type.mismatch`, as a `scale:` that isn't written out is.

White space, for `trim`, is Unicode's `White_Space` property.

There is no `trim_start`, `trim_end` or `pad_end`, and no way to go
through text character by character. Each job that needs one, such as a
slug, a mask, a preview or a change of case, is an operation instead.
Unicode normalisation happens where text comes in, not in a call (below,
and [Language gaps](language-gaps-this-page-depends-on.md)).

## Arguments checked while compiling

`replace`'s `find` and `split`'s `separator` are never empty
(`never_empty`). A literal can't be `""`, and a value's type must have a
`min_length` of at least 1; otherwise the call is `type.argument_empty`
([Errors](standard-library.md#errors)). Counts and positions are never negative, as for
lists.

## Text from outside is NFC

The same character can be written in two ways: `é` as one code point, or
as `e` followed by a combining accent. Every text a request carries, in
its body, path, query string or headers, is composed to Unicode's NFC as
it is decoded, before its rules are checked. So `café` typed either way is
the same text, and `max_length` counts the code points it is kept as
([Constraints](named-types.md#constraints),
[Text is normalised](one-input.md#text-is-normalised)).

This holds for every type built on `Text`, an `Email` included, read
through the route's codecs, a map's keys among them. Text written in code
and text read back from the database are kept as they are.

## Case and sorting in a locale

Without `locale:`, case follows Unicode's full default mapping (`ß`
becomes `SS`, and `İ` becomes `i` and a combining dot), and
`list.sort_by` sorts text keys by code point, as queries sort them.

With `locale:`, case follows that locale's rules (`i` becomes `İ` in
Turkish), and `list.sort_by` orders text keys as the locale's alphabet
does (`Ö` comes after `Z` in Swedish, and beside `O` in German). Both go
through ICU4X's case mapping and collation.

Case mapping takes any `Locale`, not only the ones `locales { }`
declares, because its data is Unicode's: one table for every locale.

Sorting uses only the locales `locales { }` declares. A `Locale` your
application doesn't support sorts as the supported locale that a header
asking for it would get, else as `default:`, the same way text for people
is written ([Locales](locales.md)). A
deployable carries only its supported locales' collation, so it sorts as
`jadpo test` does. A project that sorts by a locale and declares none is
`type.temporal_locales_required`, as one that formats is. Files handed to
the compiler on their own have no application to declare locales, so
they sort by the `Locale` they are given.

Collation is the locale's default, at CLDR's default strength, so keys
that are equal by its rules keep their order. On `list.sort_by`,
`locale:` orders text keys only. With any other key it is
`type.temporal_option_unused`, whose edit removes it.

Here a shop supports British English and Swedish. A shopper writing
Swedish in Finland gets Swedish order, the supported locale of their
language. One writing Finnish gets British English order, the shop's
default, since it supports no Finnish; in Finnish's own alphabet `Ö`
would come after `Z`:

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.sv_se]
}
```

```jadpo
type ProductName = Text {
    min_length: 1
    max_length: 60
}
```

```jadpo
use shop/types { ProductName }

/// The loaves in the order the shopper's alphabet puts their names, of the
/// alphabets the shop supports.
action in_name_order(loaves: List<ProductName>, shopper: Locale) -> List<ProductName> {
    return list.sort_by(loaves, loaf -> loaf, locale: shopper)
}
```

```jadpo
use shop/types { ProductName }

test "a locale the shop doesn't support sorts in one it does" {
    var loaves: List<ProductName> = ["Zopf", "Ölandsbröd", "Oat"]
    assert in_name_order(loaves, Locale.sv_se) == ["Oat", "Zopf", "Ölandsbröd"]
    assert in_name_order(loaves, Locale.sv_fi) == ["Oat", "Zopf", "Ölandsbröd"]
    assert in_name_order(loaves, Locale.fi_fi) == ["Oat", "Ölandsbröd", "Zopf"]
}
```

## Values as text: `from`, `parse` and `format`

`text.from` gives a value as JSON carries it
([Built-in types](built-in-types.md),
[Time types](time-types.md)). It takes a
standard type with a canonical text, and `Int`, `Decimal` and `Bool`;
anything else is `type.mismatch`. It takes no enum your application
declares: such an enum has no display label
([Enums](enums.md)), so showing one is a `match`. It
takes no text either, since a value built on `Text` already is text
(`Email`, `Url` and `IpAddress` included).

A standard type's canonical text is the one form
[the contract](every-standard-type-meets-one-contract.md) writes it as:

- A standard enum's code is its variant's entry in its table: a
  `Country`'s `GB`, a `Zone`'s IANA name, a `Locale`'s BCP 47 tag, a
  `FileType`'s media type (its variants are `png`, `jpeg`, `webp`, `pdf`,
  `csv`, `tsv` and `json`), and a `Weekday`'s own name (`monday`), which
  has no other code. Its `format` is its CLDR name where it has one.
- An `Instant` ends in `Z`.
- A `ZonedTime` is RFC 9557
  (`2026-10-25T09:30:00+00:00[Europe/London]`), and is read only with the
  offset its zone has then.
- An `InstantRange` is ISO 8601's interval, `<start>/<end>`, its start
  not after its end (`start_not_after_end`).
- A `Duration` is the ISO 8601 form JSON already carries, in hours
  (`PT26H`).

`text.parse(raw, as: T)` reads a value from its canonical text. `as:` is
a type written out: a standard type, or a type built on one or on `Text`,
`Int`, `Decimal` or `Bool`. Your application's enum or object is
`type.mismatch`. Text that isn't the type at all fails `malformed`, an
`Email` that isn't an address included. The rules of the type `as:`
names break by their own names, as a construction's do. Text read as
text is never `malformed`.

`text.format(value, locale:)` gives text for people:

- `Email`, `Url`, `IpAddress`, `Uuid` and `FileType` as their canonical
  text.
- A `Weekday` as CLDR's stand-alone weekday name.
- A `Locale` as CLDR's display name ("British English").
- A `Zone` as CLDR's generic location format ("UK Time").
- A `Duration` in CLDR's long unit names, joined as the locale joins a
  list of units ("1 hour, 30 minutes").
- An `InstantRange` as its two ends in its format, joined by ` – `,
  CLDR's English interval fallback.

`zone:` and `format:` are options of the time types only, as their
entries say. A missing `format:` is `type.temporal_format_invalid`, and
an option a type doesn't take is `type.temporal_option_unused`. How a
number is formatted is under [Numbers](numbers.md).

## Slugs

`text.slug` decomposes text canonically (NFD) and drops every mark
(Unicode general category M), so `é` becomes `e`. Compatibility
characters, such as `ﬁ` or a full-width `Ａ`, aren't folded: like any
other character outside ASCII, each becomes `-`.

## `text` is a reserved name

`text` is a standard-library namespace, as every standard namespace is.
It can't name a variable, parameter or declaration, so `text.` always
means the standard library. A field or a named argument may still be
called `text`:

```jadpo
type Title = Text {
    min_length: 1
    max_length: 80
}

type PosterLine = Text {}

/// A recipe's title, kept for the poster.
function poster_line(title: Title) -> PosterLine {
    var text: PosterLine = PosterLine(title)
    return text
}
```

```text
error: `text` is a standard-library namespace
 --> main.jadpo:10:9
   |
10 |     var text: PosterLine = PosterLine(title)
   |         ^^^^ reserved
  reason: Standard-library namespaces can't name a declaration, domain, library or variable, so `text.` always means the standard library.
  next: Choose another name than `text` (agent)
  rule: semantic.standard_namespace_reserved
  help: https://jadpo.dev/docs/errors/semantic.standard_namespace_reserved

check failed: 1 error, 1 file
```
