diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ec0469d12..9fa47731f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,7 +20,6 @@ Color and key binding variables are no longer set in universal scope 2. universal variables as a source of truth are easy to misunderstand, compared to configuration files like ``config.fish``. - Deprecations and removed features --------------------------------- - Erasing a color variable (e.g. by running ``set -e fish_color_command``) @@ -30,6 +29,10 @@ Deprecations and removed features - ``fish_config theme choose`` now clears only color variables that were set by earlier invocations of a ``fish_config theme choose`` command (which includes fish's default theme). +Scripting improvements +---------------------- +- New :ref:`status language ` commands allow showing and modifying language settings for fish messages without having to modify environment variables. + Interactive improvements ------------------------ - When typing immediately after starting fish, the first prompt is now rendered correctly. diff --git a/doc_src/cmds/_.rst b/doc_src/cmds/_.rst index 57ddea8f7..436feb621 100644 --- a/doc_src/cmds/_.rst +++ b/doc_src/cmds/_.rst @@ -23,6 +23,8 @@ If other programs launched via fish should respect these locale variables they h For :envvar:`LANGUAGE` you can use a list, or use colons to separate multiple languages. +If the :ref:`status language set ` command was used, its arguments specify the language precedence, and the environment variables are ignored. + Options ------- diff --git a/doc_src/cmds/status.rst b/doc_src/cmds/status.rst index cbf2bea1d..1bc2ca673 100644 --- a/doc_src/cmds/status.rst +++ b/doc_src/cmds/status.rst @@ -33,6 +33,7 @@ Synopsis status list-files [PATH ...] status terminal status test-terminal-feature FEATURE + status language [list-available|set [LANGUAGE ...]|unset] Description ----------- @@ -146,6 +147,32 @@ The following operations (subcommands) are available: Currently the only available *FEATURE* is :ref:`scroll-content-up `. An error will be printed when passed an unrecognized feature. +.. _status-language: + +**language** + Show or modify message localization settings. + When invoked without arguments, the current language settings are shown. + + Available subcommands: + + **list-available** + prints the language names for which fish has translations. + These names can be used with the **set** subcommand. + + **set** + sets the language precedence for fish's messages. + Overrides language settings configured via :ref:`environment variables `, but only applies to fish itself, not to any child processes. + Takes a list of language names from the set shown by the **list-available** subcommand. + For some languages, fish's translation catalogs are incomplete, meaning not all messages can be shown in these languages. + Therefore, we allow specifying a list here, with translations taken from the first specified language which has a translation available for a message. + For example, after running ``status language set pt_BR fr``, all messages which have a translation into Brazilian Portuguese will be shown in that language. + The remaining messages will be shown in French, if a French translation is available. + If none of the specified languages have a translation available for a message, the message will be shown in English. + + **unset** + undoes the effects of the **set** subcommand. + Language settings will be taken from environment variables again. + Notes ----- diff --git a/doc_src/language.rst b/doc_src/language.rst index d3ae36f81..d2ad5130d 100644 --- a/doc_src/language.rst +++ b/doc_src/language.rst @@ -1549,7 +1549,8 @@ You can change the settings of fish by changing the values of certain variables. .. describe:: Locale Variables - Locale variables such as :envvar:`LANG`, :envvar:`LC_ALL`, :envvar:`LC_MESSAGES`, :envvar:`LC_NUMERIC` and :envvar:`LC_TIME` set the language option for the shell and subprograms. See the section :ref:`Locale variables ` for more information. + Locale variables such as :envvar:`LANG`, :envvar:`LC_ALL`, :envvar:`LC_MESSAGES`, :envvar:`LC_NUMERIC` and :envvar:`LC_TIME` set the language option for the shell and subprograms. + See the section :ref:`Locale variables ` and :ref:`status language ` for more information. .. describe:: Color variables diff --git a/po/de.po b/po/de.po index 93a3c4f9a..7d5cb0966 100644 --- a/po/de.po +++ b/po/de.po @@ -83,6 +83,10 @@ msgstr "$status ist kein gültiger Befehl. Siehe `help %s`" msgid "%s" msgstr "" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "%s %s: %s: ungültiger Unterbefehl\n" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -881,6 +885,10 @@ msgstr "Abbruch" msgid "Abort (Alias for SIGABRT)" msgstr "" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "Adressbereichsfehler" @@ -1229,6 +1237,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "Funktionen auflisten oder entfernen" @@ -1279,6 +1290,9 @@ msgstr "Nie" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "Kein TTY für interaktive Shell (tcgetpgrp schlug fehl)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1775,6 +1789,9 @@ msgstr "Befehlsersetzung sind in Befehlsposition nicht erlaubt. Probier stattdes msgid "completion reached maximum recursion depth, possible cycle?" msgstr "Vervollständigung hat die maximale Rekursionstiefe erreicht, möglicher Kreis?" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "Verweis auf ein Verzeichnis" @@ -1800,10 +1817,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "aus der Quelldatei %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "in der Befehlsersetzung\n" @@ -3386,6 +3410,9 @@ msgstr "" msgid "Show modification time" msgstr "Änderungsdatum anzeigen" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/en.po b/po/en.po index 2fe606675..866e66bfb 100644 --- a/po/en.po +++ b/po/en.po @@ -81,6 +81,10 @@ msgstr "" msgid "%s" msgstr "" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -879,6 +883,10 @@ msgstr "Abort" msgid "Abort (Alias for SIGABRT)" msgstr "Abort (Alias for SIGABRT)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "Address boundary error" @@ -1227,6 +1235,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "List or remove functions" @@ -1277,6 +1288,9 @@ msgstr "Never" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "No TTY for interactive shell (tcgetpgrp failed)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1773,6 +1787,9 @@ msgstr "" msgid "completion reached maximum recursion depth, possible cycle?" msgstr "" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "" @@ -1798,10 +1815,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "from sourcing file %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "in command substitution\n" @@ -3384,6 +3408,9 @@ msgstr "" msgid "Show modification time" msgstr "Show modification time" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/fr.po b/po/fr.po index 1fda25f6a..0eec8f7e6 100644 --- a/po/fr.po +++ b/po/fr.po @@ -212,6 +212,10 @@ msgstr "$status n’est pas une commande valide. Voir « help %s »" msgid "%s" msgstr "%s" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "%s %s : %s : sous-commande invalide\n" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -1010,6 +1014,10 @@ msgstr "Abandon" msgid "Abort (Alias for SIGABRT)" msgstr "Abandon (Alias pour SIGABRT)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "Erreur de frontière d’adresse" @@ -1358,6 +1366,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "Lister ou supprimer des fonctions" @@ -1408,6 +1419,9 @@ msgstr "Jamais" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "Pas de TTY pour shell interactif (tcgetpgrp a échoué)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1904,6 +1918,9 @@ msgstr "" msgid "completion reached maximum recursion depth, possible cycle?" msgstr "" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "" @@ -1929,10 +1946,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "du fichier source %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "dans la substitution de commande\n" @@ -3515,6 +3539,9 @@ msgstr "" msgid "Show modification time" msgstr "Afficher l’horodatage de modification" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/pl.po b/po/pl.po index d70f1e052..a5e8dde40 100644 --- a/po/pl.po +++ b/po/pl.po @@ -77,6 +77,10 @@ msgstr "" msgid "%s" msgstr "" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -875,6 +879,10 @@ msgstr "Przerwij" msgid "Abort (Alias for SIGABRT)" msgstr "" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "" @@ -1223,6 +1231,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "Wypisz lub usuń funkcje" @@ -1273,6 +1284,9 @@ msgstr "Nigdy" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1769,6 +1783,9 @@ msgstr "" msgid "completion reached maximum recursion depth, possible cycle?" msgstr "" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "" @@ -1794,10 +1811,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "w zastępstwie polecenia \n" @@ -3380,6 +3404,9 @@ msgstr "" msgid "Show modification time" msgstr "" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/pt_BR.po b/po/pt_BR.po index a1f67232d..d14a3d6a6 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -82,6 +82,10 @@ msgstr "" msgid "%s" msgstr "" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -880,6 +884,10 @@ msgstr "Abortado" msgid "Abort (Alias for SIGABRT)" msgstr "Abortado (Outro nome para SIGABRT)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "Erro de fronteira de endereço (Falha de segmentação)" @@ -1228,6 +1236,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "Lista ou remove funções" @@ -1278,6 +1289,9 @@ msgstr "Nunca" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "Não há TTY para shell interativo (falha em tcgetpgrp)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1774,6 +1788,9 @@ msgstr "" msgid "completion reached maximum recursion depth, possible cycle?" msgstr "" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "" @@ -1799,10 +1816,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "do arquivo %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "na substituição de comando\n" @@ -3385,6 +3409,9 @@ msgstr "" msgid "Show modification time" msgstr "Mostra horário de modificação" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/sv.po b/po/sv.po index 86f178ca4..b207e0782 100644 --- a/po/sv.po +++ b/po/sv.po @@ -78,6 +78,10 @@ msgstr "" msgid "%s" msgstr "" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "" @@ -876,6 +880,10 @@ msgstr "Avbrott" msgid "Abort (Alias for SIGABRT)" msgstr "Avbrott (Alias för SIGABRT)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "Minnesadress korsar segmentgräns" @@ -1224,6 +1232,9 @@ msgstr "" msgid "Jobs getting started or continued" msgstr "" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "Visa eller ta bort funktioner" @@ -1274,6 +1285,9 @@ msgstr "Aldrig" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "" @@ -1770,6 +1784,9 @@ msgstr "" msgid "completion reached maximum recursion depth, possible cycle?" msgstr "" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "" @@ -1795,10 +1812,17 @@ msgid "" "`help %s` will show an online version\n" msgstr "" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "i kommandosubstitution\n" @@ -3381,6 +3405,9 @@ msgstr "" msgid "Show modification time" msgstr "Visa ändringstid" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 6b06737e7..72ed246d3 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -104,6 +104,10 @@ msgstr "$status 不是有效的命令。参见 `help %s`" msgid "%s" msgstr "%s" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "%s %s: %s: 无效的子命令\n" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "%s %s: 名为 %s 且适用于命令 %s 的缩写已存在,无法重命名 %s\n" @@ -902,6 +906,10 @@ msgstr "中止" msgid "Abort (Alias for SIGABRT)" msgstr "中止 (SIGABRT 的别名)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "地址边界错误" @@ -1253,6 +1261,9 @@ msgstr "正在改变状况的作业" msgid "Jobs getting started or continued" msgstr "正在启动或继续的作业" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "列出或移除函数" @@ -1303,6 +1314,9 @@ msgstr "从不" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "交互 shell 没有 TTY (tcgetpgrp 失败)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "未找到通配符 '%s' 的匹配项。参见 `help %s`。" @@ -1799,6 +1813,9 @@ msgstr "在命令位置不允许替换命令。试使用 var=(你的命令) $var msgid "completion reached maximum recursion depth, possible cycle?" msgstr "补全达到了最大的递归深度,可能存在循环?" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "目录符号链接" @@ -1827,10 +1844,17 @@ msgstr "" "文档可能未安装。\n" "`help %s` 将显示在线版本\n" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "从源文件 %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "在命令替换中\n" @@ -3413,6 +3437,9 @@ msgstr "显示与光标下记号相关的 man 页面条目或函数描述" msgid "Show modification time" msgstr "显示修改时间" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "显示自修改时间以来的秒数" diff --git a/po/zh_TW.po b/po/zh_TW.po index 5d35433d3..9004b15bd 100644 --- a/po/zh_TW.po +++ b/po/zh_TW.po @@ -77,6 +77,10 @@ msgstr "$status 不是有效的命令。參見「help %s」" msgid "%s" msgstr "%s" +#, c-format +msgid "%s %s: %s: invalid subcommand\n" +msgstr "%s %s:%s:無效的子命令\n" + #, c-format msgid "%s %s: Abbreviation %s already exists for commands %s, cannot rename %s\n" msgstr "%s %s:名為 %s 且適用於命令 %s 的縮寫已存在,無法重新命名 %s\n" @@ -876,6 +880,10 @@ msgstr "中止" msgid "Abort (Alias for SIGABRT)" msgstr "中止(SIGABRT 的別名)" +#, c-format +msgid "Active languages (%s):" +msgstr "" + msgid "Address boundary error" msgstr "位址邊界錯誤" @@ -1227,6 +1235,9 @@ msgstr "作業變更狀態" msgid "Jobs getting started or continued" msgstr "作業開始或繼續" +msgid "Language specifiers appear repeatedly:" +msgstr "" + msgid "List or remove functions" msgstr "列出或移除函式" @@ -1277,6 +1288,9 @@ msgstr "永不" msgid "No TTY for interactive shell (tcgetpgrp failed)" msgstr "沒有互動式 shell 所需的 TTY(tcgetpgrp 失敗了)" +msgid "No catalogs available for language specifiers:" +msgstr "" + #, c-format msgid "No matches for wildcard '%s'. See `help %s`." msgstr "wildcard「%s」無匹配項目。參見「help %s」。" @@ -1774,6 +1788,9 @@ msgstr "不允許在命令處進行命令替換。試試 var=(命令) $var ..." msgid "completion reached maximum recursion depth, possible cycle?" msgstr "補全達到最大遞迴深度,可能是循環?" +msgid "default" +msgstr "" + msgid "dir symlink" msgstr "目錄象徵式連結" @@ -1802,10 +1819,17 @@ msgstr "" "可能未安裝文件。\n" "「help %s」將顯示線上版本\n" +msgid "from command `status language set`" +msgstr "" + #, c-format msgid "from sourcing file %s\n" msgstr "在載入的檔案 %s\n" +#, c-format +msgid "from variable %s" +msgstr "" + msgid "in command substitution\n" msgstr "在命令替換\n" @@ -3388,6 +3412,9 @@ msgstr "顯示和游標處的詞元有關的手冊頁或函式說明" msgid "Show modification time" msgstr "顯示修改時間" +msgid "Show or change fish's language settings" +msgstr "" + msgid "Show seconds since the modification time" msgstr "顯示自修改時間以來的秒數" diff --git a/share/completions/status.fish b/share/completions/status.fish index 7bb9803c3..16842128f 100644 --- a/share/completions/status.fish +++ b/share/completions/status.fish @@ -23,6 +23,7 @@ set -l __fish_status_all_commands \ is-login \ is-no-job-control \ job-control \ + language \ line-number \ list-files \ print-stack-trace \ @@ -80,3 +81,19 @@ complete -f -c status -n "__fish_seen_subcommand_from job-control" -a none -d "S complete -f -c status -n "__fish_seen_subcommand_from get-file" -a '(status list-files 2>/dev/null)' complete -f -c status -n "__fish_seen_subcommand_from list-files" -a '(status list-files 2>/dev/null)' + +# Tests equality between the command line with the first item removed +# and the function's arguments. +function __fish_status_is_exact_subcommand + set -l line (commandline -pxc)[2..] + test "$line" = "$argv" +end +# Tests if the command line with the first item removed starts with the provided arguments. +function __fish_status_is_subcommand_prefix + set -l prefix (string escape --style=regex -- (string join -- ' ' $argv)) + set -l line (string join -- ' ' (commandline -pxc)[2..]) + string match -rq -- "^$prefix" $line +end +complete -f -c status -n "not __fish_seen_subcommand_from $__fish_status_all_commands" -a language -d "Show or change fish's language settings" +complete -f -c status -n "__fish_status_is_exact_subcommand language" -a "(echo list-available\tShow languages usable with \'status language set\'\nset\tSet the language\(s\) used for fish\'s messages\nunset\tUndo effects of \'status language set\'\n)" +complete -f -c status -n "__fish_status_is_subcommand_prefix language set" -a "(status language list-available)" diff --git a/src/builtins/shared.rs b/src/builtins/shared.rs index 1335e5152..bd403a816 100644 --- a/src/builtins/shared.rs +++ b/src/builtins/shared.rs @@ -67,6 +67,9 @@ pub BUILTIN_ERR_INVALID_SUBCMD "%s: %s: invalid subcommand\n" + pub BUILTIN_ERR_INVALID_SUBSUBCMD + "%s %s: %s: invalid subcommand\n" + /// Error messages for unexpected args. pub BUILTIN_ERR_ARG_COUNT0 "%s: missing argument\n" diff --git a/src/builtins/status.rs b/src/builtins/status.rs index 72d80b31f..90e6b1e68 100644 --- a/src/builtins/status.rs +++ b/src/builtins/status.rs @@ -75,6 +75,7 @@ fn to_wstr(self) -> &'static wstr { (STATUS_IS_NO_JOB_CTRL, "is-no-job-control"), (STATUS_LINE_NUMBER, "line-number", "current-line-number"), (STATUS_LIST_FILES, "list-files"), + (STATUS_LANGUAGE, "language"), (STATUS_SET_JOB_CONTROL, "job-control"), (STATUS_STACK_TRACE, "stack-trace", "print-stack-trace"), (STATUS_TERMINAL, "terminal"), @@ -303,7 +304,7 @@ fn parse_cmd_opts( *optind = w.wopt_index; - return Ok(SUCCESS); + Ok(SUCCESS) } struct EmptyEmbed; @@ -489,6 +490,47 @@ pub fn status(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> B streams.out.append(src); return Ok(SUCCESS); } + STATUS_LANGUAGE => { + cfg_if! { + if #[cfg(not(feature = "localize-messages"))] { + streams.err.append(L!("fish was built with the `localize-messages` feature disabled. The `status language` command is unavailable.\n")); + return Err(STATUS_CMD_ERROR); + } else { + if args.is_empty() { + streams.out.append(crate::wutil::gettext::status_language()); + return Ok(SUCCESS); + } + match args[0].to_string().as_str() { + "list-available" => { + streams.out.append(crate::wutil::gettext::list_available_languages()); + return Ok(SUCCESS); + }, + "set" => { + let langs = args[1..] + .iter() + .map(|lang| lang.to_string()) + .collect::>(); + let lints = crate::wutil::gettext::update_from_status_language_builtin(&langs); + let formatted_lints = lints.display_all(); + if !formatted_lints.is_empty() { + streams.err.append(&formatted_lints); + } + return Ok(SUCCESS); + } + "unset" => { + crate::wutil::gettext::unset_from_status_language_builtin(parser.vars()); + return Ok(SUCCESS); + } + invalid => { + streams + .err + .append(wgettext_fmt!(BUILTIN_ERR_INVALID_SUBSUBCMD, cmd, subcmd.to_wstr(), invalid)); + return Err(STATUS_INVALID_ARGS); + } + } + } + } + } STATUS_LIST_FILES => { use crate::util::wcsfilecmp_glob; let mut paths = vec![]; @@ -733,6 +775,7 @@ pub fn status(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> B | STATUS_TEST_FEATURE | STATUS_GET_FILE | STATUS_LIST_FILES + | STATUS_LANGUAGE | STATUS_TEST_TERMINAL_FEATURE => { unreachable!("") } diff --git a/src/env_dispatch.rs b/src/env_dispatch.rs index b7e793c4e..2c6e26a57 100644 --- a/src/env_dispatch.rs +++ b/src/env_dispatch.rs @@ -540,7 +540,7 @@ fn init_locale(vars: &EnvStack) { invalidate_numeric_locale(); #[cfg(feature = "localize-messages")] - crate::wutil::gettext::update_locale_from_env(vars); + crate::wutil::gettext::update_from_env(vars); } pub fn use_posix_spawn() -> bool { diff --git a/src/wutil/gettext.rs b/src/wutil/gettext.rs index 6f365e803..7c32186df 100644 --- a/src/wutil/gettext.rs +++ b/src/wutil/gettext.rs @@ -1,19 +1,195 @@ use std::sync::Mutex; #[cfg(feature = "localize-messages")] -use crate::env::EnvStack; +use crate::env::{EnvStack, Environment}; use crate::wchar::prelude::*; use once_cell::sync::Lazy; #[cfg(feature = "localize-messages")] mod gettext_impl { - use crate::env::{EnvStack, Environment}; use fish_gettext_maps::CATALOGS; use once_cell::sync::Lazy; use std::{collections::HashSet, sync::Mutex}; type Catalog = &'static phf::Map<&'static str, &'static str>; + pub struct SetLanguageLints<'a> { + pub duplicates: Vec<&'a str>, + pub non_existing: Vec<&'a str>, + } + + #[derive(PartialEq, Eq, Clone, Copy)] + pub enum LanguagePrecedenceOrigin { + Default, + LocaleVariable(LocaleVariable), + LanguageEnvVar, + StatusLanguage, + } + + #[derive(PartialEq, Eq, Clone, Copy)] + pub enum LocaleVariable { + #[allow(clippy::upper_case_acronyms)] + LANG, + LC_MESSAGES, + LC_ALL, + } + + impl LocaleVariable { + fn as_language_precedence_origin(&self) -> LanguagePrecedenceOrigin { + LanguagePrecedenceOrigin::LocaleVariable(*self) + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::LANG => "LANG", + Self::LC_MESSAGES => "LC_MESSAGES", + Self::LC_ALL => "LC_ALL", + } + } + } + + impl std::fmt::Display for LocaleVariable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } + } + + struct InternalLocalizationState { + precedence_origin: LanguagePrecedenceOrigin, + language_precedence: Vec<(String, Catalog)>, + } + + pub struct PublicLocalizationState { + pub precedence_origin: LanguagePrecedenceOrigin, + pub language_precedence: Vec, + } + + /// Stores the current localization status. + /// `is_active` indicates whether localization is currently active, and the reason if it is + /// not. + /// The `origin` indicates where the values in `language_precedence` were taken from. + /// `language_precedence` stores the catalogs in the order they should be used. + /// + /// This struct should be updated when the relevant variables change or `status language` is used + /// to modify the localization state. + static LOCALIZATION_STATE: Lazy> = + Lazy::new(|| Mutex::new(InternalLocalizationState::new())); + + impl InternalLocalizationState { + fn new() -> Self { + Self { + precedence_origin: LanguagePrecedenceOrigin::Default, + language_precedence: vec![], + } + } + + fn to_public(&self) -> PublicLocalizationState { + PublicLocalizationState { + precedence_origin: self.precedence_origin, + language_precedence: self + .language_precedence + .iter() + .map(|(lang, _)| lang.to_owned()) + .collect(), + } + } + + fn update_from_env( + &mut self, + message_locale: Option<(LocaleVariable, String)>, + language_var: Option>, + ) { + // Do not override values set via `status language`. + if self.precedence_origin == LanguagePrecedenceOrigin::StatusLanguage { + return; + } + + if let Some((precedence_origin, locale)) = &message_locale { + // Regular locale names start with lowercase letters (`ll_CC`, followed by some suffix). + // The C or POSIX locale is special, and often used to disable localization. + // Their names are upper-case, but variants with suffixes (`C.UTF-8`) exist. + // To ensure that such variants are accounted for, we match on prefixes of the + // locale name. + // https://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap07.html#tag_07_02 + fn is_c_locale(locale: &str) -> bool { + locale.starts_with('C') || locale.starts_with("POSIX") + } + if is_c_locale(locale) { + self.precedence_origin = + LanguagePrecedenceOrigin::LocaleVariable(*precedence_origin); + self.language_precedence.clear(); + return; + } + } + + let (precedence_origin, language_list) = if let Some(list) = language_var { + (LanguagePrecedenceOrigin::LanguageEnvVar, list) + } else if let Some((precedence_origin, locale)) = message_locale { + let mut normalized_name = String::new(); + // Strip off encoding and modifier. (We always expect UTF-8 and don't support modifiers.) + for c in locale.chars() { + if c.is_alphabetic() || c == '_' { + normalized_name.push(c); + } else { + break; + } + } + // At this point, the normalized_name should have the shape `ll` or `ll_CC`. + ( + precedence_origin.as_language_precedence_origin(), + vec![normalized_name], + ) + } else { + (LanguagePrecedenceOrigin::Default, vec![]) + }; + + let mut seen_languages = HashSet::new(); + self.language_precedence = language_list + .into_iter() + .flat_map(|lang| find_existing_catalogs(&lang)) + .filter(|(lang, _)| seen_languages.insert(lang.to_owned())) + .collect(); + self.precedence_origin = precedence_origin; + } + + fn update_from_status_language_builtin<'a, 'b: 'a, S: AsRef + 'a>( + &mut self, + langs: &'b [S], + ) -> SetLanguageLints<'a> { + let mut seen = HashSet::new(); + let mut duplicates = vec![]; + for lang in langs { + let lang = lang.as_ref(); + if !seen.insert(lang) { + duplicates.push(lang) + } + } + let mut existing_langs = vec![]; + let mut non_existing = vec![]; + for lang in langs { + let lang = lang.as_ref(); + if let Some(catalog) = CATALOGS.get(lang) { + existing_langs.push((lang.to_owned(), *catalog)); + } else { + non_existing.push(lang); + } + } + + let mut seen = HashSet::new(); + let unique_langs = existing_langs + .into_iter() + .filter(|(lang, _)| seen.insert(lang.to_owned())) + .collect(); + self.language_precedence = unique_langs; + self.precedence_origin = LanguagePrecedenceOrigin::StatusLanguage; + + SetLanguageLints { + duplicates, + non_existing, + } + } + } + /// Tries to find catalogs for `language`. /// `language` must be an ISO 639 language code, optionally followed by an underscore and an ISO /// 3166 country/territory code. @@ -52,125 +228,220 @@ fn find_existing_catalogs(language: &str) -> Vec<(String, Catalog)> { } } - /// The precedence list of user-preferred languages, obtained from the relevant environment - /// variables. - /// This should be updated when the relevant variables change. - static LANGUAGE_PRECEDENCE: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - - /// Four environment variables can be used to select languages. - /// A detailed description is available at - /// - /// Our does not replicate the behavior exactly. - /// See the following description. - /// - /// There are three variables which can be used for setting the locale for messages: - /// 1. `LC_ALL` - /// 2. `LC_MESSAGES` - /// 3. `LANG` - /// - /// The value of the first one set to a non-zero value will be considered. - /// If it is set to the `C` locale (we consider any value starting with `C` as the `C` locale), - /// localization will be disabled. - /// Otherwise, the variable `LANGUAGE` is checked. If it is non-empty, it is considered a - /// colon-separated list of languages. Languages are listed with descending priority, meaning - /// we will localize each message into the first language with a localization available. - /// Each language is specified by a 2 or 3 letter ISO 639 language code, optionally followed by - /// an underscore and an ISO 3166 country/territory code. If the second part is omitted, some - /// variant of the language will be used if localizations exist for one. We make no guarantees - /// about which variant that will be. - /// In addition to the colon-separated format, using a list with one language per element is - /// also supported. - /// - /// Returns the (possibly empty) preference list of languages. - fn get_language_preferences_from_env(vars: &EnvStack) -> Vec { - use crate::wchar::L; - - fn normalize_locale_name(locale: &str) -> String { - // Strips off the encoding and modifier parts. - let mut normalized_name = String::new(); - // Strip off encoding and modifier. (We always expect UTF-8 and don't support modifiers.) - for c in locale.chars() { - if c.is_alphabetic() || c == '_' { - normalized_name.push(c); - } else { - break; - } - } - // At this point, the normalized_name should have the shape `ll` or `ll_CC`. - normalized_name - } - - fn check_language_var(vars: &EnvStack) -> Option> { - let langs = vars.get(L!("LANGUAGE"))?; - let langs = langs.as_list(); - let filtered_langs: Vec = langs - .iter() - .filter(|lang| !lang.is_empty()) - .map(|lang| normalize_locale_name(&lang.to_string())) - .collect(); - if filtered_langs.is_empty() { - return None; - } - Some(filtered_langs) - } - - // Locale value is determined by the first of these three variables set to a non-zero - // value. - if let Some(locale) = vars - .get(L!("LC_ALL")) - .or_else(|| vars.get(L!("LC_MESSAGES")).or_else(|| vars.get(L!("LANG")))) - { - let locale = locale.as_string().to_string(); - if locale.starts_with('C') { - // Do not localize in C locale. - return vec![]; - } - // `LANGUAGE` has higher precedence than the locale value. - if let Some(precedence_list) = check_language_var(vars) { - return precedence_list; - } - // Use the locale value if `LANGUAGE` is not set. - vec![normalize_locale_name(&locale)] - } else if let Some(precedence_list) = check_language_var(vars) { - // Use the `LANGUAGE` value if locale is not set. - return precedence_list; - } else { - // None of the relevant variables are set, so we will not localize. - vec![] - } + pub(super) fn update_from_env( + locale: Option<(LocaleVariable, String)>, + language_var: Option>, + ) { + let mut localization_state = LOCALIZATION_STATE.lock().unwrap(); + localization_state.update_from_env(locale, language_var); } - /// Implementation of the function with the same name in super. - pub(super) fn update_locale_from_env(vars: &EnvStack) { - let mut seen_languages = HashSet::new(); - let mut language_precedence = LANGUAGE_PRECEDENCE.lock().unwrap(); - *language_precedence = get_language_preferences_from_env(vars) - .into_iter() - .flat_map(|lang| find_existing_catalogs(&lang)) - .filter(|(lang, _)| seen_languages.insert(lang.to_owned())) - .map(|(_, catalog)| catalog) - .collect(); + pub(super) fn update_from_status_language_builtin<'a, 'b: 'a, S: AsRef + 'a>( + langs: &'b [S], + ) -> SetLanguageLints<'a> { + let mut localization_state = LOCALIZATION_STATE.lock().unwrap(); + localization_state.update_from_status_language_builtin(langs) + } + + pub(super) fn unset_from_status_language_builtin( + locale: Option<(LocaleVariable, String)>, + language_var: Option>, + ) { + let mut localization_state = LOCALIZATION_STATE.lock().unwrap(); + localization_state.precedence_origin = LanguagePrecedenceOrigin::Default; + localization_state.update_from_env(locale, language_var); + } + + pub(super) fn status_language() -> PublicLocalizationState { + let localization_state = LOCALIZATION_STATE.lock().unwrap(); + localization_state.to_public() } pub(super) fn gettext(message_str: &'static str) -> Option<&'static str> { - let language_precedence = LANGUAGE_PRECEDENCE.lock().unwrap(); + let localization_state = LOCALIZATION_STATE.lock().unwrap(); // Use the localization from the highest-precedence language that has one available. - for catalog in language_precedence.iter() { + for (_, catalog) in localization_state.language_precedence.iter() { if let Some(localized_str) = catalog.get(message_str) { return Some(localized_str); } } None } + + pub(super) fn list_available_languages() -> Vec<&'static str> { + let mut langs: Vec<_> = CATALOGS.entries().map(|(&lang, _)| lang).collect(); + langs.sort(); + langs + } +} + +#[cfg(feature = "localize-messages")] +fn get_message_locale(vars: &EnvStack) -> Option<(gettext_impl::LocaleVariable, String)> { + use gettext_impl::LocaleVariable; + let get = |var_str: &wstr, var: LocaleVariable| { + vars.get_unless_empty(var_str) + .map(|val| (var, val.as_string().to_string())) + }; + get(L!("LC_ALL"), LocaleVariable::LC_ALL) + .or_else(|| get(L!("LC_MESSAGES"), LocaleVariable::LC_MESSAGES)) + .or_else(|| get(L!("LANG"), LocaleVariable::LANG)) +} + +#[cfg(feature = "localize-messages")] +fn get_language_var(vars: &EnvStack) -> Option> { + let langs = vars.get_unless_empty(L!("LANGUAGE"))?; + let langs = langs.as_list(); + let filtered_langs: Vec = langs + .iter() + .filter(|lang| !lang.is_empty()) + .map(|lang| lang.to_string()) + .collect(); + if filtered_langs.is_empty() { + return None; + } + Some(filtered_langs) } /// Call this when one of `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG` changes. /// Updates internal state such that the correct localizations will be used in subsequent /// localization requests. +/// +/// For deciding how to localize, the following is done: +/// +/// 1. If the language precedence was set via `status language`, env vars are ignored. +/// 2. Check the first non-empty value of the env vars `LC_ALL`, `LC_MESSAGES`, `LANG`. If it +/// starts with `C` we consider this a C locale and disable localization. +/// 3. Otherwise, the value of the `LANGUAGE` env var is used, if non-empty. This allows specifying +/// multiple languages, with languages specified first taking precedence, e.g. +/// `LANGUAGE=zh_TW:zh_CN:pt_BR` +/// 4. Otherwise, the first non-empty value of the env vars `LC_ALL`, `LC_MESSAGES`, `LANG` is +/// used. This can only specify a single language, e.g. `LANG=de_AT.UTF-8`. +/// There, we normalize locale names by stripping off the suffix, leaving only the `ll_CC` part. +/// 5. Otherwise, localization will not happen. +/// +/// If users specify `ll_CC` as a language and we don't have a catalog for this language, but we +/// have one for `ll`, that will be used instead. If users specify `ll` (without specifying a +/// language variant), which we discourage, and we don't have a catalog for `ll`, but we do have +/// one for `ll_CC`, that will be used as a fallback. If we have multiple `ll_*` catalogs, all of +/// them will be used, in arbitrary order. #[cfg(feature = "localize-messages")] -pub fn update_locale_from_env(vars: &EnvStack) { - gettext_impl::update_locale_from_env(vars); +pub fn update_from_env(vars: &EnvStack) { + gettext_impl::update_from_env(get_message_locale(vars), get_language_var(vars)); +} + +#[cfg(feature = "localize-messages")] +fn append_space_separated_list>( + string: &mut WString, + list: impl IntoIterator, +) { + for lang in list.into_iter() { + string.push(' '); + string.push_utfstr(&crate::common::escape( + WString::from_str(lang.as_ref()).as_utfstr(), + )); + } +} + +#[cfg(feature = "localize-messages")] +pub struct SetLanguageLints<'a> { + duplicates: Vec<&'a str>, + non_existing: Vec<&'a str>, +} + +#[cfg(feature = "localize-messages")] +impl<'a> From> for SetLanguageLints<'a> { + fn from(lints: gettext_impl::SetLanguageLints<'a>) -> Self { + Self { + duplicates: lints.duplicates, + non_existing: lints.non_existing, + } + } +} + +#[cfg(feature = "localize-messages")] +impl<'a> SetLanguageLints<'a> { + pub fn display_duplicates(&self) -> WString { + let mut result = WString::new(); + if self.duplicates.is_empty() { + return result; + } + result.push_utfstr(wgettext!("Language specifiers appear repeatedly:")); + append_space_separated_list(&mut result, &self.duplicates); + result.push('\n'); + result + } + + pub fn display_non_existing(&self) -> WString { + let mut result = WString::new(); + if self.non_existing.is_empty() { + return result; + } + result.push_utfstr(wgettext!("No catalogs available for language specifiers:")); + append_space_separated_list(&mut result, &self.non_existing); + result.push('\n'); + result + } + + pub fn display_all(&self) -> WString { + let mut result = WString::new(); + result.push_utfstr(&self.display_duplicates()); + result.push_utfstr(&self.display_non_existing()); + result + } +} +/// Call this when the `status language` builtin should update the language precedence. +/// `langs` should be the list of languages the precedence should be set to. +#[cfg(feature = "localize-messages")] +pub fn update_from_status_language_builtin<'a, 'b: 'a, S: AsRef + 'a>( + langs: &'b [S], +) -> SetLanguageLints<'a> { + gettext_impl::update_from_status_language_builtin(langs).into() +} + +#[cfg(feature = "localize-messages")] +pub fn unset_from_status_language_builtin(vars: &EnvStack) { + gettext_impl::unset_from_status_language_builtin( + get_message_locale(vars), + get_language_var(vars), + ); +} + +#[cfg(feature = "localize-messages")] +pub fn status_language() -> WString { + use gettext_impl::LanguagePrecedenceOrigin; + let localization_state = gettext_impl::status_language(); + let mut result = WString::new(); + localizable_consts!( + LANGUAGE_LIST_VARIABLE_ORIGIN "from variable %s" + ); + let origin_string = match localization_state.precedence_origin { + LanguagePrecedenceOrigin::Default => wgettext!("default").to_owned(), + LanguagePrecedenceOrigin::LocaleVariable(var) => { + wgettext_fmt!(LANGUAGE_LIST_VARIABLE_ORIGIN, var.as_str()) + } + LanguagePrecedenceOrigin::LanguageEnvVar => { + wgettext_fmt!(LANGUAGE_LIST_VARIABLE_ORIGIN, "LANGUAGE") + } + LanguagePrecedenceOrigin::StatusLanguage => { + wgettext!("from command `status language set`").to_owned() + } + }; + result.push_utfstr(&wgettext_fmt!("Active languages (%s):", origin_string)); + append_space_separated_list(&mut result, &localization_state.language_precedence); + result.push('\n'); + + result +} + +#[cfg(feature = "localize-messages")] +pub fn list_available_languages() -> WString { + let mut languages = WString::new(); + for lang in gettext_impl::list_available_languages() { + languages.push_str(lang); + languages.push('\n'); + } + languages } #[cfg(not(feature = "localize-messages"))] @@ -180,13 +451,13 @@ pub fn initialize_gettext() {} /// available. Without this, early error messages cannot be localized. #[cfg(feature = "localize-messages")] pub fn initialize_gettext() { - let locale_vars = EnvStack::new(); - env_stack_set_from_env!(locale_vars, "LANGUAGE"); - env_stack_set_from_env!(locale_vars, "LC_ALL"); - env_stack_set_from_env!(locale_vars, "LC_MESSAGES"); - env_stack_set_from_env!(locale_vars, "LANG"); + let vars = EnvStack::new(); + env_stack_set_from_env!(vars, "LANGUAGE"); + env_stack_set_from_env!(vars, "LC_ALL"); + env_stack_set_from_env!(vars, "LC_MESSAGES"); + env_stack_set_from_env!(vars, "LANG"); - gettext_impl::update_locale_from_env(&locale_vars); + gettext_impl::update_from_env(get_message_locale(&vars), get_language_var(&vars)); } /// Use this function to localize a message. diff --git a/tests/checks/message-localization.fish b/tests/checks/message-localization.fish index 766cfdc4a..1ecfb1403 100644 --- a/tests/checks/message-localization.fish +++ b/tests/checks/message-localization.fish @@ -55,6 +55,12 @@ begin set -l LANG C echo (_ file) # CHECK: file + set -l LANG POSIX + echo (_ file) + # CHECK: file + set -l LANG C.UTF-8 + echo (_ file) + # CHECK: file end echo (_ file) # CHECK: arquivo @@ -73,6 +79,13 @@ end echo (_ file) # CHECK: arquivo +# Check that empty vars are ignored +begin + set -l LC_ALL + echo (_ file) + # CHECK: arquivo +end + # Check that all relevant locale variables are respected. set --erase LANG set --erase LC_MESSAGES @@ -104,3 +117,71 @@ echo (_ file) set -l LC_ALL de_DE.utf8 echo (_ file) # CHECK: Datei + +# Check `status language` builtin +set --erase LANG +set --erase LC_MESSAGES +set --erase LC_ALL +set --erase LANGUAGE +status language +# CHECK: Active languages (default): +echo (_ file) +# CHECK: file + +set -l LANGUAGE pt_BR de_DE +status language +# CHECK: Active languages (from variable LANGUAGE): pt_BR de +echo (_ file) +# CHECK: arquivo + +# We have fr but not fr_FR. For the builtin command, only exact matches are allowed. +status language set fr_FR de pt_BR +# CHECKERR: No catalogs available for language specifiers: fr_FR +status language +# CHECK: Active languages (from command `status language set`): de pt_BR +echo (_ file) +# CHECK: Datei + +set -l LANGUAGE zh_TW +status language +# CHECK: Active languages (from command `status language set`): de pt_BR +echo (_ file) +# CHECK: Datei + +set -l LC_MESSAGES C +status language +# CHECK: Active languages (from command `status language set`): de pt_BR +echo (_ file) +# CHECK: Datei + +status language unset +status language +# CHECK: Active languages (from variable LC_MESSAGES): +echo (_ file) +# CHECK: file + +set --erase LC_MESSAGES +status language +# CHECK: Active languages (from variable LANGUAGE): zh_TW +echo (_ file) +# CHECK: 檔案 + +set --erase LANGUAGE +status language +# CHECK: Active languages (default): +echo (_ file) +# CHECK: file + +# Check `status language set` warnings +status language set asdf +# CHECKERR: No catalogs available for language specifiers: asdf + +# This will have to be changed if we add catalogs for languages used here. +status language set zh_HK it_IT +# CHECKERR: No catalogs available for language specifiers: zh_HK it_IT + +status language set de de +# CHECKERR: Language specifiers appear repeatedly: de + +status language set \xff quote\" +# CHECKERR: No catalogs available for language specifiers: \Xff 'quote"'