You're right that assigning the LC_* shell variables does cause bash to call POSIX setlocale() for the corresponding category with the value of the variable whether they're exported or not. For LANG, it calls setlocale(LC_ALL, thevalue) followed setlocale(LC_*) again for all the LC_* variable. For LANGUAGE, it doesn't do anything.
Now, bash is the shell of the GNU project. For localization of text, it uses GNU gettext, also known as libintl. It even comes with its own version bundled with the source which you can compile in bash if you call the configure script with --with-included-gettext.
gettext looks up message translations in a per-language database. Which language it is is determined by the value of LC_MESSAGES category though can be overridden by the $LANGUAGE environment variable.
According to the gettext documentation, the previous call to setlocale() should be the one that determines the value for the category, but there are some complications:
For multithreaded applications, there is currently no standard API that gettext can use to retrieve that value. bash is not a multithreaded application, but even what setlocale(category, NULL) returns is implementation defined and in practice not always usable.
So in practice, gettext only uses setlocale() to retrieve the language name when built as part of the GNU libc or on a system where the libc is the GNU libc (like the one built with bash with --with-included-gettext on a GNU system) because it knows it can rely on it.
On other systems, it uses getenv() to determine the locale, irrespective of how setlocale() was invoked earlier, which is why you're seeing that behaviour.
Exporting those variables is an easy work around. One could argue that if they're not exported, they're not part of the environment anyway. POSIX is not very clear on that. Another way to look at it is that the translation is not done by bash, but by a third party mechanism, so just like when executing other commands, we need to use environment variables to pass the locale information between the two software (here bash and gettext).
Now, on GNU systems, it actually gets worse.
As seen above, gettext is included in the GNU libc. $LANGUAGE takes precedence over $LC_MESSAGE but $LANGUAGE is not part of the POSIX locale API, that's an extension on top of it.
So while on a GNU system, gettext will use setlocale(LC_MESSAGES, NULL) to get the name for the LC_MESSAGES category, for LANGUAGE, it always uses getenv(), LANGUAGE is not a locale category.
The problem is that bash manages the environment by itself as part of its variable handling, disconnected from the libc's environ[] array. It does have its own getenv() which does query its own version of the environment, but when gettext is built as part of the libc, and bash is dynamically linked dgettext() calls the getenv() from the libc as that's an internal call within the libc, not bash's one, so will only get the $LANGUAGE value from the time bash was started.
So on GNU systems, unless bash was linked statically or built with --with-included-gettext, any change to $LANGUAGE will be ignored for the messages generated by bash, whether the variable is exported or not. On other systems, that's fine (as long as $LANGUAGE is exported) as gettext is not part of the libc, so it does call bash's getenv().
On Debian:
$ LANGUAGE=fr bash -c 'LANGUAGE=es; eval fi'
bash: eval: ligne 0: erreur de syntaxe près du symbole inattendu « fi »
bash: eval: ligne 0: `fi'
(message in French, the value of $LANGUAGE at the time bash was invoked, not Spanish).
Actually it's not much better with other shells.
zsh is not translated to other languages but does use strerror() which does use gettext internally on GNU systems:
$ LANGUAGE=fr zsh -c 'LANGUAGE=es; true</x; LANGUAGE=en; true</a; true < /etc/shadow'
zsh:1: no existe el archivo o el directorio: /x
zsh:1: no existe el archivo o el directorio: /a
zsh:1: permission denied: /etc/shadow
The LANGUAGE=es was honoured but see how the second message for ENOENT has not been displayed in English (presumably cached by gettext somehow; that cache should have been invalidated when $LANGUAGE changed but that was not the case).
LC_MESSAGESdoes have some effect. How does that post explain either of these observations? – Nov 16 '18 at 07:00LANGandLC_*variables are a special case; they get validated when assigned in a way that no (few?) other variables do. But the point is that variables don’t become visible (especially to external programs) and effective until they are exported, and transformed from shell variables into environment variables. … (Cont’d) – Scott - Слава Україні Nov 16 '18 at 07:29getenv, and there’s no mechanism for short-circuiting them to look for a *shell variable* calledLC_MESSAGESwhen no environment variable by that name exists. – Scott - Слава Україні Nov 16 '18 at 07:29setlocale(), notgetenv()to query the current locale.bashas shown by the OP does callsetlocale()when theLC_*variables are modified to set the current locale, so it seems there's something wrong with that library or possibly the waybashuses it. – Stéphane Chazelas Nov 16 '18 at 07:40setlocaleis being called, and AFAIK that function doesn't have an option to just validate but not set. – Nov 16 '18 at 07:40getenv()ifsetlocale()is not available. It is available on macOS though... but maybe the Homebrew build scripts are't picking it up, or are ignoring it for whatever reason. – Kusalananda Nov 16 '18 at 08:11setlocale("fr_FR.UTF-8", LC_ALL)followed bydgettext(). The messages are not translated into the language passed tosetlocale()(thoughtrussshows the corresponding.mofile is being open if there's noLC_*variable in the environment), but instead into the language referenced by the LC_* variables. One would need to look at the source to see why, but that very much looks like a bug. – Stéphane Chazelas Nov 16 '18 at 08:21exporting of variables. – Nov 19 '18 at 14:22bash: syntax error near unexpected tokenfi'even after exporting and no Japanese text.locale` however reflects the change. Anyone knows anything? – Pacerier May 29 '19 at 10:31