Skip to content

Smart case, and cafe finding Café

Level: 201 · your lists hold names with capitals and accents: people, cities, file names

One line: A query in lower case ignores case, and a single capital makes the whole query case-sensitive; -i and +i force one or the other. fzf also folds Latin letters with accents, so cafe finds Café and lodz finds Łódź. In every query measured here, though, the folding stopped as soon as the query itself held an accented letter: lodź finds nothing. With a capital it is patchier still, since Cafe finds Café but Lodz does not find Łódź. --literal turns folding off, and the locale changes nothing.

Measured

demo/words.txt holds French café and Polish żółć and Łódź, each spelled with and without capitals and accents:

Verified output of fzfcase_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ cat words.txt
Café
cafe
CAFÉ
café
żółć
Żółć
zolc
Łódź
lodz
ŁÓDŹ

$ fzf -f cafe < words.txt
Café
cafe
CAFÉ
café

$ fzf -f Cafe < words.txt
Café

$ fzf -f CAFE < words.txt
CAFÉ

$ fzf -f Cafe -i < words.txt
Café
cafe
CAFÉ
café

$ fzf -f cafe +i < words.txt
cafe
café

$ fzf -f café < words.txt
Café
CAFÉ
café

$ fzf -f cafe --literal < words.txt
cafe

$ fzf -f zolc < words.txt
żółć
Żółć
zolc

$ fzf -f żółć < words.txt
żółć
Żółć

$ fzf -f Zolc < words.txt; echo "status $?"
status 1

$ fzf -f lodz < words.txt
Łódź
lodz
ŁÓDŹ

$ fzf -f łódź < words.txt
Łódź
ŁÓDŹ

$ fzf -f Łódź < words.txt
Łódź

$ fzf -f Lodz < words.txt; echo "status $?"
status 1

$ fzf -f lodź < words.txt; echo "status $?"
status 1

$ fzf -f lodz --literal < words.txt; echo "status $?"
lodz
status 0

$ LC_ALL=C.UTF-8 fzf -f lodz < words.txt
Łódź
lodz
ŁÓDŹ

$ printf 'caf\303\251 is NFC\ncafe\314\201 is NFD\n' > forms.txt; fzf -f cafe < forms.txt
café is NFC
café is NFD

$ fzf -f café < forms.txt
café is NFC

$ q=ŁÓDŹ; echo "length ${#q}, lowered $(printf '%s' "$q" | tr '[:upper:]' '[:lower:]')"
length 7, lowered ŁÓdŹ
  • Smart case. cafe found all four spellings. Cafe found only Café, and CAFE only CAFÉ: once the query has a capital, every letter's case has to match. -i gave Cafe all four back, and +i held cafe to the two lower-case lines.
  • Accents fold one way. cafe found Café, so fzf compared that é as an e. café did not find cafe: the accented query matched only lines that have the accent. --literal turned the folding off, and cafe then found only cafe.
  • Polish letters fold in a lower-case query. zolc found żółć, Żółć and zolc, and lodz found Łódź, lodz and ŁÓDŹ. With the accents typed, żółć found the two accented spellings and łódź the two accented Łódź, still ignoring case.
  • One accent turns the folding off. lodź is lodz with one accent added, and it found nothing at all, where lodz found three lines.
  • A capital does not fold every letter. Cafe still found Café, but Lodz did not find Łódź, and Zolc did not find Żółć. Without -i, the only query here with a capital in it that found Łódź is Łódź itself.
  • The locale changed nothing. LC_ALL=C.UTF-8 printed the same three lines as the runner's LC_ALL=C.
  • é can be written two ways. forms.txt holds café twice. The first is the single character é (NFC). The second is an e followed by a combining acute accent (NFD). cafe found both, because the decomposed line simply has one extra character after the e. café, typed with the single character, found only the NFC line.
  • Do not lower-case a query yourself. Under LC_ALL=C, bash counted ŁÓDŹ as 7 characters, which is its byte count. tr '[:upper:]' '[:lower:]' lowered only the ASCII D. Lowering a query in the shell depends on the locale; -i does not.

On a Mac

Nothing changes: the key is shared. The Mac's tr and GNU tr in the Linux image both left Ł, Ó and Ź alone under LC_ALL=C, and bash 3.2 and bash 5.2 both counted 7.

In zsh and fish

zsh hands fzf the same bytes and gets the same lines. Lowering the query is where it differs:

Verified output of fzfcase_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ fzf -f lodz < words.txt
Łódź
lodz
ŁÓDŹ

$ q=ŁÓDŹ; print -r -- "length ${#q}, lowered ${(L)q}"
length 7, lowered ŁÓdŹ

$ LC_ALL=C.UTF-8 zsh -fc 'q=ŁÓDŹ; print -r -- "length ${#q}, lowered ${(L)q}"'
length 4, lowered łódź

$ fzf -f ŁÓDŹ -i < words.txt
Łódź
ŁÓDŹ

Under LC_ALL=C, zsh also counted bytes, and ${(L)q} lowered only the D. The same line in a zsh started with LC_ALL=C.UTF-8 counted 4 characters and lowered all of them. fzf -i found both Łódź lines from the query exactly as typed.

fish works in characters even under LC_ALL=C:

Verified output of fzfcase_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ fzf -f lodz < words.txt
Łódź
lodz
ŁÓDŹ

$ set q ŁÓDŹ; echo "length "(string length -- $q)", lowered "(string lower -- $q)
length 4, lowered łódź

$ fzf -f (string lower -- ŁÓDŹ) < words.txt
Łódź
ŁÓDŹ

$ fzf -f ŁÓDŹ -i < words.txt
Łódź
ŁÓDŹ

string length counted 4 and string lower gave łódź under the same LC_ALL=C that made bash and zsh count bytes. So in fish, fzf -f (string lower -- $q) finds what -i finds. -i works in all three shells without depending on the locale.

lowering ŁÓDŹ under LC_ALL=C bash zsh fish
length 7 (${#q}) 7 (${#q}) 4 (string length)
lowered ŁÓdŹ (tr) ŁÓdŹ (${(L)q}) łódź (string lower)

If you are coming from another library

  • Encodings. Normalization ↗ explains why é has two spellings, and what NFC and NFD are. forms.txt above is that difference reaching a fuzzy finder.
  • Encodings. Case is not a per-character operation ↗ shows why lower-casing is more than a table of letters. That is one more reason to leave case to -i.
  • Rust. Fuzzy finding ↗ has a Polish section on fzf's folding. It also covers the macOS Option key, which types ć when you meant Alt-C.

See also