Skip to content

fix(format date): render locales whose format carries %E or %O - #18918

Merged
fdncred merged 1 commit into
nushell:mainfrom
aron-intframe:fix/format-date-locale-alt-modifier
Aug 27, 2026
Merged

fdncred merged 1 commit into
nushell:mainfrom
aron-intframe:fix/format-date-locale-alt-modifier

Conversation

@aron-intframe

@aron-intframe aron-intframe commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

format date errored under any locale whose own LC_TIME strings carry the POSIX modifiers %E/%O, such as th_TH's %Ey (Buddhist era). chrono expands %x, %X, %c and %r from those strings but implements neither modifier, so date now | format date '%x %X' from default_env.nu failed at every startup for those users.

Those four specifiers are now expanded here from the locale's own tables with %E/%O stripped, which is what POSIX says to do when there is no alternative representation. Expanding rather than falling back to another locale keeps the locale's field order (22/10/21, not 10/22/21); only the era is lost, and chrono cannot render it anyway. An empty t_fmt_ampm (de_DE, fr_FR, nl_NL) is left for chrono so %r does not go blank.

This makes pure-rust-locales a direct dependency of nu-command. chrono already pulls it in for unstable-locales, so Cargo.lock is +1 −0 with no new package.

6 new unit tests, mutation-checked. cargo test -p nu-command --test tests: 2917 passed. fmt and clippy clean. Linux and nu-command only.

User-facing changes (Release notes)

format date no longer errors under Thai, Lao and similar locales

format date with %x, %X or %c failed with nu::shell::type_mismatch, invalid format under locales such as th_TH and lo_LA, which write the year with a modifier the date library does not implement. The default config runs date now | format date '%x %X', so affected users saw it at every startup. These now render in the locale's own field order, without the era:

# with LC_TIME=th_TH.UTF-8
date now | format date '%c'
# before: Error: nu::shell::type_mismatch, invalid format
# after:  ศ. 22 ต.ค. 21, 20:00:12

A modifier you type yourself no longer errors either: %Ey and %Od now render as %y and %d would.

Additional notes

Fixes #15266.

chrono expands %x, %X, %c and %r from the locale's own format strings at
render time, so a modifier the user never typed can appear at that point.
th_TH and lo_LA both carry %Ey (Buddhist era) in d_fmt, and th_TH also
in d_t_fmt. chrono implements neither %E nor %O and reports a format
error, so every format date under those locales failed -- including the
one in default_env.nu, which greeted those users with a type mismatch on
startup (nushell#15266).

POSIX says an implementation with no alternative representation must
behave as if the modifier were absent. Expand the locale specifiers here
instead, with %E and %O stripped, and hand chrono a format it can render.

Expanding rather than falling back to another locale keeps the locale's
own field order: th_TH now reads 22/10/21, where a fallback to POSIX
would have given 10/22/21.

A locale can leave one of these strings empty -- de_DE, fr_FR and nl_NL
have no am/pm form -- so an empty expansion is left alone for chrono to
resolve, rather than blanking the output.

Checked against chrono's own output across 14 locales x 5 formats: the
only differences are the six that used to be errors.
Comment thread Cargo.toml Resolved
@fdncred
fdncred merged commit a854062 into nushell:main Aug 27, 2026
16 checks passed
@github-actions github-actions Bot added this to the v0.116.0 milestone Aug 27, 2026
@aron-intframe

Copy link
Copy Markdown
Contributor Author

Pushed upstream as you asked: chronotope/chrono#1816. It is one line in parse_next_item, skipping the modifier per POSIX rather than expanding locale strings the way we do here:

let spec = if spec == 'E' || spec == 'O' { next!() } else { spec };

chrono's suite is green with it, and both new tests were mutation-checked — removing that line turns them red with the original panic:

$ cargo test --features unstable-locales
test result: ok. 301 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 281 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

While building that I found two things wrong with what I told you here, so before anything else:

1. This PR does not fix all the affected locales. az_IR and fa_IR still error. They have an empty t_fmt_ampm, so my carve-out leaves %r for chrono — the thing that keeps de_DE from rendering blank — but chrono's fallback for an empty am/pm form is t_fmt, and for those two that is %OH:%OM:%OS. The modifier walks straight back in:

az_IR  T_FMT_AMPM="" T_FMT="%OH:%OM:%OS"
       chrono-today=Err merged=Err proposed=Ok("20:00:12")
de_DE  T_FMT_AMPM="" T_FMT="%T"
       chrono-today=Ok("20:00:12") merged=Ok("20:00:12") proposed=Ok("20:00:12")

2. The affected-locale list I gave you was wrong. I said th_TH and lo_LA and flagged that I had not enumerated exhaustively. Scanning every LC_TIME block in pure-rust-locales 0.8.2, it is nine:

az_IR: D_FMT=%Oy/%Om/%Od | T_FMT=%OH:%OM:%OS
fa_IR: D_FMT=%Oy/%Om/%Od | T_FMT=%OH:%OM:%OS
lo_LA: D_FMT=%d/%m/%Ey
lzh_TW: D_FMT=%OC%Oy年%B%Od日 | T_FMT_AMPM=%p %OI時%OM分%OS秒
mnw_MM: D_FMT=%OC%Oy %b %Od %A | T_FMT=%OI:%OM:%OS %p
my_MM: D_FMT=%OC%Oy %b %Od %A | T_FMT=%OI:%OM:%OS %p
or_IN: D_FMT=%Od-%Om-%Oy | T_FMT=%OI:%OM:%OS %p
shn_MM: D_FMT=%OC%Oy %b %Od %A | T_FMT=%OH:%OM:%OS %p
th_TH: D_FMT=%d/%m/%Ey

The seven I never named are fixed by what merged — chrono has %C, %e, %I, %p, so stripping was enough, and lzh_TW %c now renders 2021年十月22日 (週五) 20時00分12秒. Only the two %r cases needed code.

#18924 has that fix: substitute t_fmt ourselves when t_fmt_ampm is empty, which is the same fallback chrono makes, just through the stripping path. de_DE, fr_FR, nl_NL byte-identical. Mutation check on it:

test strings::format::date::test::resolves_the_am_pm_fallback_when_it_also_carries_a_modifier ... FAILED
az_IR %r did not render: Error(TypeMismatch { err_message: "invalid format", span: Span(TEST) })

and green with the fix: cargo test -p nu-command --lib 814 passed, --test tests 2930 passed, fmt and clippy clean.

If chrono#1816 lands and you pick up the release, resolve_locale_specifiers and the pure-rust-locales direct entry you asked about can both be deleted.

Not verified: Linux and Rust stable only, nu-command only. I did not run the full workspace suite, the Windows/macOS paths, or chrono's MSRV/wasm/no-std CI jobs.

fdncred pushed a commit that referenced this pull request Aug 28, 2026
…carries `%O` (#18924)

Follow-up to #18918 (which fixed #15266). Two of the affected locales
still fail on `main`, and the set of affected locales turned out to be
larger than that PR said. Both found while working up the upstream
chrono fix @fdncred asked for.

# Description

## The bug #18918 left behind

`az_IR` and `fa_IR` have an **empty** `t_fmt_ampm`, so #18918
deliberately leaves `%r` alone for chrono to resolve — that carve-out is
what keeps `de_DE`/`fr_FR`/`nl_NL` from rendering blank. But chrono's
fallback for an empty am/pm form is the locale's `t_fmt`, and for these
two that is `%OH:%OM:%OS`. The modifier we just removed comes straight
back in:

```
az_IR  T_FMT_AMPM="" T_FMT="%OH:%OM:%OS"
       chrono-today=Err merged=Err proposed=Ok("20:00:12")
fa_IR  T_FMT_AMPM="" T_FMT="%OH:%OM:%OS"
       chrono-today=Err merged=Err proposed=Ok("20:00:12")
de_DE  T_FMT_AMPM="" T_FMT="%T"
       chrono-today=Ok("20:00:12") merged=Ok("20:00:12") proposed=Ok("20:00:12")
fr_FR  T_FMT_AMPM="" T_FMT="%T"
       chrono-today=Ok("20:00:12") merged=Ok("20:00:12") proposed=Ok("20:00:12")
nl_NL  T_FMT_AMPM="" T_FMT="%T"
       chrono-today=Ok("20:00:12") merged=Ok("20:00:12") proposed=Ok("20:00:12")
```

So `LC_TIME=fa_IR.UTF-8` still gets `nu::shell::type_mismatch, invalid
format` from `date now | format date %r`.

The fix is to make chrono's own fallback explicit: when `t_fmt_ampm` is
empty, substitute `t_fmt` ourselves, so it goes through the stripping
path like everything else. `de_DE`, `fr_FR` and `nl_NL` are
byte-identical, since their `t_fmt` is `%T`.

## The affected locale list was wrong

#18918 named `th_TH` and `lo_LA` and admitted the set was not enumerated
exhaustively. It is nine, from scanning every `LC_TIME` block in
`pure-rust-locales` 0.8.2:

```
az_IR: D_FMT=%Oy/%Om/%Od | D_T_FMT=%A %Oe %B %Oy، %OH:%OM:%OS | T_FMT=%OH:%OM:%OS
fa_IR: D_FMT=%Oy/%Om/%Od | D_T_FMT=%A %Oe %B %Oy، %OH:%OM:%OS | T_FMT=%OH:%OM:%OS
lo_LA: D_FMT=%d/%m/%Ey | D_T_FMT=%a %e %b %Ey, %H:%M:%S
lzh_TW: D_FMT=%OC%Oy年%B%Od日 | D_T_FMT=%OC%Oy年%B%Od日 (%A) %OH時%OM分%OS秒 | T_FMT=%OH時%OM分%OS秒 | T_FMT_AMPM=%p %OI時%OM分%OS秒
mnw_MM: D_FMT=%OC%Oy %b %Od %A | D_T_FMT=%OC%Oy %b %Od %A %OI:%OM:%OS %Op %Z | T_FMT=%OI:%OM:%OS %p
my_MM: D_FMT=%OC%Oy %b %Od %A | D_T_FMT=%OC%Oy %b %Od %A %OI:%OM:%OS %Op %Z | T_FMT=%OI:%OM:%OS %p
or_IN: D_FMT=%Od-%Om-%Oy | D_T_FMT=%Oe %B %Oy %OI:%OM:%OS %p %Z | T_FMT=%OI:%OM:%OS %p
shn_MM: D_FMT=%OC%Oy %b %Od %A | D_T_FMT=%OC%Oy %b %Od %A %OI:%OM:%OS %Op %Z | T_FMT=%OH:%OM:%OS %p
th_TH: D_FMT=%d/%m/%Ey | D_T_FMT=%a %e %b %Ey, %H:%M:%S
```

The good news is that the existing approach already covers the seven
that were never named — chrono has `%C`, `%e`, `%I`, `%p` and the rest,
so stripping is enough. `lzh_TW` `%c` renders `2021年十月22日 (週五)
20時00分12秒`, `my_MM` `%X` renders `08:00:12 ညနေ`. Only the two `%r` cases
above needed code. A test now pins all nine so the next locale table
bump cannot quietly break one.

# User-Facing Changes

`format date` with `%r` under `LC_TIME=az_IR` or `fa_IR` renders instead
of erroring. Nothing else changes: every other locale, including the
empty-am/pm ones, produces the same bytes as before.

# Tests + Formatting

- `resolves_the_am_pm_fallback_when_it_also_carries_a_modifier` — the
new case, `az_IR`/`fa_IR`.
- `falls_back_to_the_plain_time_format_when_the_locale_has_no_am_pm` —
the old `de_DE`/`fr_FR` test, updated for the new resolution and still
asserting the rendered output is unchanged.
- `renders_every_locale_that_carries_an_alternative_modifier` — `%x`,
`%X`, `%c`, `%r` under all nine.

Mutation-checked rather than trusted green. Dropping the fallback turns
three tests red with the reported error:

```
test strings::format::date::test::resolves_the_am_pm_fallback_when_it_also_carries_a_modifier ... FAILED
test strings::format::date::test::falls_back_to_the_plain_time_format_when_the_locale_has_no_am_pm ... FAILED
test strings::format::date::test::renders_every_locale_that_carries_an_alternative_modifier ... FAILED
az_IR %r did not render: Error(TypeMismatch { err_message: "invalid format", span: Span(TEST) })
```

Green:

```
$ cargo test -p nu-command --lib
test result: ok. 814 passed; 0 failed; 2 ignored; 0 filtered out; across 4 groups

$ cargo test -p nu-command --test tests
test result: ok. 2930 passed; 0 failed; 35 ignored; 0 filtered out; across 29 groups
```

`cargo fmt --all -- --check` and `cargo clippy -p nu-command
--all-targets` are clean.

Linux, Rust stable, `nu-command` only. I did not run the full workspace
suite or the Windows/macOS paths.

# After Submitting

The upstream fix @fdncred asked for is chronotope/chrono#1816 — one line
in chrono's `parse_next_item` that skips `%E`/`%O` per POSIX, with the
same nine locales tested. If that lands and nushell picks up the
release, this whole `resolve_locale_specifiers` layer and the
`pure-rust-locales` direct dependency can be deleted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: aron-intframe <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
@fdncred fdncred added notes:fixes Noted in "Bug fixes" section notes:ready Indicates Ready for Release notes labels Sep 8, 2026
@fdncred

fdncred commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

I just noticed that you didn't follow the PR template. Please update to follow it. We use the information for making release notes.

@aron-intframe

Copy link
Copy Markdown
Contributor Author

You're right, and it isn't close: the template was rewritten four months before I opened this PR, so I had no excuse.

2026-04-17T16:52:45Z 80616834 Update PR template (#18026)

The body here had ### What was happening / ### The rule used / ### Verification and not a single second-level section, so there was nothing for you to lift release notes from. I've rewritten it in place into the three template sections, same content, with the user-facing part rewritten in release-note style (including the behaviour change where a user-typed %Ey used to error and now renders, since that is user-visible and belongs in the notes):

template requires: ['## Description', '## User-facing changes (Release notes)', '## Additional notes']
PR 18918 now has: ['## Description', '## User-facing changes (Release notes)', '## Additional notes']

I did the same to the follow-up #18924, which you also labelled notes:ready. It used the pre-2025 template (# Description, # User-Facing Changes) at first level, which devdocs/HOWTOS.md explicitly lists as not detected. Both are three-section now. No code changed, description edits only.

One thing I hit while checking, which is not about my PRs. The generator in nu_scripts, make_release/notes/generate.nu (last commit 2026-03-01), matches only the old heading, while the template (2026-04-17) tells contributors to write the new one:

    mut notes = if "## Release notes summary" in $pr.body {
      $pr.body | extract-notes
    } else if $has_ready_label {
      # If no release notes summary exists but ready label is set, treat as empty
      if not $has_hall_of_fame_label {
        # Hall of fame does not need release notes section necessarily
        $pr = $pr | add-notice warning "no release notes section but notes:ready label"

Across the last 120 PRs merged into main:

(has '## Release notes summary', has '## User-facing changes (Release notes)') -> count
(False, True) 94
(True, False) 3

So if that script is still what builds the notes, 94 of those fall into the notes:ready branch with an empty summary rather than being extracted, silently, with only a warning. I followed the template heading because that is what you asked for, but if the script is the thing that actually reads it, say so and I'll switch both PRs to ## Release notes summary (or carry both headings) in a minute.

Not verified: I could not execute generate.nu, there is no nu on this machine. I evaluated its "## Release notes summary" in $pr.body condition and ran the 120-PR survey in Python against the real bodies from the API, not through the script itself. Linux only, no rebuild of the crate, nothing pushed.

@fdncred

fdncred commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The response and all the description and user facing changes text is way too verbose. You should tell you LLM to be more concise. Nobody writes like that.

@aron-intframe

Copy link
Copy Markdown
Contributor Author

You're right, and the gap is measurable. I counted words in the bodies of the last 168 PRs merged into main by other authors, then mine:

168 merged PRs by other authors: median 280 words, p90 567
  #18918: 782 words
  #18924: 740 words

Both sat past the 90th percentile, and CONTRIBUTING.md asks for the release notes section to be "brief and simple to understand", with technical detail moved out of it. I've rewritten both bodies in place, same three template sections, no code touched:

18918 patched, now 330 words
18924 patched, now 270 words

The user-facing section is now one short paragraph and one before/after example per PR. The locale tables, the mutation-test transcripts and the per-locale output dumps are deleted, not relocated. Same rule from here on, including in comments like this one.

@fdncred

fdncred commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

notes:fixes Noted in "Bug fixes" section notes:ready Indicates Ready for Release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug Report: Type Mismatch Error in default_env.nu When Using format date '%x %X'

2 participants