Compare commits

..
Author SHA1 Message Date
sixsmith 309fc72c38 temp 2026-10-10 01:04:50 -04:00
46 changed files with 1707 additions and 2252 deletions
+1
View File
@@ -7,3 +7,4 @@
/bin
dist/
tests/integration/.bats
tests/resources/path_to_repo
+50
View File
@@ -84,3 +84,53 @@ build-mo:
release:
@goreleaser release --clean
CLIENT_VM := abra-client
vm-client-status:
sudo systemctl status microvm@$(CLIENT_VM).service
vm-client-create:test-integration-hosts
sudo microvm -f git+file://$$(pwd) -c $(CLIENT_VM)
vm-client-start:
sudo systemctl start microvm@$(CLIENT_VM).service
vm-client-update:test-integration-hosts
sudo microvm -R -f git+file://$$(pwd) -u $(CLIENT_VM)
vm-client-run:
sudo microvm -f git+file://$$(pwd) -r $(CLIENT_VM)
vm-client-stop:
sudo systemctl stop microvm@$(CLIENT_VM)
vm-client-delete: vm-client-stop
sudo rm -rf /var/lib/microvms/$(CLIENT_VM)
vm-client-connect:
ssh abra@10.0.0.2 -i ./tests/resources/local_integration_ssh
SERVER_VM := abra-server
vm-server-status:
sudo systemctl status microvm@$(SERVER_VM).service
vm-server-create:
sudo microvm -f git+file://$$(pwd) -c $(SERVER_VM)
vm-server-start:
sudo systemctl start microvm@$(SERVER_VM).service
vm-server-update:
sudo microvm -R -f git+file://$$(pwd) -u $(SERVER_VM)
vm-server-run:
sudo microvm -f git+file://$$(pwd) -r $(SERVER_VM)
vm-server-stop:
sudo systemctl stop microvm@$(SERVER_VM)
vm-server-delete: vm-server-stop
sudo rm -rf /var/lib/microvms/$(SERVER_VM)
vm-server-connect:
ssh abra@10.0.0.3 -i ./tests/resources/local_integration_ssh
test-integration-hosts:
./scripts/tests/extra_hosts
# TEST_INTEGRATION_FILES overrides the default of running all test files
ifdef TEST_INTEGRATION_FILES
test_files := $(TEST_INTEGRATION_FILES);
else
test_files := *;
endif
test-integration: build
chmod 600 ./tests/resources/local_integration_ssh
ssh-add ./tests/resources/local_integration_ssh
@bats -Tp --verbose-run tests/integration/$(test_files)
Generated
+38
View File
@@ -18,6 +18,27 @@
"type": "github"
}
},
"microvm": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"spectrum": "spectrum"
},
"locked": {
"lastModified": 1784666190,
"narHash": "sha256-xgfS6slV7J3baMooNN1UuBi51RIgg9y0DbCxfSA0668=",
"owner": "astro",
"repo": "microvm.nix",
"rev": "fa5340ac684cdce8a22b6d4a0bcebb0cc999275e",
"type": "github"
},
"original": {
"owner": "astro",
"repo": "microvm.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
@@ -37,9 +58,26 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"microvm": "microvm",
"nixpkgs": "nixpkgs"
}
},
"spectrum": {
"flake": false,
"locked": {
"lastModified": 1783694892,
"narHash": "sha256-xO8f7Qng+18FK2UlB9vcrkxCaQMCt5WjCH24aW/11eg=",
"ref": "refs/heads/main",
"rev": "24c4346e30fdea8d8e80f34aec3554a15a667d24",
"revCount": 1410,
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
},
"original": {
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
+87 -18
View File
@@ -4,34 +4,103 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
microvm = {
url = "github:astro/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
microvm,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages = rec {
abra = pkgs.callPackage ./package.nix { };
default = abra;
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
publicKey = builtins.readFile ./tests/resources/local_integration_ssh.pub;
privateKey = builtins.readFile ./tests/resources/local_integration_ssh;
pathToRepo = builtins.readFile ./tests/resources/path_to_repo;
# local DNS aliases for the server host
extraHosts = builtins.readFile ./tests/resources/extra_hosts;
username = "abra";
password = "abra";
defaultDNS = [
# Quad9.net
"9.9.9.9"
"149.112.112.112"
"2620:fe::fe"
"2620:fe::9"
];
in
{
nixosConfigurations = {
abra-client = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/client/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
extraHosts
pathToRepo
;
};
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
abra-server = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/server/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
;
};
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go_1_26
gnumake
};
nixosModules = rec {
host = {
imports = [
microvm.nixosModules.host
./nix/modules/host.nix
];
};
}
);
default = host;
};
packages = rec {
abra = pkgs.callPackage ./nix/package.nix { };
default = abra;
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
};
devShells.${system}.default = pkgs.mkShell {
# testing env variables
BATS_LIB_PATH = "~/.local/share/bats/";
TEST_SERVER = "abra.local";
ABRA_DIR = "$HOME/.abra_test";
packages = with pkgs; [
go_1_26
gnumake
gopls
];
};
};
}
+23 -20
View File
@@ -1,6 +1,6 @@
module coopcloud.tech/abra
go 1.27
go 1.26.0
require (
coopcloud.tech/tagcmp v0.0.0-20260515102403-c26951b55977
@@ -9,24 +9,18 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/containers/image v3.0.2+incompatible
github.com/decentral1se/passgen v1.0.1
github.com/distribution/reference v0.6.0
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-units v0.5.0
github.com/go-git/go-git/v5 v5.19.3
github.com/google/go-cmp v0.7.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/leonelquinteros/gotext v1.8.0
github.com/leonelquinteros/gotext v1.7.2
github.com/moby/sys/signal v0.7.1
github.com/moby/term v0.5.2
github.com/pkg/errors v0.9.1
github.com/regclient/regclient v0.11.5
github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
@@ -55,18 +49,15 @@ require (
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containers/storage v1.38.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.2 // indirect
@@ -75,9 +66,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -95,9 +84,7 @@ require (
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/morikuni/aec v1.1.0 // indirect
@@ -106,24 +93,19 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runc v1.1.13 // indirect
github.com/opencontainers/runtime-spec v1.1.0 // indirect
github.com/pjbgf/sha1cd v0.7.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -154,6 +136,27 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/containers/image v3.0.2+incompatible
github.com/containers/storage v1.38.2 // indirect
github.com/decentral1se/passgen v1.0.1
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.49.0
)
replace github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible
replace github.com/spf13/cobra => github.com/decentral1se/cobra v1.10.2
+2 -2
View File
@@ -604,8 +604,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
github.com/leonelquinteros/gotext v1.8.0/go.mod h1:Wqke380+8unZq2F4oTmmqmbUY9zWQMzwImdC34XOAo8=
github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc=
github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8=
github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
-54
View File
@@ -1,54 +0,0 @@
{
buildGo126Module,
fetchgit,
lib,
installShellFiles,
}:
buildGo126Module rec {
pname = "abra";
version = "0.13.0-beta";
rev = "06a57ded025a43c80f94d4e65299add8a31830dc";
src = fetchgit {
url = "https://git.coopcloud.tech/toolshed/abra.git";
tag = version;
hash = "sha256-rgoK0TY0WLSQ39lPvVM80zW/qJF40VFBSxYDOaKXZQo=";
};
vendorHash = null;
nativeBuildInputs = [
installShellFiles
];
env.CGO_ENABLED = 0;
buildPhase = ''
runHook preBuild
go build -ldflags="-s -w -X 'main.Commit=${rev}' -X 'main.Version=${version}'" ./cmd/abra
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -D abra $out/bin/abra
runHook postInstall
'';
postInstall = ''
export ABRA_DIR="$out"
$out/bin/abra autocomplete bash >abra.bash
$out/bin/abra autocomplete fish >abra.fish
$out/bin/abra autocomplete zsh >abra.zsh
installShellCompletion abra.{bash,fish,zsh}
'';
meta = with lib; {
description = "The Co-op Cloud utility belt";
homepage = "https://docs.coopcloud.tech/abra";
changelog = "https://git.coopcloud.tech/toolshed/abra/releases/tag/${version}";
mainProgram = "abra";
license = licenses.gpl3Plus;
maintainers = "devydave";
};
}
+157 -137
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+171 -148
View File
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -593,7 +593,15 @@ func ReadReposMetadata(debug bool) (RepoCatalogue, error) {
// sortRecipeVersions sorts the recipe semver versions
func sortRecipeVersions(versions RecipeVersions) {
sort.Slice(versions, func(i, j int) bool {
return safeCompareVersions(getVersionString(versions[i]), getVersionString(versions[j]))
version1, err := tagcmp.Parse(getVersionString(versions[i]))
if err != nil {
panic(err)
}
version2, err := tagcmp.Parse(getVersionString(versions[j]))
if err != nil {
panic(err)
}
return version1.IsLessThan(version2)
})
}
@@ -609,23 +617,18 @@ func getVersionString(versionMap map[string]map[string]ServiceMeta) string {
// sortVersionStrings sorts a list of semver version strings
func sortVersionStrings(versions []string) {
sort.Slice(versions, func(i, j int) bool {
return safeCompareVersions(versions[i], versions[j])
version1, err := tagcmp.Parse(versions[i])
if err != nil {
panic(err)
}
version2, err := tagcmp.Parse(versions[j])
if err != nil {
panic(err)
}
return version1.IsLessThan(version2)
})
}
// Invalid versions are sorted as less than valid ones.
func safeCompareVersions(v1, v2 string) bool {
version1, err1 := tagcmp.Parse(v1)
if err1 != nil {
return true
}
version2, err2 := tagcmp.Parse(v2)
if err2 != nil {
return false
}
return version1.IsLessThan(version2)
}
// GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue.
func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]string, error) {
var versions []string
-38
View File
@@ -123,41 +123,3 @@ func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) {
assert.NotEqual(t, label, timeout)
}
}
// This is not a full test of comparison, as that occurs in the external
// tagcmp package. This is a sunny-day integration test
func TestSortRecipeVersionsSortsTwoValidStrings(t *testing.T) {
versions := RecipeVersions{
{"2.0.0": {}}, // Index 0
{"1.0.0": {}}, // Index 1
}
sortRecipeVersions(versions)
assert.Equal(t, "1.0.0", getVersionString(versions[0]), "Version 1.0.0 should be first")
assert.Equal(t, "2.0.0", getVersionString(versions[1]), "Version 2.0.0 should be second")
}
func TestSortRecipeVersionsSortsInvalidVersionStringToTheBottom(t *testing.T) {
versions := RecipeVersions{
{"2.0.0": {}}, // Index 0
{"invalid": {}}, // Index 1
}
sortRecipeVersions(versions)
assert.Equal(t, "invalid", getVersionString(versions[0]), "The invalid version should be first")
assert.Equal(t, "2.0.0", getVersionString(versions[1]), "Version 2.0.0 should be second")
}
func TestSortRecipeVersionsSortsInvalidVersionStringToTheBottomInReverse(t *testing.T) {
versions := RecipeVersions{
{"invalid": {}}, // Index 0
{"2.0.0": {}}, // Index 1
}
sortRecipeVersions(versions)
assert.Equal(t, "invalid", getVersionString(versions[0]), "The invalid version should be first")
assert.Equal(t, "2.0.0", getVersionString(versions[1]), "Version 2.0.0 should be second")
}
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.31.7
image: nginx:1.31.6
secrets:
- test_pass_one
- test_pass_two
+2 -1
View File
@@ -7,4 +7,5 @@
* Integration tests are in `./tests/integration`. Please see [these
docs](https://docs.coopcloud.tech/abra/hack/#integration-tests) for
instructions and tips on how to run them.
instructions and tips on how to run them. If you want to run it locally via nix,
read the [nix testing docs](../nix/hosts/README.md).
+9 -5
View File
@@ -598,8 +598,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "WITH_COMMENT=foo"
@@ -610,8 +609,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --force
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
refute_output --partial "WITH_COMMENT=foo"
assert_output --partial "WITH_COMMENT=bar"
@@ -629,8 +627,14 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' \
if _is_local;
then
run docker service inspect --format "{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}" \
"${TEST_APP_DOMAIN//./_}_app"
else
run ssh "$TEST_SERVER" "docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' ${TEST_APP_DOMAIN//./_}_app"
fi
assert_success
assert_output --partial "tcp=1312"
assert_output --partial "udp=1312"
@@ -133,8 +133,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
+11 -4
View File
@@ -140,15 +140,22 @@ teardown(){
_undeploy_app
sanitisedDomainName="${TEST_APP_DOMAIN//./_}"
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
assert_success
remote_ssh_command=""
if _is_local;
then
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
else
remote_ssh_command="ssh '$TEST_SERVER'"
run cat "$ABRA_DIR/recipes/abra-test-recipe/abra.sh" | ssh "$TEST_SERVER" docker config create "${sanitisedDomainName}_test_conf_v99 -"
assert_success
fi
assert bash -c "docker config ls | grep -q test_conf_v99"
assert bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
run $ABRA app rm "$TEST_APP_DOMAIN" --no-input
assert_success
refute bash -c "docker config ls | grep -q test_conf_v99"
refute bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
}
@test "remove .env file" {
@@ -54,8 +54,7 @@ teardown(){
--no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
@@ -53,8 +53,7 @@ teardown(){
run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.2.0+1.21.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.2.0+1.21.0"
assert_output --partial "$TEST_RECIPE:0.2.0+1.21.0"
}
+11 -2
View File
@@ -31,9 +31,18 @@ _ensure_ssh_agent() {
exit 1
fi
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
if [ ! -S ~/.ssh/ssh_auth_sock ]; then
if [[ ! -v SSH_AUTH_SOCK ]]; then
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
eval `ssh-agent`
ln -sf "$SSH_AUTH_SOCK" ~/.ssh/ssh_auth_sock
fi
}
_is_local() {
if [[ "$TEST_SERVER" == "default" ]];
then
return 0
else
return 1
fi
}
+30 -6
View File
@@ -1,13 +1,37 @@
#!/usr/bin/env bash
_ensure_swarm() {
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! $(docker network ls | grep -q 'proxy'); then
run docker network create -d overlay proxy
assert_success
if ! docker network ls | grep -q 'proxy'; then
run docker network create -d overlay proxy
assert_success
fi
if ! _is_local;
then
if [ "$(ssh "$TEST_SERVER" docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run ssh "$TEST_SERVER" docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! ssh "$TEST_SERVER" docker network ls | grep -q 'proxy'; then
run ssh "$TEST_SERVER" docker network create -d overlay proxy
assert_success
fi
fi
}
_inspect_env_test_app() {
if _is_local;
then
containerId="$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)"
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$containerId"
else
containerId="$(ssh "$TEST_SERVER" docker ps -f name="$TEST_APP_DOMAIN" -q)"
run ssh "$TEST_SERVER" "docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' '$containerId'"
fi
}
-2
View File
@@ -38,8 +38,6 @@ teardown(){
run $ABRA recipe upgrade "custom-html" --no-input
assert_success
assert_output --partial 'can upgrade service: app'
assert_exists "$ABRA_DIR/recipes/custom-html"
}
+3 -3
View File
@@ -27,7 +27,7 @@ teardown(){
assert bash -c "docker context ls | grep -q $TEST_SERVER"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
assert_equal "$server_dir_perms" "700"
}
@test "error if using name and --local together" {
@@ -43,8 +43,8 @@ teardown(){
assert bash -c "docker context ls | grep -q default"
assert_output --partial 'local server successfully added'
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/default")
assert_equal "$server_dir_perms" "700"
}
@test "create local server fails when no docker swarm" {
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.31.7
image: nginx:1.31.6
networks:
- proxy
deploy:
-64
View File
@@ -1,64 +0,0 @@
# AGENTS.md - gotext Project Guidelines
This document is the **source of truth** for the `gotext` project's architecture, conventions, and development workflows. All contributors — human or AI agent — must adhere to these guidelines.
Agent-specific configuration files (e.g., `GEMINI.md`, `CLAUDE.md`) should reference this document rather than duplicating its content.
## Project Overview
`gotext` is a GNU Gettext implementation for Go (Golang). It provides a thread-safe, hierarchical translation system that supports PO/MO files, plural forms, and context-aware translations.
## Core Architecture
The project follows a hierarchical structure for managing translations:
1. **Global Package API (`gotext.go`)**: Provides convenient, global functions (e.g., `Get`, `GetD`, `GetN`) that rely on a shared `globalConfig`.
2. **Locales (`locale.go`)**: Represent a specific language (e.g., "en_US", "es_ES"). A `Locale` container manages multiple `Domains`.
3. **Domains (`domain.go`)**: The core translation engine for a specific set of messages. It handles:
- Message retrieval (with support for plural forms and `msgctxt`).
- Header parsing (including plural-form rules).
- Thread-safety via `sync.RWMutex`.
4. **Translators (`translator.go`)**: An interface for translation sources.
- **PO (`po.go`)**: Parsers for human-readable Gettext Portable Object files.
- **MO (`mo.go`)**: Parsers for binary Gettext Machine Object files.
5. **Plural Forms (`plurals/`)**: A dedicated package for compiling and evaluating GNU Gettext plural form expressions.
## CLI Tools
- **xgotext (`cli/xgotext/`)**: A tool to scan Go source files, extract translatable strings (matching `Get`, `GetD`, etc.), and generate/update PO files.
## Key Files & Symbols
- `gotext.go`: `Configure`, `Get`, `GetD`, `GetN`.
- `locale.go`: `Locale`, `NewLocale`, `AddDomain`.
- `domain.go`: `Domain`, `Get`, `GetN`, `parseHeaders`.
- `translator.go`: `Translator` interface.
- `plurals/compiler.go`: `Compile` function for plural expressions.
## Development Workflows
### Branching & PRs
- **Feature Branches**: All development MUST happen in dedicated feature branches (e.g., `feature/description-of-change`, `fix/bug-description`, `refactor/topic`).
- **Target Branch**: All Pull Requests MUST be made against the `master` branch.
- **Review Requirement**: No direct commits to `master` are allowed. All changes must be reviewed via a PR.
- **GitHub Integration**: If the `gh` CLI tool is installed and authenticated, it should be used for managing Pull Requests, Issues, and Reviews. Otherwise, these tasks must be performed manually through the GitHub web interface.
### Coding Standards
- **Idiomatic Go**: Follow standard Go conventions (`gofmt`, `go vet`).
- **Thread Safety**: Ensure all shared state (like in `Domain`) is protected by appropriate synchronization primitives.
- **Testing**:
- Every new feature or bug fix must include corresponding tests.
- Use the `fixtures/` directory for sample PO/MO files.
- Run `go test ./...` to ensure no regressions.
### Pre-Submit Checklist
Before submitting a Pull Request, verify:
1. All existing tests pass: `go test ./...`
2. Code passes static analysis: `go vet ./...`
3. Code is properly formatted: `gofmt`
4. New features or bug fixes include corresponding tests.
5. Commit messages are clear and descriptive.
+45 -2
View File
@@ -1,3 +1,46 @@
# CODE OF CONDUCT
# Contributor Covenant Code of Conduct
The canonical text of this project's Code of Conduct lives at [docs/CODE_OF_CONDUCT.md](docs/CODE_OF_CONDUCT.md).
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at leonel.quinteros@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
+14 -4
View File
@@ -1,9 +1,19 @@
# CONTRIBUTING
Thank you for considering contributing to `gotext`! All kinds of contributions — features, bug fixes, tests, documentation, or discussions — are welcome.
This open source project welcomes everybody that wants to contribute to it by implementing new features, fixing bugs, testing, creating documentation or simply talk about it.
The canonical contribution guidelines live at [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
Most contributions will start by creating a new Issue to discuss what is the contribution about and to agree on the steps to move forward.
Before contributing, please also read [AGENTS.md](AGENTS.md): it is the source of truth for project architecture, conventions, and development workflows (branching, PR process, coding standards, and the pre-submit checklist).
## Issues
All issues reports are welcome. Open a new Issue whenever you want to report a bug, request a change or make a proposal.
This should be your start point of contribution.
## Pull Requests
If you have any changes that can be merged, feel free to send a Pull Request.
Usually, you'd want to create a new Issue to discuss about the change you want to merge and why it's needed or what it solves.
Start point: open an [Issue](https://github.com/leonelquinteros/gotext/issues) to discuss your idea, then send a Pull Request against `master`.
-9
View File
@@ -1,9 +0,0 @@
# GEMINI.md - Gemini CLI Instructions for gotext
> **Important**: Read and follow [`AGENTS.md`](./AGENTS.md) first. It is the source of truth for project architecture, conventions, and development workflows. The instructions below are specific to Gemini CLI and supplement — not replace — those guidelines.
## Gemini-Specific Instructions
- Always work in a feature branch. Never commit directly to `master`.
- Before submitting, verify changes with existing and new tests (`go test ./...`).
- Propose a PR to `master` after confirmation, using the `gh` CLI when available.
+206 -99
View File
@@ -9,151 +9,258 @@
[GNU gettext utilities](https://www.gnu.org/software/gettext) for Go.
`gotext` is a native Go implementation of the GNU Gettext utilities. It provides a thread-safe, flexible, and powerful way to handle internationalization (i18n) and localization (l10n) in your Go applications.
---
# Features
**📖 [Read the Full Documentation](https://leonelquinteros.github.io/gotext/)**
- Implements GNU gettext support in native Go.
- Complete support for [PO files](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html) including:
- Support for multiline strings and headers.
- Support for variables inside translation strings using Go's [fmt syntax](https://golang.org/pkg/fmt/).
- Support for [pluralization rules](https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html).
- Support for [message contexts](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html).
- Support for MO files.
- Thread-safe: This package is safe for concurrent use across multiple goroutines.
- It works with UTF-8 encoding as it's the default for Go language.
- Unit tests available.
- Language codes are automatically simplified from the form `en_UK` to `en` if the first isn't available.
- Ready to use inside Go templates.
- Objects are serializable to []byte to store them in cache.
- Support for Go Modules.
---
### Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Getting Started](#getting-started)
- [CLI Tool (xgotext)](#cli-tool-xgotext)
- [Advanced Usage](#advanced-usage)
- [Locale Object](#using-locale-object)
- [Plural Forms](#use-plural-forms-of-translations)
- [Dynamic Variables](#using-dynamic-variables-on-translations)
- [Directory Structure](#locales-directories-structure)
- [Contributing](#contributing)
- [License](#license)
# License
---
[MIT license](LICENSE)
## Features
- Native Go implementation of Gettext (the core library has no external dependencies).
- Full support for **PO and MO files**.
- **Pluralization rules** support via GNU Gettext plural-form expressions.
- **Message context** support (`msgctxt`).
- Thread-safe for concurrent use.
- Works with UTF-8 by default.
- Integrated CLI tool (`xgotext`) for string extraction.
- Serializable objects for caching.
- Plain-string results that plug directly into Go's `text/template` and `html/template` pipelines.
---
# Documentation
## Installation
```bash
Refer to package documentation at (https://pkg.go.dev/github.com/leonelquinteros/gotext)
# Installation
```
go get github.com/leonelquinteros/gotext
```
---
- There are no requirements or dependencies to use this package.
- No need to install GNU gettext utilities (unless specific needs of CLI tools).
- No need for environment variables. Some naming conventions are applied but not needed.
## Getting Started
For a quick start, use the package-level API:
# Usage examples
## Using package for single language/domain settings
For quick/simple translations you can use the package level functions directly.
```go
package main
import (
"fmt"
"github.com/leonelquinteros/gotext"
)
func main() {
// Configure package: locales path, language, and domain
gotext.Configure("/path/to/locales", "en_US", "default")
// Configure package
gotext.Configure("/path/to/locales/root/dir", "en_UK", "domain-name")
// Simple translation
fmt.Println(gotext.Get("Hello, world!"))
// Translate text from default domain
fmt.Println(gotext.Get("My text on 'domain-name' domain"))
// Translation with variables
fmt.Println(gotext.Get("Hello, %s!", "Gopher"))
// Translate text from a different domain without reconfigure
fmt.Println(gotext.GetD("domain2", "Another text on a different domain"))
}
```
## Using dynamic variables on translations
All translation strings support dynamic variables to be inserted without translate.
Use the fmt.Printf syntax (from Go's "fmt" package) to specify how to print the non-translated variable inside the translation string.
```go
import (
"fmt"
"github.com/leonelquinteros/gotext"
)
func main() {
// Configure package
gotext.Configure("/path/to/locales/root/dir", "en_UK", "domain-name")
// Set variables
name := "John"
// Translate text with variables
fmt.Println(gotext.Get("Hi, my name is %s", name))
}
```
## Using Locale object
When having multiple languages/domains/libraries at the same time, you can create Locale objects for each variation
so you can handle each settings on their own.
```go
import (
"fmt"
"github.com/leonelquinteros/gotext"
)
func main() {
// Create Locale with library path and language code
l := gotext.NewLocale("/path/to/locales/root/dir", "es_UY")
// Load domain '/path/to/locales/root/dir/es_UY/default.po'
l.AddDomain("default")
// Translate text from default domain
fmt.Println(l.Get("Translate this"))
// Load different domain
l.AddDomain("translations")
// Translate text from domain
fmt.Println(l.GetD("translations", "Translate this"))
}
```
For more details, see the [Getting Started Guide](docs/GETTING_STARTED.md).
This is also helpful for using inside templates (from the "text/template" package), where you can pass the Locale object to the template.
If you set the Locale object as "Loc" in the template, then the template code would look like:
---
## CLI Tool (xgotext)
`gotext` includes a command-line tool to extract translatable strings from your Go source code.
**Install xgotext:**
```bash
go install github.com/leonelquinteros/gotext/cli/xgotext@latest
```
{{ .Loc.Get "Translate this" }}
```
**Extract strings:**
```bash
xgotext -in . -out locales/templates
```
The command generates `.pot` templates under `locales/templates`; merge those templates into language-specific `.po` files.
See the [xgotext Documentation](docs/xgotext.md) for full usage details.
## Using the Po object to handle .po files and PO-formatted strings
---
## Advanced Usage
### Using Locale object
For managing multiple languages or domains independently:
For when you need to work with PO files and strings,
you can directly use the Po object to parse it and access the translations in there in the same way.
```go
l := gotext.NewLocale("/path/to/locales", "es_UY")
l.AddDomain("default")
fmt.Println(l.Get("Translate this"))
import (
"fmt"
"github.com/leonelquinteros/gotext"
)
func main() {
// Set PO content
str := `
msgid "Translate this"
msgstr "Translated text"
msgid "Another string"
msgstr ""
msgid "One with var: %s"
msgstr "This one sets the var: %s"
`
// Create Po object
po := gotext.NewPo()
po.Parse(str)
fmt.Println(po.Get("Translate this"))
}
```
### Use plural forms of translations
`gotext` handles complex pluralization rules defined in PO headers:
## Use plural forms of translations
PO format supports defining one or more plural forms for the same translation.
Relying on the PO file headers, a Plural-Forms formula can be set on the translation file
as defined in (https://www.gnu.org/savannah-checkouts/gnu/gettext/manual/html_node/Plural-forms.html)
```go
// GetN(singular, plural, quantity, args...)
fmt.Println(gotext.GetN("I have one apple.", "I have %d apples.", 5, 5))
```
See the [Plural Forms Guide](docs/PLURALS.md) for more examples.
import (
"fmt"
"github.com/leonelquinteros/gotext"
)
### Using dynamic variables on translations
Supports standard `fmt` package syntax:
```go
name := "John"
fmt.Println(gotext.Get("Hi, my name is %s", name))
func main() {
// Set PO content
str := `
msgid ""
msgstr ""
# Header below
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Translate this"
msgstr "Translated text"
msgid "Another string"
msgstr ""
msgid "One with var: %s"
msgid_plural "Several with vars: %s"
msgstr[0] "This one is the singular: %s"
msgstr[1] "This one is the plural: %s"
`
// Create Po object
po := new(gotext.Po)
po.Parse(str)
fmt.Println(po.GetN("One with var: %s", "Several with vars: %s", 54, v))
// "This one is the plural: Variable"
}
```
### More advanced scenarios
- **Embedded translation files** (`embed.FS`) and named `%(name)s` formatting: see the [Advanced Usage Guide](docs/ADVANCED.md).
- **Languages fallback**: pass multiple languages to `Configure` as a colon-separated list (`gotext.Configure("/path/to/locales", "es_UY:es:en_US", "default")`).
- **Serialization/caching** of parsed locales: `Locale.MarshalBinary` / `UnmarshalBinary`.
---
# Locales directories structure
The package will assume a directories structure starting with a base path that will be provided to the package configuration
or to object constructors depending on the use, but either will use the same convention to lookup inside the base path.
Inside the base directory where will be the language directories named using the language and country 2-letter codes (en_US, es_AR, ...).
All package functions can lookup after the simplified version for each language in case the full code isn't present but the more general language code exists.
So if the language set is `en_UK`, but there is no directory named after that code and there is a directory named `en`,
all package functions will be able to resolve this generalization and provide translations for the more general library.
The language codes are assumed to be [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) codes (2-letter codes).
That said, most functions will work with any coding standard as long the directory name matches the language code set on the configuration.
Then, there can be a `LC_MESSAGES` containing all PO files or the PO files themselves.
A library directory structure can look like:
## Locales directories structure
The package expects a standard Gettext directory structure:
```
/path/to/locales
/en_US
/LC_MESSAGES
default.po
/es_ES
/LC_MESSAGES
default.po
/path/to/locales/en_US
/path/to/locales/en_US/LC_MESSAGES
/path/to/locales/en_US/LC_MESSAGES/default.po
/path/to/locales/en_US/LC_MESSAGES/extras.po
/path/to/locales/en_UK
/path/to/locales/en_UK/LC_MESSAGES
/path/to/locales/en_UK/LC_MESSAGES/default.po
/path/to/locales/en_UK/LC_MESSAGES/extras.po
/path/to/locales/en_AU
/path/to/locales/en_AU/LC_MESSAGES
/path/to/locales/en_AU/LC_MESSAGES/default.po
/path/to/locales/en_AU/LC_MESSAGES/extras.po
/path/to/locales/es
/path/to/locales/es/default.po
/path/to/locales/es/extras.po
/path/to/locales/es_ES
/path/to/locales/es_ES/default.po
/path/to/locales/es_ES/extras.po
/path/to/locales/fr
/path/to/locales/fr/default.po
/path/to/locales/fr/extras.po
```
Translation files can live either inside an `LC_MESSAGES` subdirectory or directly under the language code (`/es_ES/default.po`); both layouts are supported.
It supports automatic language simplification (e.g., falling back from `en_UK` to `en`).
And so on...
---
## Contributing
We welcome contributions of all kinds!
- [Contributing Guidelines](CONTRIBUTING.md)
- [Code of Conduct](CODE_OF_CONDUCT.md)
# Contribute
---
- Please, contribute.
- Use the package on your projects.
- Report issues on Github.
- Send pull requests for bugfixes and improvements.
- Send proposals on Github issues.
## License
`gotext` is released under the [MIT License](LICENSE).
+157 -316
View File
@@ -4,7 +4,7 @@ import (
"bytes"
"encoding/gob"
"fmt"
"maps"
"regexp"
"sort"
"strconv"
"strings"
@@ -64,6 +64,9 @@ func (m HeaderMap) Del(key string) {
// Get value for key from HeaderMap
func (m HeaderMap) Get(key string) string {
if m == nil {
return ""
}
v := m[key]
if len(v) == 0 {
return ""
@@ -78,6 +81,9 @@ func (m HeaderMap) Set(key, value string) {
// Values returns all values for a given key from HeaderMap
func (m HeaderMap) Values(key string) []string {
if m == nil {
return nil
}
return m[key]
}
@@ -94,111 +100,29 @@ func NewDomain() *Domain {
return domain
}
func cloneRefs(refs []string) []string {
if refs == nil {
return nil
}
owned := make([]string, len(refs))
copy(owned, refs)
return owned
}
func cloneHeaderMap(headers HeaderMap) HeaderMap {
if headers == nil {
return nil
}
owned := make(HeaderMap, len(headers))
for key, values := range headers {
owned[key] = cloneRefs(values)
}
return owned
}
func cloneTranslation(trans *Translation) *Translation {
if trans == nil {
return nil
}
owned := NewTranslation()
owned.ID = trans.ID
owned.PluralID = trans.PluralID
owned.dirty = trans.dirty
owned.Refs = cloneRefs(trans.Refs)
maps.Copy(owned.Trs, trans.Trs)
return owned
}
func cloneTranslations(translations map[string]*Translation) map[string]*Translation {
if translations == nil {
return nil
}
owned := make(map[string]*Translation, len(translations))
for id, trans := range translations {
owned[id] = cloneTranslation(trans)
}
return owned
}
func cloneContextTranslations(contexts map[string]map[string]*Translation) map[string]map[string]*Translation {
if contexts == nil {
return nil
}
owned := make(map[string]map[string]*Translation, len(contexts))
for context, translations := range contexts {
owned[context] = cloneTranslations(translations)
}
return owned
}
func (do *Domain) hasTranslation(id string) bool {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
return do.translations[id] != nil
}
func (do *Domain) hasContextTranslation(id, context string) bool {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
return do.contextTranslations[context][id] != nil
}
// SetPluralResolver sets a custom plural resolver function
func (do *Domain) SetPluralResolver(f func(int) int) {
do.pluralMutex.Lock()
defer do.pluralMutex.Unlock()
do.customPluralResolver = f
}
func (do *Domain) pluralForm(n int) int {
// Snapshot plural state before calling an expression or custom resolver.
// do we really need locking here? not sure how this plurals.Expression works, so sticking with it for now
do.pluralMutex.RLock()
pluralforms := do.pluralforms
customPluralResolver := do.customPluralResolver
do.pluralMutex.RUnlock()
if pluralforms != nil {
return pluralforms.Eval(uint32(n))
}
defer do.pluralMutex.RUnlock()
// Failure fallback
if customPluralResolver != nil {
return customPluralResolver(n)
}
if do.pluralforms == nil {
if do.customPluralResolver != nil {
return do.customPluralResolver(n)
}
/* Use the Germanic plural rule. */
if n == 1 {
return 0
/* Use the Germanic plural rule. */
if n == 1 {
return 0
}
return 1
}
return 1
return do.pluralforms.Eval(uint32(n))
}
// parseHeaders retrieves data from previously parsed headers. it's called by both Mo and Po when parsing
@@ -212,16 +136,18 @@ func (do *Domain) parseHeaders() {
languageKey := "Language"
pluralFormsKey := "Plural-Forms"
for line := range strings.SplitSeq(raw, "\n") {
rawLines := strings.Split(raw, "\n")
for _, line := range rawLines {
if len(line) == 0 {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
key := line[:colonIdx]
lowerKey := strings.ToLower(key)
if lowerKey == strings.ToLower(languageKey) {
languageKey = key
@@ -229,7 +155,7 @@ func (do *Domain) parseHeaders() {
pluralFormsKey = key
}
value = strings.TrimSpace(value)
value := strings.TrimSpace(line[colonIdx+1:])
do.Headers.Add(key, value)
}
@@ -242,18 +168,22 @@ func (do *Domain) parseHeaders() {
return
}
for i := range strings.SplitSeq(do.PluralForms, ";") {
key, value, ok := strings.Cut(i, "=")
if !ok {
// Split plural form header value
pfs := strings.Split(do.PluralForms, ";")
// Parse values
for _, i := range pfs {
vs := strings.SplitN(i, "=", 2)
if len(vs) != 2 {
continue
}
switch strings.TrimSpace(key) {
switch strings.TrimSpace(vs[0]) {
case "nplurals":
do.nplurals, _ = strconv.Atoi(value)
do.nplurals, _ = strconv.Atoi(vs[1])
case "plural":
do.plural = value
do.plural = vs[1]
if expr, err := plurals.Compile(do.plural); err == nil {
do.pluralforms = expr
@@ -291,19 +221,17 @@ func (do *Domain) DropStaleTranslations() {
// SetRefs set source references for a given translation
func (do *Domain) SetRefs(str string, refs []string) {
ownedRefs := cloneRefs(refs)
do.trMutex.Lock()
do.pluralMutex.Lock()
defer do.trMutex.Unlock()
defer do.pluralMutex.Unlock()
if trans, ok := do.translations[str]; ok && trans != nil {
trans.SetRefs(ownedRefs)
if trans, ok := do.translations[str]; ok {
trans.Refs = refs
} else {
trans := NewTranslation()
trans = NewTranslation()
trans.ID = str
trans.SetRefs(ownedRefs)
trans.SetRefs(refs)
do.translations[str] = trans
}
}
@@ -315,8 +243,8 @@ func (do *Domain) GetRefs(str string) []string {
defer do.trMutex.RUnlock()
if do.translations != nil {
if trans, ok := do.translations[str]; ok && trans != nil {
return cloneRefs(trans.Refs)
if trans, ok := do.translations[str]; ok {
return trans.Refs
}
}
return nil
@@ -340,7 +268,7 @@ func (do *Domain) Set(id, str string) {
}
// Get retrieves the Translation for the given string.
func (do *Domain) Get(str string, vars ...any) string {
func (do *Domain) Get(str string, vars ...interface{}) string {
// Sync read
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -356,8 +284,8 @@ func (do *Domain) Get(str string, vars ...any) string {
}
// Append retrieves the Translation for the given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) Append(b []byte, str string, vars ...any) []byte {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) Append(b []byte, str string, vars ...interface{}) []byte {
// Sync read
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -394,48 +322,40 @@ func (do *Domain) SetN(id, plural string, n int, str string) {
}
// GetN retrieves the (N)th plural form of Translation for the given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetN(str, plural string, n int, vars ...any) string {
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetN(str, plural string, n int, vars ...interface{}) string {
// Sync read
do.trMutex.RLock()
defer do.trMutex.RUnlock()
if do.translations != nil {
if _, ok := do.translations[str]; ok {
return FormatString(do.translations[str].GetN(pluralForm), vars...)
return FormatString(do.translations[str].GetN(do.pluralForm(n)), vars...)
}
}
// Parse plural forms to distinguish between plural and singular
if pluralForm == 0 {
if do.pluralForm(n) == 0 {
return FormatString(str, vars...)
}
return FormatString(plural, vars...)
}
// AppendN adds the (N)th plural form of Translation for the given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendN(b []byte, str, plural string, n int, vars ...any) []byte {
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendN(b []byte, str, plural string, n int, vars ...interface{}) []byte {
// Sync read
do.trMutex.RLock()
defer do.trMutex.RUnlock()
if do.translations != nil {
if _, ok := do.translations[str]; ok {
return Appendf(b, do.translations[str].GetN(pluralForm), vars...)
return Appendf(b, do.translations[str].GetN(do.pluralForm(n)), vars...)
}
}
// Parse plural forms to distinguish between plural and singular
if pluralForm == 0 {
if do.pluralForm(n) == 0 {
return Appendf(b, str, vars...)
}
return Appendf(b, plural, vars...)
@@ -468,8 +388,8 @@ func (do *Domain) SetC(id, ctx, str string) {
}
// GetC retrieves the corresponding Translation for a given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetC(str, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetC(str, ctx string, vars ...interface{}) string {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -488,8 +408,8 @@ func (do *Domain) GetC(str, ctx string, vars ...any) string {
}
// AppendC retrieves the corresponding Translation for a given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendC(b []byte, str, ctx string, vars ...any) []byte {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendC(b []byte, str, ctx string, vars ...interface{}) []byte {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -523,14 +443,12 @@ func (do *Domain) SetNC(id, plural, ctx string, n int, str string) {
} else {
trans = NewTranslation()
trans.ID = id
trans.PluralID = plural
trans.SetN(pluralForm, str)
context[id] = trans
}
} else {
trans := NewTranslation()
trans.ID = id
trans.PluralID = plural
trans.SetN(pluralForm, str)
do.contextTranslations[ctx] = map[string]*Translation{
id: trans,
@@ -539,22 +457,19 @@ func (do *Domain) SetNC(id, plural, ctx string, n int, str string) {
}
// GetNC retrieves the (N)th plural form of Translation for the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetNC(str, plural string, n int, ctx string, vars ...any) string {
if do.hasContextTranslation(str, ctx) {
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) GetNC(str, plural string, n int, ctx string, vars ...interface{}) string {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
do.trMutex.RLock()
if translations, ok := do.contextTranslations[ctx]; ok && translations != nil {
if trans, ok := translations[str]; ok && trans != nil {
translated := trans.GetN(pluralForm)
do.trMutex.RUnlock()
return FormatString(translated, vars...)
if do.contextTranslations != nil {
if _, ok := do.contextTranslations[ctx]; ok {
if do.contextTranslations[ctx] != nil {
if _, ok := do.contextTranslations[ctx][str]; ok {
return FormatString(do.contextTranslations[ctx][str].GetN(do.pluralForm(n)), vars...)
}
}
}
do.trMutex.RUnlock()
}
if n == 1 {
@@ -564,22 +479,19 @@ func (do *Domain) GetNC(str, plural string, n int, ctx string, vars ...any) stri
}
// AppendNC retrieves the (N)th plural form of Translation for the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...any) []byte {
if do.hasContextTranslation(str, ctx) {
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (do *Domain) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...interface{}) []byte {
do.trMutex.RLock()
defer do.trMutex.RUnlock()
do.trMutex.RLock()
if translations, ok := do.contextTranslations[ctx]; ok && translations != nil {
if trans, ok := translations[str]; ok && trans != nil {
translated := trans.GetN(pluralForm)
do.trMutex.RUnlock()
return Appendf(b, translated, vars...)
if do.contextTranslations != nil {
if _, ok := do.contextTranslations[ctx]; ok {
if do.contextTranslations[ctx] != nil {
if _, ok := do.contextTranslations[ctx][str]; ok {
return Appendf(b, do.contextTranslations[ctx][str].GetN(do.pluralForm(n)), vars...)
}
}
}
do.trMutex.RUnlock()
}
if n == 1 {
@@ -595,14 +507,6 @@ func (do *Domain) IsTranslated(str string) bool {
// IsTranslatedN reports whether a plural string is translated
func (do *Domain) IsTranslatedN(str string, n int) bool {
if !do.hasTranslation(str) {
return false
}
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -610,10 +514,10 @@ func (do *Domain) IsTranslatedN(str string, n int) bool {
return false
}
tr, ok := do.translations[str]
if !ok || tr == nil {
if !ok {
return false
}
return tr.IsTranslatedN(pluralForm)
return tr.IsTranslatedN(do.pluralForm(n))
}
// IsTranslatedC reports whether a context string is translated
@@ -623,14 +527,6 @@ func (do *Domain) IsTranslatedC(str, ctx string) bool {
// IsTranslatedNC reports whether a plural context string is translated
func (do *Domain) IsTranslatedNC(str string, n int, ctx string) bool {
if !do.hasContextTranslation(str, ctx) {
return false
}
// Get plural form before acquiring the translation lock so a custom
// resolver is never called while a project lock is held.
pluralForm := do.pluralForm(n)
do.trMutex.RLock()
defer do.trMutex.RUnlock()
@@ -638,24 +534,36 @@ func (do *Domain) IsTranslatedNC(str string, n int, ctx string) bool {
return false
}
translations, ok := do.contextTranslations[ctx]
if !ok || translations == nil {
if !ok {
return false
}
tr, ok := translations[str]
if !ok || tr == nil {
if !ok {
return false
}
return tr.IsTranslatedN(pluralForm)
return tr.IsTranslatedN(do.pluralForm(n))
}
// GetTranslations returns a copy of every translation in the domain. It does not support contexts.
func (do *Domain) GetTranslations() map[string]*Translation {
all := make(map[string]*Translation, len(do.translations))
do.trMutex.RLock()
defer do.trMutex.RUnlock()
all := make(map[string]*Translation, len(do.translations))
for msgID, trans := range do.translations {
all[msgID] = cloneTranslation(trans)
newTrans := NewTranslation()
newTrans.ID = trans.ID
newTrans.PluralID = trans.PluralID
newTrans.dirty = trans.dirty
if len(trans.Refs) > 0 {
newTrans.Refs = make([]string, len(trans.Refs))
copy(newTrans.Refs, trans.Refs)
}
for k, v := range trans.Trs {
newTrans.Trs[k] = v
}
all[msgID] = newTrans
}
return all
@@ -663,18 +571,32 @@ func (do *Domain) GetTranslations() map[string]*Translation {
// GetCtxTranslations returns a copy of every translation in the domain with context
func (do *Domain) GetCtxTranslations() map[string]map[string]*Translation {
all := make(map[string]map[string]*Translation, len(do.contextTranslations))
do.trMutex.RLock()
defer do.trMutex.RUnlock()
all := make(map[string]map[string]*Translation, len(do.contextTranslations))
for ctx, translations := range do.contextTranslations {
for msgID, trans := range translations {
newTrans := NewTranslation()
newTrans.ID = trans.ID
newTrans.PluralID = trans.PluralID
newTrans.dirty = trans.dirty
if len(trans.Refs) > 0 {
newTrans.Refs = make([]string, len(trans.Refs))
copy(newTrans.Refs, trans.Refs)
}
for k, v := range trans.Trs {
newTrans.Trs[k] = v
}
if all[ctx] == nil {
all[ctx] = make(map[string]*Translation)
}
all[ctx][msgID] = cloneTranslation(trans)
all[ctx][msgID] = newTrans
}
}
return all
@@ -682,11 +604,10 @@ func (do *Domain) GetCtxTranslations() map[string]map[string]*Translation {
// SourceReference is a struct to hold source reference information
type SourceReference struct {
path string
line int
context string
hasContext bool
trans *Translation
path string
line int
context string
trans *Translation
}
func extractPathAndLine(ref string) (string, int) {
@@ -706,11 +627,6 @@ func extractPathAndLine(ref string) (string, int) {
// MarshalText implements encoding.TextMarshaler interface
// Assists round-trip of POT/PO content
func (do *Domain) MarshalText() ([]byte, error) {
do.trMutex.RLock()
do.pluralMutex.RLock()
defer do.trMutex.RUnlock()
defer do.pluralMutex.RUnlock()
var buf bytes.Buffer
if len(do.headerComments) > 0 {
buf.WriteString(strings.Join(do.headerComments, "\n"))
@@ -765,29 +681,31 @@ func (do *Domain) MarshalText() ([]byte, error) {
v := do.Headers[k]
for _, value := range v {
encoded := EscapeSpecialCharacters(k + ": " + value + "\n")
buf.WriteString("\n\"" + encoded + "\"")
buf.WriteString("\n\"" + k + ": " + value + "\\n\"")
}
}
// Just as with headers, output translations in consistent order (to minimise diffs between round-trips), with (first) source reference taking priority, followed by context and finally ID
references := make([]SourceReference, 0)
for name, ctx := range do.contextTranslations {
for _, trans := range ctx {
for id, trans := range ctx {
if id == "" {
continue
}
if len(trans.Refs) > 0 {
path, line := extractPathAndLine(trans.Refs[0])
references = append(references, SourceReference{
path: path,
line: line,
context: name,
hasContext: true,
trans: trans,
path,
line,
name,
trans,
})
} else {
references = append(references, SourceReference{
context: name,
hasContext: true,
trans: trans,
"",
0,
name,
trans,
})
}
}
@@ -801,13 +719,17 @@ func (do *Domain) MarshalText() ([]byte, error) {
if len(trans.Refs) > 0 {
path, line := extractPathAndLine(trans.Refs[0])
references = append(references, SourceReference{
path: path,
line: line,
trans: trans,
path,
line,
"",
trans,
})
} else {
references = append(references, SourceReference{
trans: trans,
"",
0,
"",
trans,
})
}
}
@@ -832,9 +754,6 @@ func (do *Domain) MarshalText() ([]byte, error) {
if references[i].context > references[j].context {
return false
}
if references[i].hasContext != references[j].hasContext {
return !references[i].hasContext
}
return references[i].trans.ID < references[j].trans.ID
})
@@ -846,7 +765,7 @@ func (do *Domain) MarshalText() ([]byte, error) {
buf.WriteByte(byte('\n'))
}
if !ref.hasContext {
if ref.context == "" {
buf.WriteString("\nmsgid \"" + EscapeSpecialCharacters(trans.ID) + "\"")
} else {
buf.WriteString("\nmsgctxt \"" + EscapeSpecialCharacters(ref.context) + "\"\nmsgid \"" + EscapeSpecialCharacters(trans.ID) + "\"")
@@ -855,16 +774,9 @@ func (do *Domain) MarshalText() ([]byte, error) {
if trans.PluralID == "" {
buf.WriteString("\nmsgstr \"" + EscapeSpecialCharacters(trans.Trs[0]) + "\"")
} else {
buf.WriteString("\nmsgid_plural \"" + EscapeSpecialCharacters(trans.PluralID) + "\"")
pluralIndexes := make([]int, 0, len(trans.Trs))
for i := range trans.Trs {
pluralIndexes = append(pluralIndexes, i)
}
sort.Ints(pluralIndexes)
for _, i := range pluralIndexes {
buf.WriteString("\nmsgstr[" + strconv.Itoa(i) + "] \"" + EscapeSpecialCharacters(trans.Trs[i]) + "\"")
buf.WriteString("\nmsgid_plural \"" + trans.PluralID + "\"")
for i, tr := range trans.Trs {
buf.WriteString("\nmsgstr[" + EscapeSpecialCharacters(strconv.Itoa(i)) + "] \"" + tr + "\"")
}
}
}
@@ -874,23 +786,8 @@ func (do *Domain) MarshalText() ([]byte, error) {
// EscapeSpecialCharacters escapes special characters in a string
func EscapeSpecialCharacters(s string) string {
var escaped strings.Builder
escaped.Grow(len(s))
s = regexp.MustCompile(`([^\\])(")`).ReplaceAllString(s, "$1\\\"") // Escape non-escaped double quotation marks
for i := range len(s) {
switch s[i] {
case '\\':
escaped.WriteByte('\\')
escaped.WriteByte('\\')
case '"':
escaped.WriteByte('\\')
escaped.WriteByte('"')
default:
escaped.WriteByte(s[i])
}
}
s = escaped.String()
if strings.Count(s, "\n") == 0 {
return s
}
@@ -924,26 +821,16 @@ func EscapeSpecialCharacters(s string) string {
return strings.Join(data, "\n")
}
func (do *Domain) translatorEncodingSnapshot() *TranslatorEncoding {
do.trMutex.RLock()
do.pluralMutex.RLock()
defer do.trMutex.RUnlock()
defer do.pluralMutex.RUnlock()
return &TranslatorEncoding{
Headers: cloneHeaderMap(do.Headers),
Language: do.Language,
PluralForms: do.PluralForms,
Nplurals: do.nplurals,
Plural: do.plural,
Translations: cloneTranslations(do.translations),
Contexts: cloneContextTranslations(do.contextTranslations),
}
}
// MarshalBinary implements encoding.BinaryMarshaler interface
func (do *Domain) MarshalBinary() ([]byte, error) {
obj := do.translatorEncodingSnapshot()
obj := new(TranslatorEncoding)
obj.Headers = do.Headers
obj.Language = do.Language
obj.PluralForms = do.PluralForms
obj.Nplurals = do.nplurals
obj.Plural = do.plural
obj.Translations = do.translations
obj.Contexts = do.contextTranslations
var buff bytes.Buffer
encoder := gob.NewEncoder(&buff)
@@ -958,68 +845,22 @@ func (do *Domain) UnmarshalBinary(data []byte) error {
obj := new(TranslatorEncoding)
decoder := gob.NewDecoder(buff)
if err := decoder.Decode(obj); err != nil {
err := decoder.Decode(obj)
if err != nil {
return err
}
headers := cloneHeaderMap(obj.Headers)
if headers == nil {
headers = make(HeaderMap)
}
translations := cloneTranslations(obj.Translations)
if translations == nil {
translations = make(map[string]*Translation)
}
contextTranslations := cloneContextTranslations(obj.Contexts)
if contextTranslations == nil {
contextTranslations = make(map[string]map[string]*Translation)
}
for id, trans := range translations {
if trans == nil {
trans = NewTranslation()
trans.ID = id
translations[id] = trans
}
}
for context, translationsForContext := range contextTranslations {
if translationsForContext == nil {
contextTranslations[context] = make(map[string]*Translation)
continue
}
for id, trans := range translationsForContext {
if trans == nil {
trans = NewTranslation()
trans.ID = id
translationsForContext[id] = trans
}
}
}
var pluralforms plurals.Expression
if expr, err := plurals.Compile(obj.Plural); err == nil {
pluralforms = expr
}
do.trMutex.Lock()
do.pluralMutex.Lock()
do.Headers = headers
do.Headers = obj.Headers
do.Language = obj.Language
do.PluralForms = obj.PluralForms
do.nplurals = obj.Nplurals
do.plural = obj.Plural
do.pluralforms = pluralforms
do.translations = translations
do.contextTranslations = contextTranslations
do.pluralTranslations = make(map[string]*Translation)
do.headerComments = make([]string, 0)
do.trBuffer = nil
do.ctxBuffer = ""
do.refBuffer = ""
do.translations = obj.Translations
do.contextTranslations = obj.Contexts
do.pluralMutex.Unlock()
do.trMutex.Unlock()
if expr, err := plurals.Compile(do.plural); err == nil {
do.pluralforms = expr
}
return nil
}
+108 -170
View File
@@ -55,59 +55,51 @@ func init() {
domain: "default",
languages: []string{FallbackLocale},
library: "/usr/local/share/locale",
locales: make([]*Locale, 0),
locales: nil,
}
// Register Translator types for gob encoding
gob.Register(TranslatorEncoding{})
}
// loadLocales ensures package-level locales and their configured domain are loaded.
// loadLocales creates a new Locale object for every language (specified using Configure)
// at package level based on the configuration of global configuration .
// It is called when trying to use Get or GetD methods.
func loadLocales(rebuildCache bool) {
globalConfig.Lock()
if globalConfig.locales == nil || rebuildCache {
locales := make([]*Locale, 0, len(globalConfig.languages))
var locales []*Locale
for _, language := range globalConfig.languages {
locales = append(locales, NewLocale(globalConfig.library, language))
}
globalConfig.locales = locales
}
locales := append([]*Locale(nil), globalConfig.locales...)
domain := globalConfig.domain
globalConfig.Unlock()
for _, locale := range locales {
if locale == nil {
continue
for _, locale := range globalConfig.locales {
if _, ok := locale.Domains[globalConfig.domain]; !ok || rebuildCache {
locale.AddDomain(globalConfig.domain)
}
if rebuildCache || !locale.hasDomain(domain) {
locale.AddDomain(domain)
}
locale.SetDomain(domain)
locale.SetDomain(globalConfig.domain)
}
globalConfig.Unlock()
}
// GetDomain is the domain getter for the package configuration
func GetDomain() string {
var dom string
globalConfig.RLock()
domain := globalConfig.domain
var locale *Locale
for _, candidate := range globalConfig.locales {
if candidate != nil {
locale = candidate
break
}
if globalConfig.locales != nil {
// All locales have the same domain
dom = globalConfig.locales[0].GetDomain()
}
if dom == "" {
dom = globalConfig.domain
}
globalConfig.RUnlock()
if locale != nil {
if localeDomain := locale.GetDomain(); localeDomain != "" {
return localeDomain
}
}
return domain
return dom
}
// SetDomain sets the name for the domain to be used at package level.
@@ -115,8 +107,8 @@ func GetDomain() string {
func SetDomain(dom string) {
globalConfig.Lock()
globalConfig.domain = dom
for _, locale := range globalConfig.locales {
if locale != nil {
if globalConfig.locales != nil {
for _, locale := range globalConfig.locales {
locale.SetDomain(dom)
}
}
@@ -139,10 +131,8 @@ func GetLanguage() string {
// GetLanguages returns all languages that have been supplied.
func GetLanguages() []string {
globalConfig.RLock()
languages := make([]string, len(globalConfig.languages))
copy(languages, globalConfig.languages)
globalConfig.RUnlock()
return languages
defer globalConfig.RUnlock()
return globalConfig.languages
}
// SetLanguage sets the language code (or colon separated language codes) to be used at package level.
@@ -150,7 +140,7 @@ func GetLanguages() []string {
func SetLanguage(lang string) {
globalConfig.Lock()
var languages []string
for language := range strings.SplitSeq(lang, ":") {
for _, language := range strings.Split(lang, ":") {
languages = append(languages, SimplifiedLocale(language))
}
globalConfig.languages = languages
@@ -181,20 +171,17 @@ func SetLibrary(lib string) {
// GetLocales returns the locales that have been set for the package configuration.
func GetLocales() []*Locale {
globalConfig.RLock()
locales := make([]*Locale, len(globalConfig.locales))
copy(locales, globalConfig.locales)
globalConfig.RUnlock()
return locales
defer globalConfig.RUnlock()
return globalConfig.locales
}
// GetStorage is the locale storage getter for the package configuration.
//
// Deprecated: Storage has been renamed to Locale for consistency, use GetLocales instead.
func GetStorage() *Locale {
for _, locale := range GetLocales() {
if locale != nil {
return locale
}
locales := GetLocales()
if len(locales) > 0 {
return locales[0]
}
return nil
}
@@ -203,40 +190,24 @@ func GetStorage() *Locale {
// NewLocale(). This makes it possible to attach custom Domain objects from in-memory po/mo.
// The library, language and domain of the first Locale will set the default global configuration.
func SetLocales(locales []*Locale) {
ownedLocales := make([]*Locale, len(locales))
copy(ownedLocales, locales)
globalConfig.Lock()
globalConfig.locales = ownedLocales
defer globalConfig.Unlock()
languages := make([]string, 0, len(ownedLocales))
var first *Locale
for _, locale := range ownedLocales {
if locale == nil {
continue
}
globalConfig.locales = locales
globalConfig.library = locales[0].path
globalConfig.domain = locales[0].defaultDomain
locale.RLock()
if first == nil {
first = locale
globalConfig.library = locale.path
globalConfig.domain = locale.defaultDomain
}
var languages []string
for _, locale := range locales {
languages = append(languages, locale.lang)
locale.RUnlock()
}
globalConfig.languages = languages
globalConfig.Unlock()
}
// SetStorage allows overriding the global Locale object with one built manually with NewLocale().
//
// Deprecated: Storage has been renamed to Locale for consistency, use SetLocales instead.
func SetStorage(locale *Locale) {
if locale == nil {
SetLocales(nil)
return
}
SetLocales([]*Locale{locale})
}
@@ -248,7 +219,7 @@ func Configure(lib, lang, dom string) {
globalConfig.Lock()
globalConfig.library = lib
var languages []string
for language := range strings.SplitSeq(lang, ":") {
for _, language := range strings.Split(lang, ":") {
languages = append(languages, SimplifiedLocale(language))
}
globalConfig.languages = languages
@@ -259,137 +230,114 @@ func Configure(lib, lang, dom string) {
}
// Get uses the default domain globally set to return the corresponding Translation of a given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func Get(str string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func Get(str string, vars ...interface{}) string {
return GetD(GetDomain(), str, vars...)
}
// GetN retrieves the (N)th plural form of Translation for the given string in the default domain.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetN(str, plural string, n int, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetN(str, plural string, n int, vars ...interface{}) string {
return GetND(GetDomain(), str, plural, n, vars...)
}
// GetD returns the corresponding Translation in the given domain for a given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetD(dom, str string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetD(dom, str string, vars ...interface{}) string {
// Try to load default package Locales
loadLocales(false)
locales := GetLocales()
var fallback *Locale
for _, locale := range locales {
if locale == nil {
continue
}
fallback = locale
if !locale.hasDomain(dom) {
globalConfig.RLock()
defer globalConfig.RUnlock()
var tr string
for i, locale := range globalConfig.locales {
if _, ok := locale.Domains[dom]; !ok {
locale.AddDomain(dom)
}
if locale.IsTranslatedD(dom, str) {
return locale.GetD(dom, str, vars...)
if !locale.IsTranslatedD(dom, str) && i < (len(globalConfig.locales)-1) {
continue
}
tr = locale.GetD(dom, str, vars...)
break
}
if fallback != nil {
return fallback.GetD(dom, str, vars...)
}
return FormatString(str, vars...)
return tr
}
// GetND retrieves the (N)th plural form of Translation in the given domain for a given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetND(dom, str, plural string, n int, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetND(dom, str, plural string, n int, vars ...interface{}) string {
// Try to load default package Locales
loadLocales(false)
locales := GetLocales()
var fallback *Locale
for _, locale := range locales {
if locale == nil {
continue
}
fallback = locale
if !locale.hasDomain(dom) {
globalConfig.RLock()
defer globalConfig.RUnlock()
var tr string
for i, locale := range globalConfig.locales {
if _, ok := locale.Domains[dom]; !ok {
locale.AddDomain(dom)
}
if locale.IsTranslatedND(dom, str, n) {
return locale.GetND(dom, str, plural, n, vars...)
if !locale.IsTranslatedND(dom, str, n) && i < (len(globalConfig.locales)-1) {
continue
}
tr = locale.GetND(dom, str, plural, n, vars...)
break
}
if fallback != nil {
return fallback.GetND(dom, str, plural, n, vars...)
}
if n == 1 {
return FormatString(str, vars...)
}
return FormatString(plural, vars...)
return tr
}
// GetC uses the default domain globally set to return the corresponding Translation of the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetC(str, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetC(str, ctx string, vars ...interface{}) string {
return GetDC(GetDomain(), str, ctx, vars...)
}
// GetNC retrieves the (N)th plural form of Translation for the given string in the given context in the default domain.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetNC(str, plural string, n int, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetNC(str, plural string, n int, ctx string, vars ...interface{}) string {
return GetNDC(GetDomain(), str, plural, n, ctx, vars...)
}
// GetDC returns the corresponding Translation in the given domain for the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetDC(dom, str, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetDC(dom, str, ctx string, vars ...interface{}) string {
// Try to load default package Locales
loadLocales(false)
locales := GetLocales()
var fallback *Locale
for _, locale := range locales {
if locale == nil {
globalConfig.RLock()
defer globalConfig.RUnlock()
var tr string
for i, locale := range globalConfig.locales {
if !locale.IsTranslatedDC(dom, str, ctx) && i < (len(globalConfig.locales)-1) {
continue
}
fallback = locale
if !locale.hasDomain(dom) {
locale.AddDomain(dom)
}
if locale.IsTranslatedDC(dom, str, ctx) {
return locale.GetDC(dom, str, ctx, vars...)
}
tr = locale.GetDC(dom, str, ctx, vars...)
break
}
if fallback != nil {
return fallback.GetDC(dom, str, ctx, vars...)
}
return FormatString(str, vars...)
return tr
}
// GetNDC retrieves the (N)th plural form of Translation in the given domain for a given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func GetNDC(dom, str, plural string, n int, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func GetNDC(dom, str, plural string, n int, ctx string, vars ...interface{}) string {
// Try to load default package Locales
loadLocales(false)
locales := GetLocales()
var fallback *Locale
for _, locale := range locales {
if locale == nil {
// Return Translation
globalConfig.RLock()
defer globalConfig.RUnlock()
var tr string
for i, locale := range globalConfig.locales {
if !locale.IsTranslatedNDC(dom, str, n, ctx) && i < (len(globalConfig.locales)-1) {
continue
}
fallback = locale
if !locale.hasDomain(dom) {
locale.AddDomain(dom)
}
if locale.IsTranslatedNDC(dom, str, n, ctx) {
return locale.GetNDC(dom, str, plural, n, ctx, vars...)
}
tr = locale.GetNDC(dom, str, plural, n, ctx, vars...)
break
}
if fallback != nil {
return fallback.GetNDC(dom, str, plural, n, ctx, vars...)
}
if n == 1 {
return FormatString(str, vars...)
}
return FormatString(plural, vars...)
return tr
}
// IsTranslated reports whether a string is translated in given languages.
@@ -418,23 +366,18 @@ func IsTranslatedND(dom, str string, n int, langs ...string) bool {
}
loadLocales(false)
locales := GetLocales()
globalConfig.RLock()
defer globalConfig.RUnlock()
for _, lang := range langs {
lang = SimplifiedLocale(lang)
for _, supportedLocale := range locales {
if supportedLocale == nil {
for _, supportedLocale := range globalConfig.locales {
if lang != supportedLocale.GetActualLanguage(dom) {
continue
}
if lang != supportedLocale.GetLanguage() && lang != supportedLocale.GetActualLanguage(dom) {
continue
}
if !supportedLocale.hasDomain(dom) {
supportedLocale.AddDomain(dom)
}
if supportedLocale.IsTranslatedND(dom, str, n) {
return true
}
return supportedLocale.IsTranslatedND(dom, str, n)
}
}
return false
@@ -455,7 +398,7 @@ func IsTranslatedNC(str string, n int, ctx string, langs ...string) bool {
// IsTranslatedDC reports whether a domain context string is translated in given languages.
// When the langs argument is omitted, the output of GetLanguages is used.
func IsTranslatedDC(dom, str, ctx string, langs ...string) bool {
return IsTranslatedNDC(dom, str, 1, ctx, langs...)
return IsTranslatedNDC(dom, str, 0, ctx, langs...)
}
// IsTranslatedNDC reports whether a plural domain context string is translated in any of given languages.
@@ -466,23 +409,18 @@ func IsTranslatedNDC(dom, str string, n int, ctx string, langs ...string) bool {
}
loadLocales(false)
locales := GetLocales()
globalConfig.RLock()
defer globalConfig.RUnlock()
for _, lang := range langs {
lang = SimplifiedLocale(lang)
for _, supportedLocale := range locales {
if supportedLocale == nil {
for _, locale := range globalConfig.locales {
if lang != locale.GetActualLanguage(dom) {
continue
}
if lang != supportedLocale.GetLanguage() && lang != supportedLocale.GetActualLanguage(dom) {
continue
}
if !supportedLocale.hasDomain(dom) {
supportedLocale.AddDomain(dom)
}
if supportedLocale.IsTranslatedNDC(dom, str, n, ctx) {
return true
}
return locale.IsTranslatedNDC(dom, str, n, ctx)
}
}
return false
+27 -31
View File
@@ -29,21 +29,16 @@ func SimplifiedLocale(lang string) string {
}
// FormatString applies text formatting only when needed to parse variables.
func FormatString(str string, vars ...any) string {
func FormatString(str string, vars ...interface{}) string {
if len(vars) > 0 {
return FormatStringWithArgs(str, vars)
return fmt.Sprintf(str, vars...)
}
return str
}
// FormatStringWithArgs applies text formatting as a proxy for fmt.Sprintf.
func FormatStringWithArgs(str string, vars []any) string {
return fmt.Sprintf(str, vars...)
}
// Appendf applies text formatting only when needed to parse variables.
func Appendf(b []byte, str string, vars ...any) []byte {
func Appendf(b []byte, str string, vars ...interface{}) []byte {
if len(vars) > 0 {
return fmt.Appendf(b, str, vars...)
}
@@ -52,49 +47,50 @@ func Appendf(b []byte, str string, vars ...any) []byte {
}
// NPrintf support named format
// NPrintf("%(name)s is Type %(type)s", map[string]any{"name": "Gotext", "type": "struct"})
func NPrintf(format string, params map[string]any) {
// NPrintf("%(name)s is Type %(type)s", map[string]interface{}{"name": "Gotext", "type": "struct"})
func NPrintf(format string, params map[string]interface{}) {
f, p := parseSprintf(format, params)
fmt.Printf(f, p...)
}
// Sprintf support named format
//
// Sprintf("%(name)s is Type %(type)s", map[string]any{"name": "Gotext", "type": "struct"})
func Sprintf(format string, params map[string]any) string {
// Sprintf("%(name)s is Type %(type)s", map[string]interface{}{"name": "Gotext", "type": "struct"})
func Sprintf(format string, params map[string]interface{}) string {
f, p := parseSprintf(format, params)
return fmt.Sprintf(f, p...)
}
func parseSprintf(format string, params map[string]any) (string, []any) {
func parseSprintf(format string, params map[string]interface{}) (string, []interface{}) {
f, n := reformatSprintf(format)
var p []any
if len(n) > 0 {
p = make([]any, len(n))
for i, v := range n {
p[i] = params[v]
}
var p []interface{}
for _, v := range n {
p = append(p, params[v])
}
return f, p
}
func reformatSprintf(f string) (string, []string) {
matches := re.FindAllStringSubmatchIndex(f, -1)
ord := make([]string, 0, len(matches))
pair := make([]int, 1, 1+2*len(matches))
pair[0] = 0
m := re.FindAllStringSubmatch(f, -1)
i := re.FindAllStringSubmatchIndex(f, -1)
for _, match := range matches {
ord = append(ord, f[match[2]:match[3]])
pair = append(pair, match[2]-1, match[3]+1)
ord := []string{}
for _, v := range m {
ord = append(ord, v[1])
}
pair := []int{0}
for _, v := range i {
pair = append(pair, v[2]-1)
pair = append(pair, v[3]+1)
}
pair = append(pair, len(f))
plen := len(pair)
var out strings.Builder
out.Grow(len(f))
for n := 0; n < len(pair); n += 2 {
out.WriteString(f[pair[n]:pair[n+1]])
out := ""
for n := 0; n < plen; n += 2 {
out += f[pair[n]:pair[n+1]]
}
return out.String(), ord
return out, ord
}
+142 -193
View File
@@ -9,7 +9,6 @@ import (
"bytes"
"encoding/gob"
"io/fs"
"maps"
"os"
"path"
"sync"
@@ -23,6 +22,8 @@ multiple languages at the same time by working with this object.
Example:
import (
"encoding/gob"
"bytes"
"fmt"
"github.com/leonelquinteros/gotext"
)
@@ -89,114 +90,95 @@ func NewLocaleFSWithPath(l string, filesystem fs.FS, p string) *Locale {
return loc
}
func (l *Locale) snapshotPathLanguageFS() (string, string, fs.FS) {
if l == nil {
return "", "", nil
func (l *Locale) findExt(dom, ext string) string {
filename := path.Join(l.path, l.lang, "LC_MESSAGES", dom+"."+ext)
if l.fileExists(filename) {
return filename
}
l.RLock()
p := l.path
lang := l.lang
filesystem := l.fs
l.RUnlock()
return p, lang, filesystem
}
func fileExistsIn(filesystem fs.FS, filename string) bool {
if filesystem != nil {
_, err := fs.Stat(filesystem, filename)
return err == nil
}
_, err := os.Stat(filename)
return err == nil
}
func (l *Locale) findExtWithFS(dom, ext string) (fs.FS, string) {
p, lang, filesystem := l.snapshotPathLanguageFS()
filename := path.Join(p, lang, "LC_MESSAGES", dom+"."+ext)
if fileExistsIn(filesystem, filename) {
return filesystem, filename
}
if len(lang) > 2 {
filename = path.Join(p, lang[:2], "LC_MESSAGES", dom+"."+ext)
if fileExistsIn(filesystem, filename) {
return filesystem, filename
if len(l.lang) > 2 {
filename = path.Join(l.path, l.lang[:2], "LC_MESSAGES", dom+"."+ext)
if l.fileExists(filename) {
return filename
}
}
filename = path.Join(p, lang, dom+"."+ext)
if fileExistsIn(filesystem, filename) {
return filesystem, filename
filename = path.Join(l.path, l.lang, dom+"."+ext)
if l.fileExists(filename) {
return filename
}
if len(lang) > 2 {
filename = path.Join(p, lang[:2], dom+"."+ext)
if fileExistsIn(filesystem, filename) {
return filesystem, filename
if len(l.lang) > 2 {
filename = path.Join(l.path, l.lang[:2], dom+"."+ext)
if l.fileExists(filename) {
return filename
}
}
return filesystem, ""
return ""
}
// GetActualLanguage inspects the filesystem and decides whether to strip
// a CC part of the ll_CC locale string.
func (l *Locale) GetActualLanguage(dom string) string {
p, lang, filesystem := l.snapshotPathLanguageFS()
extensions := []string{"po", "mo"}
extensions := []string{"mo", "po"}
var fp string
for _, ext := range extensions {
// 'll' (or 'll_CC') exists, and it was specified as-is
fp = path.Join(p, lang, "LC_MESSAGES", dom+"."+ext)
if fileExistsIn(filesystem, fp) {
return lang
fp = path.Join(l.path, l.lang, "LC_MESSAGES", dom+"."+ext)
if l.fileExists(fp) {
return l.lang
}
// 'll' exists, but 'll_CC' was specified
if len(lang) > 2 {
fp = path.Join(p, lang[:2], "LC_MESSAGES", dom+"."+ext)
if fileExistsIn(filesystem, fp) {
return lang[:2]
if len(l.lang) > 2 {
fp = path.Join(l.path, l.lang[:2], "LC_MESSAGES", dom+"."+ext)
if l.fileExists(fp) {
return l.lang[:2]
}
}
// 'll' (or 'll_CC') exists outside of LC_category, and it was specified as-is
fp = path.Join(p, lang, dom+"."+ext)
if fileExistsIn(filesystem, fp) {
return lang
fp = path.Join(l.path, l.lang, dom+"."+ext)
if l.fileExists(fp) {
return l.lang
}
// 'll' exists outside of LC_category, but 'll_CC' was specified
if len(lang) > 2 {
fp = path.Join(p, lang[:2], dom+"."+ext)
if fileExistsIn(filesystem, fp) {
return lang[:2]
if len(l.lang) > 2 {
fp = path.Join(l.path, l.lang[:2], dom+"."+ext)
if l.fileExists(fp) {
return l.lang[:2]
}
}
}
return ""
}
func (l *Locale) fileExists(filename string) bool {
if l.fs != nil {
_, err := fs.Stat(l.fs, filename)
return err == nil
}
_, err := os.Stat(filename)
return err == nil
}
// AddDomain creates a new domain for a given locale object and initializes the Po object.
// If the domain exists, it gets reloaded.
func (l *Locale) AddDomain(dom string) {
if l == nil {
return
}
var poObj Translator
filesystem, file := l.findExtWithFS(dom, "po")
file := l.findExt(dom, "po")
if file != "" {
poObj = NewPoFS(filesystem)
poObj = NewPoFS(l.fs)
// Parse file.
poObj.ParseFile(file)
} else {
filesystem, file = l.findExtWithFS(dom, "mo")
file = l.findExt(dom, "mo")
if file != "" {
poObj = NewMoFS(filesystem)
poObj = NewMoFS(l.fs)
// Parse file.
poObj.ParseFile(file)
} else {
// fallback return if no file found with
return
}
}
@@ -230,34 +212,9 @@ func (l *Locale) AddTranslator(dom string, tr Translator) {
l.Unlock()
}
func (l *Locale) translatorFor(dom string) Translator {
if l == nil {
return nil
}
l.RLock()
translator := l.Domains[dom]
l.RUnlock()
return translator
}
func (l *Locale) hasDomain(dom string) bool {
if l == nil {
return false
}
l.RLock()
_, ok := l.Domains[dom]
l.RUnlock()
return ok
}
// GetDomain is the domain getter for Locale configuration
func (l *Locale) GetDomain() string {
if l == nil {
return ""
}
l.RLock()
dom := l.defaultDomain
l.RUnlock()
@@ -266,10 +223,6 @@ func (l *Locale) GetDomain() string {
// SetDomain sets the name for the domain to be used.
func (l *Locale) SetDomain(dom string) {
if l == nil {
return
}
l.Lock()
l.defaultDomain = dom
l.Unlock()
@@ -277,10 +230,6 @@ func (l *Locale) SetDomain(dom string) {
// GetLanguage is the lang getter for Locale configuration
func (l *Locale) GetLanguage() string {
if l == nil {
return ""
}
l.RLock()
lang := l.lang
l.RUnlock()
@@ -288,37 +237,51 @@ func (l *Locale) GetLanguage() string {
}
// Get uses a domain "default" to return the corresponding Translation of a given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) Get(str string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) Get(str string, vars ...interface{}) string {
return l.GetD(l.GetDomain(), str, vars...)
}
// GetN retrieves the (N)th plural form of Translation for the given string in the "default" domain.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetN(str, plural string, n int, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetN(str, plural string, n int, vars ...interface{}) string {
return l.GetND(l.GetDomain(), str, plural, n, vars...)
}
// GetD returns the corresponding Translation in the given domain for the given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetD(dom, str string, vars ...any) string {
translator := l.translatorFor(dom)
if translator != nil {
return translator.Get(str, vars...)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetD(dom, str string, vars ...interface{}) string {
// Sync read
l.RLock()
defer l.RUnlock()
if l.Domains != nil {
if _, ok := l.Domains[dom]; ok {
if l.Domains[dom] != nil {
return l.Domains[dom].Get(str, vars...)
}
}
}
return FormatString(str, vars...)
}
// GetND retrieves the (N)th plural form of Translation in the given domain for the given string.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetND(dom, str, plural string, n int, vars ...any) string {
translator := l.translatorFor(dom)
if translator != nil {
return translator.GetN(str, plural, n, vars...)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetND(dom, str, plural string, n int, vars ...interface{}) string {
// Sync read
l.RLock()
defer l.RUnlock()
if l.Domains != nil {
if _, ok := l.Domains[dom]; ok {
if l.Domains[dom] != nil {
return l.Domains[dom].GetN(str, plural, n, vars...)
}
}
}
// Use western default rule (n != 1) to handle missing domain default result.
// Use western default rule (plural > 1) to handle missing domain default result.
if n == 1 {
return FormatString(str, vars...)
}
@@ -326,37 +289,51 @@ func (l *Locale) GetND(dom, str, plural string, n int, vars ...any) string {
}
// GetC uses a domain "default" to return the corresponding Translation of the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetC(str, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetC(str, ctx string, vars ...interface{}) string {
return l.GetDC(l.GetDomain(), str, ctx, vars...)
}
// GetNC retrieves the (N)th plural form of Translation for the given string in the given context in the "default" domain.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetNC(str, plural string, n int, ctx string, vars ...any) string {
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetNC(str, plural string, n int, ctx string, vars ...interface{}) string {
return l.GetNDC(l.GetDomain(), str, plural, n, ctx, vars...)
}
// GetDC returns the corresponding Translation in the given domain for the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetDC(dom, str, ctx string, vars ...any) string {
translator := l.translatorFor(dom)
if translator != nil {
return translator.GetC(str, ctx, vars...)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetDC(dom, str, ctx string, vars ...interface{}) string {
// Sync read
l.RLock()
defer l.RUnlock()
if l.Domains != nil {
if _, ok := l.Domains[dom]; ok {
if l.Domains[dom] != nil {
return l.Domains[dom].GetC(str, ctx, vars...)
}
}
}
return FormatString(str, vars...)
}
// GetNDC retrieves the (N)th plural form of Translation in the given domain for the given string in the given context.
// Supports optional parameters (vars... any) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetNDC(dom, str, plural string, n int, ctx string, vars ...any) string {
translator := l.translatorFor(dom)
if translator != nil {
return translator.GetNC(str, plural, n, ctx, vars...)
// Supports optional parameters (vars... interface{}) to be inserted on the formatted string using the fmt.Printf syntax.
func (l *Locale) GetNDC(dom, str, plural string, n int, ctx string, vars ...interface{}) string {
// Sync read
l.RLock()
defer l.RUnlock()
if l.Domains != nil {
if _, ok := l.Domains[dom]; ok {
if l.Domains[dom] != nil {
return l.Domains[dom].GetNC(str, plural, n, ctx, vars...)
}
}
}
// Use western default rule (n != 1) to handle missing domain default result.
// Use western default rule (plural > 1) to handle missing domain default result.
if n == 1 {
return FormatString(str, vars...)
}
@@ -366,26 +343,13 @@ func (l *Locale) GetNDC(dom, str, plural string, n int, ctx string, vars ...any)
// GetTranslations returns a copy of all translations in all domains of this locale. It does not support contexts.
func (l *Locale) GetTranslations() map[string]*Translation {
all := make(map[string]*Translation)
if l == nil {
return all
}
l.RLock()
translators := make([]Translator, 0, len(l.Domains))
defer l.RUnlock()
for _, translator := range l.Domains {
translators = append(translators, translator)
}
l.RUnlock()
for _, translator := range translators {
if translator == nil {
continue
for msgID, trans := range translator.GetDomain().GetTranslations() {
all[msgID] = trans
}
domain := translator.GetDomain()
if domain == nil {
continue
}
maps.Copy(all, domain.GetTranslations())
}
return all
@@ -393,7 +357,7 @@ func (l *Locale) GetTranslations() map[string]*Translation {
// IsTranslated reports whether a string is translated
func (l *Locale) IsTranslated(str string) bool {
return l.IsTranslatedND(l.GetDomain(), str, 1)
return l.IsTranslatedND(l.GetDomain(), str, 0)
}
// IsTranslatedN reports whether a plural string is translated
@@ -403,25 +367,27 @@ func (l *Locale) IsTranslatedN(str string, n int) bool {
// IsTranslatedD reports whether a domain string is translated
func (l *Locale) IsTranslatedD(dom, str string) bool {
return l.IsTranslatedND(dom, str, 1)
return l.IsTranslatedND(dom, str, 0)
}
// IsTranslatedND reports whether a plural domain string is translated
func (l *Locale) IsTranslatedND(dom, str string, n int) bool {
translator := l.translatorFor(dom)
if translator == nil {
l.RLock()
defer l.RUnlock()
if l.Domains == nil {
return false
}
domain := translator.GetDomain()
if domain == nil {
translator, ok := l.Domains[dom]
if !ok {
return false
}
return domain.IsTranslatedN(str, n)
return translator.GetDomain().IsTranslatedN(str, n)
}
// IsTranslatedC reports whether a context string is translated
func (l *Locale) IsTranslatedC(str, ctx string) bool {
return l.IsTranslatedNDC(l.GetDomain(), str, 1, ctx)
return l.IsTranslatedNDC(l.GetDomain(), str, 0, ctx)
}
// IsTranslatedNC reports whether a plural context string is translated
@@ -431,20 +397,22 @@ func (l *Locale) IsTranslatedNC(str string, n int, ctx string) bool {
// IsTranslatedDC reports whether a domain context string is translated
func (l *Locale) IsTranslatedDC(dom, str, ctx string) bool {
return l.IsTranslatedNDC(dom, str, 1, ctx)
return l.IsTranslatedNDC(dom, str, 0, ctx)
}
// IsTranslatedNDC reports whether a plural domain context string is translated
func (l *Locale) IsTranslatedNDC(dom string, str string, n int, ctx string) bool {
translator := l.translatorFor(dom)
if translator == nil {
l.RLock()
defer l.RUnlock()
if l.Domains == nil {
return false
}
domain := translator.GetDomain()
if domain == nil {
translator, ok := l.Domains[dom]
if !ok {
return false
}
return domain.IsTranslatedNC(str, n, ctx)
return translator.GetDomain().IsTranslatedNC(str, n, ctx)
}
// LocaleEncoding is used as intermediary storage to encode Locale objects to Gob.
@@ -457,35 +425,18 @@ type LocaleEncoding struct {
// MarshalBinary implements encoding BinaryMarshaler interface
func (l *Locale) MarshalBinary() ([]byte, error) {
var localePath, lang, defaultDomain string
domains := make(map[string]Translator)
if l != nil {
l.RLock()
localePath = l.path
lang = l.lang
defaultDomain = l.defaultDomain
domains = make(map[string]Translator, len(l.Domains))
for name, translator := range l.Domains {
if translator != nil {
domains[name] = translator
}
}
l.RUnlock()
}
obj := &LocaleEncoding{
Path: localePath,
Lang: lang,
Domains: make(map[string][]byte, len(domains)),
DefaultDomain: defaultDomain,
}
for name, translator := range domains {
data, err := translator.MarshalBinary()
obj := new(LocaleEncoding)
obj.DefaultDomain = l.defaultDomain
obj.Domains = make(map[string][]byte)
for k, v := range l.Domains {
var err error
obj.Domains[k], err = v.MarshalBinary()
if err != nil {
return nil, err
}
obj.Domains[name] = data
}
obj.Lang = l.lang
obj.Path = l.path
var buff bytes.Buffer
encoder := gob.NewEncoder(&buff)
@@ -494,7 +445,7 @@ func (l *Locale) MarshalBinary() ([]byte, error) {
return buff.Bytes(), err
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler interface
// UnmarshalBinary implements encoding BinaryUnmarshaler interface
func (l *Locale) UnmarshalBinary(data []byte) error {
buff := bytes.NewBuffer(data)
obj := new(LocaleEncoding)
@@ -505,7 +456,12 @@ func (l *Locale) UnmarshalBinary(data []byte) error {
return err
}
domains := make(map[string]Translator, len(obj.Domains))
l.defaultDomain = obj.DefaultDomain
l.lang = obj.Lang
l.path = obj.Path
// Decode Domains
l.Domains = make(map[string]Translator)
for k, v := range obj.Domains {
var tr TranslatorEncoding
buff := bytes.NewBuffer(v)
@@ -515,15 +471,8 @@ func (l *Locale) UnmarshalBinary(data []byte) error {
return err
}
domains[k] = tr.GetTranslator()
l.Domains[k] = tr.GetTranslator()
}
l.Lock()
l.defaultDomain = obj.DefaultDomain
l.lang = obj.Lang
l.path = obj.Path
l.Domains = domains
l.Unlock()
return nil
}
-40
View File
@@ -1,40 +0,0 @@
site_name: gotext
site_description: GNU gettext utilities for Go
site_author: Leonel Quinteros
repo_url: https://github.com/leonelquinteros/gotext
edit_uri: edit/master/docs/
theme:
name: material
palette:
primary: indigo
accent: indigo
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.suggest
- search.highlight
nav:
- Home: index.md
- Getting Started: GETTING_STARTED.md
- User Guides:
- Plural Forms: PLURALS.md
- xgotext CLI: xgotext.md
- Character Encoding: ENCODING.md
- Advanced Usage: ADVANCED.md
- Best Practices: BEST_PRACTICES.md
- API Reference: https://pkg.go.dev/github.com/leonelquinteros/gotext
- Contributing:
- Guidelines: CONTRIBUTING.md
- Code of Conduct: CODE_OF_CONDUCT.md
- License: LICENSE.md
markdown_extensions:
- admonition
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
+94 -62
View File
@@ -76,42 +76,42 @@ func (mo *Mo) GetDomain() *Domain {
}
// Get returns the translation for the given string
func (mo *Mo) Get(str string, vars ...any) string {
func (mo *Mo) Get(str string, vars ...interface{}) string {
return mo.domain.Get(str, vars...)
}
// Append a translation string into the domain
func (mo *Mo) Append(b []byte, str string, vars ...any) []byte {
func (mo *Mo) Append(b []byte, str string, vars ...interface{}) []byte {
return mo.domain.Append(b, str, vars...)
}
// GetN returns the translation for the given string and plural form
func (mo *Mo) GetN(str, plural string, n int, vars ...any) string {
func (mo *Mo) GetN(str, plural string, n int, vars ...interface{}) string {
return mo.domain.GetN(str, plural, n, vars...)
}
// AppendN appends a translation string for the given plural form into the domain
func (mo *Mo) AppendN(b []byte, str, plural string, n int, vars ...any) []byte {
func (mo *Mo) AppendN(b []byte, str, plural string, n int, vars ...interface{}) []byte {
return mo.domain.AppendN(b, str, plural, n, vars...)
}
// GetC returns the translation for the given string and context
func (mo *Mo) GetC(str, ctx string, vars ...any) string {
func (mo *Mo) GetC(str, ctx string, vars ...interface{}) string {
return mo.domain.GetC(str, ctx, vars...)
}
// AppendC appends a translation string for the given context into the domain
func (mo *Mo) AppendC(b []byte, str, ctx string, vars ...any) []byte {
func (mo *Mo) AppendC(b []byte, str, ctx string, vars ...interface{}) []byte {
return mo.domain.AppendC(b, str, ctx, vars...)
}
// GetNC returns the translation for the given string, plural form and context
func (mo *Mo) GetNC(str, plural string, n int, ctx string, vars ...any) string {
func (mo *Mo) GetNC(str, plural string, n int, ctx string, vars ...interface{}) string {
return mo.domain.GetNC(str, plural, n, ctx, vars...)
}
// AppendNC appends a translation string for the given plural form and context into the domain
func (mo *Mo) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...any) []byte {
func (mo *Mo) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...interface{}) []byte {
return mo.domain.AppendNC(b, str, plural, n, ctx, vars...)
}
@@ -163,18 +163,22 @@ func (mo *Mo) Parse(buf []byte) {
defer mo.domain.trMutex.Unlock()
defer mo.domain.pluralMutex.Unlock()
if len(buf) < 28 {
return
}
r := bytes.NewReader(buf)
var magicNumber uint32
if err := binary.Read(r, binary.LittleEndian, &magicNumber); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
var bo binary.ByteOrder
switch binary.LittleEndian.Uint32(buf[:4]) {
switch magicNumber {
case MoMagicLittleEndian:
bo = binary.LittleEndian
case MoMagicBigEndian:
bo = binary.BigEndian
default:
return
// return fmt.Errorf("gettext: %v", "invalid magic number")
}
var header struct {
@@ -183,58 +187,82 @@ func (mo *Mo) Parse(buf []byte) {
MsgIDCount uint32
MsgIDOffset uint32
MsgStrOffset uint32
HashSize uint32
HashOffset uint32
}
bufLen := uint64(len(buf))
header.MajorVersion = bo.Uint16(buf[4:6])
header.MinorVersion = bo.Uint16(buf[6:8])
header.MsgIDCount = bo.Uint32(buf[8:12])
header.MsgIDOffset = bo.Uint32(buf[12:16])
header.MsgStrOffset = bo.Uint32(buf[16:20])
if header.MajorVersion != 0 && header.MajorVersion != 1 {
if err := binary.Read(r, bo, &header); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
if header.MinorVersion != 0 && header.MinorVersion != 1 {
if v := header.MajorVersion; v != 0 && v != 1 {
return
// return fmt.Errorf("gettext: %v", "invalid version number")
}
if v := header.MinorVersion; v != 0 && v != 1 {
return
// return fmt.Errorf("gettext: %v", "invalid version number")
}
directorySize := uint64(header.MsgIDCount) * 8
if uint64(header.MsgIDOffset)+directorySize > bufLen ||
uint64(header.MsgStrOffset)+directorySize > bufLen {
msgIDStart := make([]uint32, header.MsgIDCount)
msgIDLen := make([]uint32, header.MsgIDCount)
if _, err := r.Seek(int64(header.MsgIDOffset), 0); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
for i := uint32(0); i < header.MsgIDCount; i++ {
msgIDEntry := int(uint64(header.MsgIDOffset) + uint64(i)*8)
msgIDLen := bo.Uint32(buf[msgIDEntry:])
msgIDStart := bo.Uint32(buf[msgIDEntry+4:])
if uint64(msgIDStart) > bufLen ||
uint64(msgIDLen) > bufLen-uint64(msgIDStart) {
for i := 0; i < int(header.MsgIDCount); i++ {
if err := binary.Read(r, bo, &msgIDLen[i]); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
msgStrEntry := int(uint64(header.MsgStrOffset) + uint64(i)*8)
msgStrLen := bo.Uint32(buf[msgStrEntry:])
msgStrStart := bo.Uint32(buf[msgStrEntry+4:])
if uint64(msgStrStart) > bufLen ||
uint64(msgStrLen) > bufLen-uint64(msgStrStart) {
if err := binary.Read(r, bo, &msgIDStart[i]); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
}
for i := uint32(0); i < header.MsgIDCount; i++ {
msgIDEntry := int(uint64(header.MsgIDOffset) + uint64(i)*8)
msgIDLen := bo.Uint32(buf[msgIDEntry:])
msgIDStart := bo.Uint32(buf[msgIDEntry+4:])
msgIDEnd := int(uint64(msgIDStart) + uint64(msgIDLen))
msgStrStart := make([]int32, header.MsgIDCount)
msgStrLen := make([]int32, header.MsgIDCount)
if _, err := r.Seek(int64(header.MsgStrOffset), 0); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
for i := 0; i < int(header.MsgIDCount); i++ {
if err := binary.Read(r, bo, &msgStrLen[i]); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
if err := binary.Read(r, bo, &msgStrStart[i]); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
}
msgStrEntry := int(uint64(header.MsgStrOffset) + uint64(i)*8)
msgStrLen := bo.Uint32(buf[msgStrEntry:])
msgStrStart := bo.Uint32(buf[msgStrEntry+4:])
msgStrEnd := int(uint64(msgStrStart) + uint64(msgStrLen))
for i := 0; i < int(header.MsgIDCount); i++ {
if _, err := r.Seek(int64(msgIDStart[i]), 0); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
msgIDData := make([]byte, msgIDLen[i])
if _, err := r.Read(msgIDData); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
mo.addTranslation(buf[int(msgIDStart):msgIDEnd], buf[int(msgStrStart):msgStrEnd])
if _, err := r.Seek(int64(msgStrStart[i]), 0); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
msgStrData := make([]byte, msgStrLen[i])
if _, err := r.Read(msgStrData); err != nil {
return
// return fmt.Errorf("gettext: %v", err)
}
if len(msgIDData) == 0 {
mo.addTranslation(msgIDData, msgStrData)
} else {
mo.addTranslation(msgIDData, msgStrData)
}
}
// Parse headers
@@ -251,32 +279,36 @@ func (mo *Mo) addTranslation(msgid, msgstr []byte) {
translation := NewTranslation()
var msgctxt []byte
var msgidPlural []byte
hasContext := false
if eotIndex := bytes.IndexByte(msgid, EotSeparator[0]); eotIndex >= 0 {
hasContext = true
msgctxt = msgid[:eotIndex]
msgid = msgid[eotIndex+len(EotSeparator):]
d := bytes.Split(msgid, []byte(EotSeparator))
if len(d) == 1 {
msgid = d[0]
} else {
msgid, msgctxt = d[1], d[0]
}
if nulIndex := bytes.IndexByte(msgid, NulSeparator[0]); nulIndex >= 0 {
msgidPlural = msgid[nulIndex+1:]
msgid = msgid[:nulIndex]
dd := bytes.Split(msgid, []byte(NulSeparator))
if len(dd) > 1 {
msgid = dd[0]
dd = dd[1:]
}
translation.ID = string(msgid)
msgidPlural = bytes.Join(dd, []byte(NulSeparator))
if len(msgidPlural) > 0 {
translation.PluralID = string(msgidPlural)
}
i := 0
for s := range bytes.SplitSeq(msgstr, []byte(NulSeparator)) {
translation.Trs[i] = string(s)
i++
ddd := bytes.Split(msgstr, []byte(NulSeparator))
if len(ddd) > 0 {
for i, s := range ddd {
translation.Trs[i] = string(s)
}
}
if hasContext {
// With context, including an explicitly empty context...
if len(msgctxt) > 0 {
// With context...
if _, ok := mo.domain.contextTranslations[string(msgctxt)]; !ok {
mo.domain.contextTranslations[string(msgctxt)] = make(map[string]*Translation)
}
+182 -366
View File
@@ -12,12 +12,18 @@ package plurals
import (
"errors"
"fmt"
"slices"
"regexp"
"strconv"
"strings"
"unicode"
)
type match struct {
openPos int
closePos int
}
var pat = regexp.MustCompile(`(\?|:|\|\||&&|==|!=|>=|>|<=|<|%|\d+|n)`)
type testToken interface {
compile(tokens []string) (test test, err error)
}
@@ -25,30 +31,30 @@ type testToken interface {
type cmpTestBuilder func(val uint32, flipped bool) test
type logicTestBuild func(left test, right test) test
func compileTernary(tokens []string, depth int) (expr Expression, err error) {
if depth > maxParseDepth {
return nil, errors.New("expression nesting is too deep")
}
var ternaryToken ternaryStruct
type ternaryStruct struct{}
func (ternaryStruct) compile(tokens []string) (expr Expression, err error) {
main, err := splitTokens(tokens, "?")
if err != nil {
return nil, err
return expr, err
}
test, err := compileTestDepth(strings.Join(main.Left, ""), depth+1)
test, err := compileTest(strings.Join(main.Left, ""))
if err != nil {
return nil, err
return expr, err
}
actions, err := splitTernaryTokens(main.Right)
actions, err := splitTokens(main.Right, ":")
if err != nil {
return nil, err
return expr, err
}
trueAction, err := compileExpressionDepth(strings.Join(actions.Left, ""), depth+1)
trueAction, err := compileExpression(strings.Join(actions.Left, ""))
if err != nil {
return nil, err
return expr, err
}
falseAction, err := compileExpressionDepth(strings.Join(actions.Right, ""), depth+1)
falseAction, err := compileExpression(strings.Join(actions.Right, ""))
if err != nil {
return nil, err
return expr, nil
}
return ternary{
test: test,
@@ -57,145 +63,93 @@ func compileTernary(tokens []string, depth int) (expr Expression, err error) {
}, nil
}
const maxParseDepth = 1024
func splitTernaryTokens(tokens []string) (s splitted, err error) {
depth := 0
for index, token := range tokens {
switch token {
case "?":
depth++
case ":":
if depth == 0 {
return splitted{
Left: tokens[:index],
Right: tokens[index+1:],
}, nil
}
depth--
}
}
return s, errors.New("':' not found for ternary expression")
}
var constToken constValStruct
type constValStruct struct{}
func (constValStruct) compile(tokens []string) (expr Expression, err error) {
if len(tokens) == 0 {
return nil, errors.New("got nothing instead of constant")
return expr, errors.New("got nothing instead of constant")
}
if len(tokens) != 1 {
return nil, fmt.Errorf("invalid constant: %s", strings.Join(tokens, ""))
return expr, fmt.Errorf("invalid constant: %s", strings.Join(tokens, ""))
}
i, err := strconv.Atoi(tokens[0])
if err != nil {
return nil, err
return expr, err
}
return constValue{value: i}, nil
}
func compileLogicTestDepth(tokens []string, sep string, builder logicTestBuild, depth int) (test test, err error) {
if depth > maxParseDepth {
return nil, errors.New("expression nesting is too deep")
}
func compileLogicTest(tokens []string, sep string, builder logicTestBuild) (test test, err error) {
split, err := splitTokens(tokens, sep)
if err != nil {
return nil, err
return test, err
}
left, err := compileTestDepth(strings.Join(split.Left, ""), depth+1)
left, err := compileTest(strings.Join(split.Left, ""))
if err != nil {
return nil, err
return test, err
}
right, err := compileTestDepth(strings.Join(split.Right, ""), depth+1)
right, err := compileTest(strings.Join(split.Right, ""))
if err != nil {
return nil, err
return test, err
}
return builder(left, right), nil
}
var orToken orStruct
type orStruct struct{}
func (orStruct) compile(tokens []string) (test test, err error) {
return compileLogicTest(tokens, "||", buildOr)
}
func buildOr(left test, right test) test {
return or{left: left, right: right}
}
var andToken andStruct
type andStruct struct{}
func (andStruct) compile(tokens []string) (test test, err error) {
return compileLogicTest(tokens, "&&", buildAnd)
}
func buildAnd(left test, right test) test {
return and{left: left, right: right}
}
func unwrapSingleGroup(tokens []string) ([]string, error) {
for len(tokens) == 1 && strings.HasPrefix(tokens[0], "(") {
inner, err := tokenize(tokens[0])
if err != nil {
return nil, err
}
if len(inner) == 1 && inner[0] == tokens[0] {
break
}
tokens = inner
}
return tokens, nil
}
func isSimpleN(tokens []string) bool {
tokens, err := unwrapSingleGroup(tokens)
if err != nil || len(tokens) != 1 {
return false
}
return tokens[0] == "n"
}
func parseLiteralTokens(tokens []string) (uint32, error) {
tokens, err := unwrapSingleGroup(tokens)
if err != nil {
return 0, err
}
if len(tokens) != 1 {
return 0, errors.New("expected a single integer")
}
return parseUint32(tokens[0])
}
func containsSimpleN(tokens []string) bool {
for _, token := range tokens {
if isSimpleN([]string{token}) {
return true
}
}
return false
}
func compileMod(tokens []string) (math math, err error) {
split, err := splitTokens(tokens, "%")
if err != nil {
return math, err
}
if !isSimpleN(split.Left) {
if len(split.Left) != 1 || split.Left[0] != "n" {
return math, errors.New("modulus operation requires 'n' as left operand")
}
i, err := parseLiteralTokens(split.Right)
if len(split.Right) != 1 {
return math, errors.New("modulus operation requires simple integer as right operand")
}
i, err := parseUint32(split.Right[0])
if err != nil {
return math, err
}
if i == 0 {
return math, errors.New("modulus operation requires a non-zero integer as right operand")
}
return mod{value: i}, nil
return mod{value: uint32(i)}, nil
}
func subPipe(modTokens []string, actionTokens []string, builder cmpTestBuilder, flipped bool) (test test, err error) {
modifier, err := compileMod(modTokens)
if err != nil {
return nil, err
return test, err
}
if len(actionTokens) == 0 {
return nil, errors.New("can only get modulus of integer")
if len(actionTokens) != 1 {
return test, errors.New("can only get modulus of integer")
}
i, err := parseLiteralTokens(actionTokens)
i, err := parseUint32(actionTokens[0])
if err != nil {
return nil, err
return test, err
}
action := builder(i, flipped)
action := builder(uint32(i), flipped)
return pipe{
modifier: modifier,
action: action,
@@ -205,37 +159,31 @@ func subPipe(modTokens []string, actionTokens []string, builder cmpTestBuilder,
func compileEquality(tokens []string, sep string, builder cmpTestBuilder) (test test, err error) {
split, err := splitTokens(tokens, sep)
if err != nil {
return nil, err
return test, err
}
left, err := unwrapSingleGroup(split.Left)
if err != nil {
return nil, err
}
right, err := unwrapSingleGroup(split.Right)
if err != nil {
return nil, err
}
if isSimpleN(left) {
i, err := parseLiteralTokens(right)
if len(split.Left) == 1 && split.Left[0] == "n" {
if len(split.Right) != 1 {
return test, errors.New("test can only compare n to integers")
}
i, err := parseUint32(split.Right[0])
if err != nil {
return nil, err
return test, err
}
return builder(i, false), nil
}
if isSimpleN(right) {
i, err := parseLiteralTokens(left)
} else if len(split.Right) == 1 && split.Right[0] == "n" {
if len(split.Left) != 1 {
return test, errors.New("test can only compare n to integers")
}
i, err := parseUint32(split.Left[0])
if err != nil {
return nil, err
return test, err
}
return builder(i, true), nil
} else if contains(split.Left, "n") && contains(split.Left, "%") {
return subPipe(split.Left, split.Right, builder, false)
}
if containsSimpleN(left) && slices.Contains(left, "%") {
return subPipe(left, right, builder, false)
}
if containsSimpleN(right) && slices.Contains(right, "%") {
return subPipe(right, left, builder, true)
}
return nil, errors.New("equality test must have 'n' as one of the two tests")
return test, errors.New("equality test must have 'n' as one of the two tests")
}
var eqToken eqStruct
@@ -310,8 +258,8 @@ type testTokenDef struct {
}
var precedence = []testTokenDef{
{op: "||"},
{op: "&&"},
{op: "||", token: orToken},
{op: "&&", token: andToken},
{op: "==", token: eqToken},
{op: "!=", token: neqToken},
{op: ">=", token: gteToken},
@@ -325,10 +273,20 @@ type splitted struct {
Right []string
}
// Find index of token in list of tokens
func index(tokens []string, sep string) int {
for index, token := range tokens {
if token == sep {
return index
}
}
return -1
}
// Split a list of tokens by a token into a splitted struct holding the tokens
// before and after the token to be split by.
func splitTokens(tokens []string, sep string) (s splitted, err error) {
index := slices.Index(tokens, sep)
index := index(tokens, sep)
if index == -1 {
return s, fmt.Errorf("'%s' not found in ['%s']", sep, strings.Join(tokens, "','"))
}
@@ -338,277 +296,135 @@ func splitTokens(tokens []string, sep string) (s splitted, err error) {
}, nil
}
func trimLexicalWhitespace(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
// Scan a string for parenthesis
func scan(s string) <-chan match {
ch := make(chan match)
go func() {
depth := 0
opener := 0
for index, char := range s {
switch char {
case '(':
if depth == 0 {
opener = index
}
depth++
case ')':
depth--
if depth == 0 {
ch <- match{
openPos: opener,
closePos: index + 1,
}
}
}
}
return r
}, s)
close(ch)
}()
return ch
}
func lexicalToken(s string, index int) (token string, next int, err error) {
if index >= len(s) {
return "", index, errors.New("unexpected end of expression")
}
switch s[index] {
case '?', ':', '%', '(', ')':
return s[index : index+1], index + 1, nil
case '|':
if index+1 >= len(s) || s[index+1] != '|' {
return "", index, errors.New("logical OR must use '||'")
}
return "||", index + 2, nil
case '&':
if index+1 >= len(s) || s[index+1] != '&' {
return "", index, errors.New("logical AND must use '&&'")
}
return "&&", index + 2, nil
case '=':
if index+1 >= len(s) || s[index+1] != '=' {
return "", index, errors.New("equality must use '=='")
}
return "==", index + 2, nil
case '!':
if index+1 >= len(s) || s[index+1] != '=' {
return "", index, errors.New("inequality must use '!='")
}
return "!=", index + 2, nil
case '>':
if index+1 < len(s) && s[index+1] == '=' {
return ">=", index + 2, nil
}
return ">", index + 1, nil
case '<':
if index+1 < len(s) && s[index+1] == '=' {
return "<=", index + 2, nil
}
return "<", index + 1, nil
case 'n':
return "n", index + 1, nil
default:
if s[index] >= '0' && s[index] <= '9' {
next = index + 1
for next < len(s) && s[next] >= '0' && s[next] <= '9' {
next++
// Split the string into tokens
func split(s string) <-chan string {
ch := make(chan string)
go func() {
s = strings.ReplaceAll(s, " ", "")
if !strings.Contains(s, "(") {
ch <- s
} else {
last := 0
end := len(s)
for info := range scan(s) {
if last != info.openPos {
ch <- s[last:info.openPos]
}
ch <- s[info.openPos:info.closePos]
last = info.closePos
}
return s[index:next], next, nil
}
return "", index, fmt.Errorf("invalid character %q in expression", s[index])
}
}
func validateParentheses(s string) error {
depth := 0
for index := 0; index < len(s); {
token, next, err := lexicalToken(s, index)
if err != nil {
return err
}
switch token {
case "(":
depth++
case ")":
depth--
if depth < 0 {
return errors.New("unmatched closing parenthesis")
if last != end {
ch <- s[last:]
}
}
index = next
}
if depth != 0 {
return errors.New("unmatched opening parenthesis")
}
return nil
}
func stripRedundantOuterParens(s string) string {
if len(s) < 2 || s[0] != '(' {
return s
}
leading := 0
for leading < len(s) && s[leading] == '(' {
leading++
}
openers := make([]int, 0, leading)
outer := leading
for index := range s {
switch s[index] {
case '(':
openers = append(openers, index)
case ')':
if len(openers) == 0 {
return s
}
opener := openers[len(openers)-1]
openers = openers[:len(openers)-1]
if opener < outer && index != len(s)-1-opener {
outer = opener
}
}
}
if len(openers) != 0 {
return s
}
return s[outer : len(s)-outer]
close(ch)
}()
return ch
}
// Tokenizes a string into a list of strings, tokens grouped by parenthesis are
// not split! If the string starts with ( and ends in ), those are stripped.
func tokenize(s string) ([]string, error) {
s = trimLexicalWhitespace(s)
if s == "" {
return []string{}, nil
}
if err := validateParentheses(s); err != nil {
return nil, err
}
s = stripRedundantOuterParens(s)
if s == "" {
return []string{}, nil
}
func tokenize(s string) []string {
/*
TODO: Properly detect if the string starts with a ( and ends with a )
and that those two form a matching pair.
ret := []string{}
for index := 0; index < len(s); {
if s[index] == '(' {
start := index
depth := 0
groupEnd := false
for index < len(s) {
switch s[index] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
index++
ret = append(ret, s[start:index])
groupEnd = true
}
}
if groupEnd {
break
}
index++
}
if depth != 0 {
return nil, errors.New("unmatched opening parenthesis")
}
continue
}
token, next, err := lexicalToken(s, index)
if err != nil {
return nil, err
}
if token == ")" {
return nil, errors.New("unmatched closing parenthesis")
}
ret = append(ret, token)
index = next
Eg: (foo) -> true; (foo)(bar) -> false;
*/
if len(s) == 0 {
return []string{}
}
return ret, nil
if s[0] == '(' && s[len(s)-1] == ')' {
s = s[1 : len(s)-1]
}
ret := []string{}
for chunk := range split(s) {
if len(chunk) != 0 {
if chunk[0] == '(' && chunk[len(chunk)-1] == ')' {
ret = append(ret, chunk)
} else {
for _, token := range pat.FindAllStringSubmatch(chunk, -1) {
ret = append(ret, token[0])
}
}
} else {
fmt.Printf("Empty chunk in string '%s'\n", s)
}
}
return ret
}
// Compile a string containing a plural form expression to a Expression object.
func Compile(s string) (expr Expression, err error) {
s = trimLexicalWhitespace(s)
if s == "" {
return nil, errors.New("empty expression")
if s == "0" {
return constValue{value: 0}, nil
}
return compileExpressionDepth(s, 0)
if !strings.Contains(s, "?") {
s += "?1:0"
}
return compileExpression(s)
}
// Compiles an expression (ternary, numeric leaf, or test).
func compileExpressionDepth(s string, depth int) (expr Expression, err error) {
if depth > maxParseDepth {
return nil, errors.New("expression nesting is too deep")
}
tokens, err := tokenize(s)
if err != nil {
return nil, err
}
if slices.Contains(tokens, "?") {
return compileTernary(tokens, depth)
}
for _, tokenDef := range precedence {
if !slices.Contains(tokens, tokenDef.op) {
continue
// Check if a token is in a slice of strings
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
condition, err := compileTestDepth(s, depth+1)
if err != nil {
return nil, err
}
return testValue{condition: condition}, nil
}
if isSimpleN(tokens) {
return variableValue{}, nil
}
if slices.Contains(tokens, "%") {
modifier, err := compileMod(tokens)
if err != nil {
return nil, err
}
return mathValue{value: modifier}, nil
return false
}
// Compiles an expression (ternary or constant)
func compileExpression(s string) (expr Expression, err error) {
tokens := tokenize(s)
if contains(tokens, "?") {
return ternaryToken.compile(tokens)
}
return constToken.compile(tokens)
}
// Compiles a test (comparison)
func compileTestDepth(s string, depth int) (test test, err error) {
if depth > maxParseDepth {
return nil, errors.New("expression nesting is too deep")
}
tokens, err := tokenize(s)
if err != nil {
return nil, err
}
func compileTest(s string) (test test, err error) {
tokens := tokenize(s)
for _, tokenDef := range precedence {
if !slices.Contains(tokens, tokenDef.op) {
continue
}
switch tokenDef.op {
case "||":
return compileLogicTestDepth(tokens, "||", buildOr, depth)
case "&&":
return compileLogicTestDepth(tokens, "&&", buildAnd, depth)
default:
if contains(tokens, tokenDef.op) {
return tokenDef.token.compile(tokens)
}
}
if slices.Contains(tokens, "%") {
m, err := compileMod(tokens)
if err != nil {
return nil, err
}
return pipe{
modifier: m,
action: notequal{value: 0},
}, nil
}
if len(tokens) == 1 && strings.HasPrefix(tokens[0], "(") {
inner, err := tokenize(tokens[0])
if err != nil {
return nil, err
}
if len(inner) == 1 && inner[0] == tokens[0] {
return nil, errors.New("cannot compile parenthesized test")
}
return compileTestDepth(tokens[0], depth+1)
}
if len(tokens) == 1 && tokens[0] == "n" {
return notequal{value: 0}, nil
}
if value, err := parseLiteralTokens(tokens); err == nil {
return literalTest{value: value}, nil
}
return nil, errors.New("cannot compile")
return test, errors.New("cannot compile")
}
func parseUint32(s string) (ui uint32, err error) {
s = trimLexicalWhitespace(s)
i, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return ui, err
-34
View File
@@ -20,37 +20,6 @@ func (c constValue) Eval(n uint32) int {
return c.value
}
type variableValue struct{}
func (variableValue) Eval(n uint32) int {
return int(n)
}
type mathValue struct {
value math
}
func (m mathValue) Eval(n uint32) int {
if m.value == nil {
return -1
}
return int(m.value.calc(n))
}
type testValue struct {
condition test
}
func (t testValue) Eval(n uint32) int {
if t.condition == nil {
return -1
}
if t.condition.test(n) {
return 1
}
return 0
}
type test interface {
test(n uint32) bool
}
@@ -62,9 +31,6 @@ type ternary struct {
}
func (t ternary) Eval(n uint32) int {
if t.test == nil {
return -1
}
if t.test.test(n) {
if t.trueExpr == nil {
return -1
-3
View File
@@ -15,8 +15,5 @@ type mod struct {
}
func (m mod) calc(n uint32) uint32 {
if m.value == 0 {
return 0
}
return n % m.value
}
-17
View File
@@ -22,14 +22,6 @@ func (e notequal) test(n uint32) bool {
return n != e.value
}
type literalTest struct {
value uint32
}
func (l literalTest) test(n uint32) bool {
return l.value != 0
}
type gt struct {
value uint32
flipped bool
@@ -85,9 +77,6 @@ type and struct {
}
func (e and) test(n uint32) bool {
if e.left == nil || e.right == nil {
return false
}
if !e.left.test(n) {
return false
}
@@ -100,9 +89,6 @@ type or struct {
}
func (e or) test(n uint32) bool {
if e.left == nil || e.right == nil {
return false
}
if e.left.test(n) {
return true
}
@@ -115,8 +101,5 @@ type pipe struct {
}
func (e pipe) test(n uint32) bool {
if e.modifier == nil || e.action == nil {
return false
}
return e.action.test(e.modifier.calc(n))
}
+80 -307
View File
@@ -42,10 +42,6 @@ type Po struct {
domain *Domain
fs fs.FS
parseBufferHasID bool
parseBufferHasPlural bool
parseBufferHasContext bool
}
type parseState int
@@ -56,7 +52,6 @@ const (
msgID
msgIDPlural
msgStr
msgStrInvalid
)
// NewPo should always be used to instantiate a new Po object
@@ -99,7 +94,7 @@ func (po *Po) GetRefs(str string) []string {
// SetPluralResolver sets the plural resolver function
func (po *Po) SetPluralResolver(f func(int) int) {
po.domain.SetPluralResolver(f)
po.domain.customPluralResolver = f
}
// Set translation
@@ -108,12 +103,12 @@ func (po *Po) Set(id, str string) {
}
// Get translation
func (po *Po) Get(str string, vars ...any) string {
func (po *Po) Get(str string, vars ...interface{}) string {
return po.domain.Get(str, vars...)
}
// Append translation
func (po *Po) Append(b []byte, str string, vars ...any) []byte {
func (po *Po) Append(b []byte, str string, vars ...interface{}) []byte {
return po.domain.Append(b, str, vars...)
}
@@ -123,12 +118,12 @@ func (po *Po) SetN(id, plural string, n int, str string) {
}
// GetN gets the plural translation
func (po *Po) GetN(str, plural string, n int, vars ...any) string {
func (po *Po) GetN(str, plural string, n int, vars ...interface{}) string {
return po.domain.GetN(str, plural, n, vars...)
}
// AppendN appends the plural translation
func (po *Po) AppendN(b []byte, str, plural string, n int, vars ...any) []byte {
func (po *Po) AppendN(b []byte, str, plural string, n int, vars ...interface{}) []byte {
return po.domain.AppendN(b, str, plural, n, vars...)
}
@@ -138,12 +133,12 @@ func (po *Po) SetC(id, ctx, str string) {
}
// GetC gets the translation for a given context
func (po *Po) GetC(str, ctx string, vars ...any) string {
func (po *Po) GetC(str, ctx string, vars ...interface{}) string {
return po.domain.GetC(str, ctx, vars...)
}
// AppendC appends the translation for a given context
func (po *Po) AppendC(b []byte, str, ctx string, vars ...any) []byte {
func (po *Po) AppendC(b []byte, str, ctx string, vars ...interface{}) []byte {
return po.domain.AppendC(b, str, ctx, vars...)
}
@@ -153,12 +148,12 @@ func (po *Po) SetNC(id, plural, ctx string, n int, str string) {
}
// GetNC gets the plural translation for a given context
func (po *Po) GetNC(str, plural string, n int, ctx string, vars ...any) string {
func (po *Po) GetNC(str, plural string, n int, ctx string, vars ...interface{}) string {
return po.domain.GetNC(str, plural, n, ctx, vars...)
}
// AppendNC appends the plural translation for a given context
func (po *Po) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...any) []byte {
func (po *Po) AppendNC(b []byte, str, plural string, n int, ctx string, vars ...interface{}) []byte {
return po.domain.AppendNC(b, str, plural, n, ctx, vars...)
}
@@ -219,17 +214,16 @@ func (po *Po) Parse(buf []byte) {
defer po.domain.trMutex.Unlock()
defer po.domain.pluralMutex.Unlock()
// Get lines
lines := strings.Split(string(buf), "\n")
// Init buffer
po.domain.trBuffer = NewTranslation()
po.domain.ctxBuffer = ""
po.domain.refBuffer = ""
po.parseBufferHasID = false
po.parseBufferHasPlural = false
po.parseBufferHasContext = false
state := head
activeMsgStrIndex := 0
for l := range strings.Lines(string(buf)) {
for _, l := range lines {
// Trim spaces
l = strings.TrimSpace(l)
@@ -239,71 +233,44 @@ func (po *Po) Parse(buf []byte) {
continue
}
// Multi line strings and headers
if strings.HasPrefix(l, "\"") {
if !po.parseString(l, state, activeMsgStrIndex) && state != head {
state = msgStrInvalid
}
continue
}
// Buffer context and continue
if po.hasKeywordBoundary(l, "msgctxt") {
if po.parseContext(l) {
state = msgCtxt
} else {
state = msgStrInvalid
}
continue
}
// Check for plural form
if po.hasKeywordBoundary(l, "msgid_plural") {
if state != msgID || !po.parsePluralID(l) {
state = msgStrInvalid
} else {
state = msgIDPlural
}
if strings.HasPrefix(l, "msgctxt") {
po.parseContext(l)
state = msgCtxt
continue
}
// Buffer msgid and continue
if po.hasKeywordBoundary(l, "msgid") {
if po.parseID(l) {
state = msgID
} else {
state = msgStrInvalid
}
if strings.HasPrefix(l, "msgid") && !strings.HasPrefix(l, "msgid_plural") {
po.parseID(l)
state = msgID
continue
}
// Check for plural form
if strings.HasPrefix(l, "msgid_plural") {
po.parsePluralID(l)
po.domain.pluralTranslations[po.domain.trBuffer.PluralID] = po.domain.trBuffer
state = msgIDPlural
continue
}
// Save Translation
if po.hasKeywordBoundary(l, "msgstr") {
if state != msgID && state != msgIDPlural && state != msgStr {
activeMsgStrIndex = 0
state = msgStrInvalid
continue
}
if strings.HasPrefix(l, "msgstr") {
po.parseMessage(l)
state = msgStr
continue
}
var ok bool
activeMsgStrIndex, ok = po.parseMessage(l)
if ok {
state = msgStr
} else {
activeMsgStrIndex = 0
state = msgStrInvalid
}
// Multi line strings and headers
if strings.HasPrefix(l, "\"") && strings.HasSuffix(l, "\"") {
po.parseString(l, state)
continue
}
}
// Save last Translation buffer, but do not synthesize an entry after an
// invalid directive. Preserve the historical empty-input entry.
if po.parseBufferHasID {
po.saveBuffer()
} else if len(buf) == 0 {
po.domain.translations[""] = po.domain.trBuffer
}
// Save last Translation buffer.
po.saveBuffer()
// Parse headers
po.domain.parseHeaders()
@@ -318,30 +285,22 @@ func (po *Po) Parse(buf []byte) {
// saveBuffer takes the context and Translation buffers
// and saves it on the translations collection
func (po *Po) saveBuffer() {
if !po.parseBufferHasID {
return
}
// Store a completed plural ID after all of its continuation lines have
// been decoded.
if po.parseBufferHasPlural {
po.domain.pluralTranslations[po.domain.trBuffer.PluralID] = po.domain.trBuffer
}
if po.parseBufferHasContext {
// With no context...
if po.domain.ctxBuffer == "" {
po.domain.translations[po.domain.trBuffer.ID] = po.domain.trBuffer
} else {
// With context...
if _, ok := po.domain.contextTranslations[po.domain.ctxBuffer]; !ok {
po.domain.contextTranslations[po.domain.ctxBuffer] = make(map[string]*Translation)
}
po.domain.contextTranslations[po.domain.ctxBuffer][po.domain.trBuffer.ID] = po.domain.trBuffer
// Context applies only to the record it prefixes, even when its ID is empty.
po.domain.ctxBuffer = ""
po.parseBufferHasContext = false
} else {
po.domain.translations[po.domain.trBuffer.ID] = po.domain.trBuffer
// Cleanup current context buffer if needed
if po.domain.trBuffer.ID != "" {
po.domain.ctxBuffer = ""
}
}
po.parseBufferHasID = false
po.parseBufferHasPlural = false
// Flush Translation buffer
if po.domain.refBuffer == "" {
po.domain.trBuffer = NewTranslation()
@@ -369,106 +328,68 @@ func (po *Po) parseComment(l string, state parseState) {
// parseContext takes a line starting with "msgctxt",
// saves the current Translation buffer and creates a new context.
func (po *Po) parseContext(l string) bool {
value, ok := po.parseQuotedDirective(l, "msgctxt")
if !ok {
return false
}
func (po *Po) parseContext(l string) {
// Save current Translation buffer.
po.saveBuffer()
// Buffer context, including an explicitly empty context.
po.domain.ctxBuffer = value
po.parseBufferHasContext = true
return true
// Buffer context
po.domain.ctxBuffer, _ = strconv.Unquote(strings.TrimSpace(strings.TrimPrefix(l, "msgctxt")))
}
// parseID takes a line starting with "msgid",
// saves the current Translation and creates a new msgid buffer.
func (po *Po) parseID(l string) bool {
value, ok := po.parseQuotedDirective(l, "msgid")
if !ok {
return false
}
func (po *Po) parseID(l string) {
// Save current Translation buffer.
po.saveBuffer()
// Set id
po.domain.trBuffer.ID = value
po.parseBufferHasID = true
return true
po.domain.trBuffer.ID, _ = strconv.Unquote(strings.TrimSpace(strings.TrimPrefix(l, "msgid")))
}
// parsePluralID saves the plural id buffer from a line starting with "msgid_plural"
func (po *Po) parsePluralID(l string) bool {
value, ok := po.parseQuotedDirective(l, "msgid_plural")
if !ok {
return false
}
po.domain.trBuffer.PluralID = value
po.parseBufferHasPlural = true
return true
func (po *Po) parsePluralID(l string) {
po.domain.trBuffer.PluralID, _ = strconv.Unquote(strings.TrimSpace(strings.TrimPrefix(l, "msgid_plural")))
}
// parseMessage takes a line starting with "msgstr" and saves it into the current buffer.
func (po *Po) parseMessage(l string) (int, bool) {
if !po.hasKeywordBoundary(l, "msgstr") {
return 0, false
}
l = strings.TrimSpace(l[len("msgstr"):])
func (po *Po) parseMessage(l string) {
l = strings.TrimSpace(strings.TrimPrefix(l, "msgstr"))
// Check for indexed Translation forms
if strings.HasPrefix(l, "[") {
idx := strings.IndexByte(l, ']')
if idx <= 1 || !isASCIIPluralIndex(l[1:idx]) {
idx := strings.Index(l, "]")
if idx == -1 {
// Skip wrong index formatting
return 0, false
return
}
// Parse index
i, err := strconv.Atoi(l[1:idx])
if err != nil {
// Skip wrong index formatting
return 0, false
return
}
// Parse Translation string
clean, ok := decodePOQuotedString(strings.TrimSpace(l[idx+1:]))
if !ok {
return 0, false
}
po.domain.trBuffer.Trs[i] = clean
po.domain.trBuffer.Trs[i], _ = strconv.Unquote(strings.TrimSpace(l[idx+1:]))
return i, true
// Loop
return
}
// Save single Translation form under 0 index
clean, ok := decodePOQuotedString(l)
if !ok {
return 0, false
}
po.domain.trBuffer.Trs[0] = clean
return 0, true
po.domain.trBuffer.Trs[0], _ = strconv.Unquote(l)
}
// parseString takes a well formatted string without prefix
// and creates headers or attach multi-line strings when corresponding
func (po *Po) parseString(l string, state parseState, activeMsgStrIndex int) bool {
if state == msgStrInvalid {
return false
}
clean, ok := decodePOQuotedString(l)
if !ok {
return false
}
func (po *Po) parseString(l string, state parseState) {
clean, _ := strconv.Unquote(l)
switch state {
case msgStr:
// Append to active Translation form
po.domain.trBuffer.Trs[activeMsgStrIndex] += clean
// Append to last Translation found
po.domain.trBuffer.Trs[len(po.domain.trBuffer.Trs)-1] += clean
case msgID:
// Multiline msgid - Append to current id
@@ -481,174 +402,26 @@ func (po *Po) parseString(l string, state parseState, activeMsgStrIndex int) boo
case msgCtxt:
// Multiline context - Append to current context
po.domain.ctxBuffer += clean
}
return true
}
}
// isValidLine checks for line prefixes to detect valid syntax.
func (po *Po) isValidLine(l string) bool {
if strings.HasPrefix(l, "\"") {
return true
// Check prefix
valid := []string{
"\"",
"msgctxt",
"msgid",
"msgid_plural",
"msgstr",
}
for _, keyword := range []string{
"msgctxt",
"msgid_plural",
"msgid",
"msgstr",
} {
if po.hasKeywordBoundary(l, keyword) {
for _, v := range valid {
if strings.HasPrefix(l, v) {
return true
}
}
return false
}
func (po *Po) hasKeywordBoundary(l, keyword string) bool {
if !strings.HasPrefix(l, keyword) {
return false
}
if len(l) == len(keyword) {
return true
}
next := l[len(keyword)]
if keyword == "msgstr" && next == '[' {
return true
}
return next == ' ' || next == '\t'
}
func (po *Po) parseQuotedDirective(l, keyword string) (string, bool) {
if !po.hasKeywordBoundary(l, keyword) {
return "", false
}
value, ok := decodePOQuotedString(strings.TrimSpace(l[len(keyword):]))
if !ok {
return "", false
}
return value, true
}
// decodePOQuotedString decodes a GNU PO double-quoted string.
func decodePOQuotedString(source string) (string, bool) {
if len(source) < 2 || source[0] != '"' {
return "", false
}
var decoded []byte
for i := 1; i < len(source); {
switch source[i] {
case '"':
if i != len(source)-1 {
return "", false
}
if decoded == nil {
return source[1:i], true
}
return string(decoded), true
case '\\':
if decoded == nil {
decoded = make([]byte, 0, len(source)-2)
decoded = append(decoded, source[1:i]...)
}
i++
if i >= len(source) {
return "", false
}
switch source[i] {
case 'a':
decoded = append(decoded, '\a')
i++
case 'b':
decoded = append(decoded, '\b')
i++
case 'f':
decoded = append(decoded, '\f')
i++
case 'n':
decoded = append(decoded, '\n')
i++
case 'r':
decoded = append(decoded, '\r')
i++
case 't':
decoded = append(decoded, '\t')
i++
case 'v':
decoded = append(decoded, '\v')
i++
case '\\':
decoded = append(decoded, '\\')
i++
case '"':
decoded = append(decoded, '"')
i++
case '0', '1', '2', '3', '4', '5', '6', '7':
var value byte
for digits := 0; i < len(source) && digits < 3; digits++ {
if source[i] < '0' || source[i] > '7' {
break
}
value = value<<3 | (source[i] - '0')
i++
}
decoded = append(decoded, value)
case 'x':
i++
if i >= len(source) || !isPOHexDigit(source[i]) {
return "", false
}
var value byte
for i < len(source) && isPOHexDigit(source[i]) {
value = value<<4 | poHexValue(source[i])
i++
}
decoded = append(decoded, value)
default:
return "", false
}
default:
if decoded != nil {
decoded = append(decoded, source[i])
}
i++
}
}
return "", false
}
func isPOHexDigit(c byte) bool {
return c >= '0' && c <= '9' ||
c >= 'a' && c <= 'f' ||
c >= 'A' && c <= 'F'
}
func poHexValue(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
default:
return c - 'A' + 10
}
}
func isASCIIPluralIndex(index string) bool {
if index == "" {
return false
}
for i := 0; i < len(index); i++ {
if index[i] < '0' || index[i] > '9' {
return false
}
}
return true
}
+14 -6
View File
@@ -49,8 +49,11 @@ func (t *Translation) Set(str string) {
// Get returns the string of the translation
func (t *Translation) Get() string {
if tr := t.Trs[0]; tr != "" {
return tr
// Look for Translation index 0
if _, ok := t.Trs[0]; ok {
if t.Trs[0] != "" {
return t.Trs[0]
}
}
// Return untranslated id by default
@@ -65,8 +68,11 @@ func (t *Translation) SetN(n int, str string) {
// GetN returns the string of the plural translation
func (t *Translation) GetN(n int) string {
if tr := t.Trs[n]; tr != "" {
return tr
// Look for Translation index
if _, ok := t.Trs[n]; ok {
if t.Trs[n] != "" {
return t.Trs[n]
}
}
// Return untranslated singular if corresponding
@@ -80,10 +86,12 @@ func (t *Translation) GetN(n int) string {
// IsTranslated reports whether a string is translated
func (t *Translation) IsTranslated() bool {
return t.Trs[0] != ""
tr, ok := t.Trs[0]
return tr != "" && ok
}
// IsTranslatedN reports whether a plural string is translated
func (t *Translation) IsTranslatedN(n int) bool {
return t.Trs[n] != ""
tr, ok := t.Trs[n]
return tr != "" && ok
}
+14 -60
View File
@@ -9,20 +9,18 @@ import (
"errors"
"io/fs"
"os"
"github.com/leonelquinteros/gotext/plurals"
)
// Translator interface is used by Locale and Po objects.
// Translator interface is used by Locale and Po objects.Translator
// It contains all methods needed to parse translation sources and obtain corresponding translations.
// It includes encoding.BinaryMarshaler and encoding.BinaryUnmarshaler methods for Locale serialization.
// Also implements gob.GobEncoder/gob.DobDecoder interfaces to allow serialization of Locale objects.
type Translator interface {
ParseFile(f string)
Parse(buf []byte)
Get(str string, vars ...any) string
GetN(str, plural string, n int, vars ...any) string
GetC(str, ctx string, vars ...any) string
GetNC(str, plural string, n int, ctx string, vars ...any) string
Get(str string, vars ...interface{}) string
GetN(str, plural string, n int, vars ...interface{}) string
GetC(str, ctx string, vars ...interface{}) string
GetNC(str, plural string, n int, ctx string, vars ...interface{}) string
MarshalBinary() ([]byte, error)
UnmarshalBinary([]byte) error
@@ -33,10 +31,10 @@ type Translator interface {
// It contains all methods needed to parse translation sources and to append entries to the object.
type AppendTranslator interface {
Translator
Append(b []byte, str string, vars ...any) []byte
AppendN(b []byte, str, plural string, n int, vars ...any) []byte
AppendC(b []byte, str, ctx string, vars ...any) []byte
AppendNC(b []byte, str, plural string, n int, ctx string, vars ...any) []byte
Append(b []byte, str string, vars ...interface{}) []byte
AppendN(b []byte, str, plural string, n int, vars ...interface{}) []byte
AppendC(b []byte, str, ctx string, vars ...interface{}) []byte
AppendNC(b []byte, str, plural string, n int, ctx string, vars ...interface{}) []byte
}
// TranslatorEncoding is used as intermediary storage to encode Translator objects to Gob.
@@ -65,58 +63,14 @@ type TranslatorEncoding struct {
// deserialize into a Po-compatible object.
func (te *TranslatorEncoding) GetTranslator() Translator {
po := NewPo()
if te == nil {
return po
}
headers := te.Headers
if headers == nil {
headers = make(HeaderMap)
}
translations := te.Translations
if translations == nil {
translations = make(map[string]*Translation)
}
for id, translation := range translations {
if translation == nil {
translation = NewTranslation()
translation.ID = id
translations[id] = translation
}
}
contexts := te.Contexts
if contexts == nil {
contexts = make(map[string]map[string]*Translation)
}
for context, translationsForContext := range contexts {
if translationsForContext == nil {
contexts[context] = make(map[string]*Translation)
continue
}
for id, translation := range translationsForContext {
if translation == nil {
translation = NewTranslation()
translation.ID = id
translationsForContext[id] = translation
}
}
}
po.domain.Headers = headers
po.domain = NewDomain()
po.domain.Headers = te.Headers
po.domain.Language = te.Language
po.domain.PluralForms = te.PluralForms
po.domain.nplurals = te.Nplurals
po.domain.plural = te.Plural
po.domain.translations = translations
po.domain.contextTranslations = contexts
if expr, err := plurals.Compile(te.Plural); err == nil {
po.domain.pluralforms = expr
}
po.Headers = po.domain.Headers
po.Language = po.domain.Language
po.PluralForms = po.domain.PluralForms
po.domain.translations = te.Translations
po.domain.contextTranslations = te.Contexts
return po
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8
FROM golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344acbcf65fc7c455d4ff5a40602
ENV GOOS=linux
ENV GOARCH=arm
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8
FROM golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344acbcf65fc7c455d4ff5a40602
ENV GOOS=linux
ENV GOARCH=arm64
+1 -1
View File
@@ -1,4 +1,4 @@
# This is a renovate-friendly source of Docker images.
FROM python:3.13.6-slim-bullseye@sha256:e98b521460ee75bca92175c16247bdf7275637a8faaeb2bcfa19d879ae5c4b9a AS python
FROM otel/weaver:v0.28.0@sha256:5857bb81d8cc7ed608cdcae37c4b459eb020f21dd4acade35f7933cd6762fbcc AS weaver
FROM otel/weaver:v0.21.2@sha256:2401de985c38bdb98b43918e2f43aa36b2afed4aa5669ac1c1de0a17301cd36d AS weaver
FROM avtodev/markdown-lint:v1@sha256:6aeedc2f49138ce7a1cd0adffc1b1c0321b841dc2102408967d9301c031949ee AS markdown
Generated Vendored
+2 -2
View File
@@ -403,8 +403,8 @@ github.com/klauspost/compress/internal/le
github.com/klauspost/compress/internal/snapref
github.com/klauspost/compress/zstd
github.com/klauspost/compress/zstd/internal/xxhash
# github.com/leonelquinteros/gotext v1.8.0
## explicit; go 1.27
# github.com/leonelquinteros/gotext v1.7.2
## explicit; go 1.23.5
github.com/leonelquinteros/gotext
github.com/leonelquinteros/gotext/plurals
# github.com/lucasb-eyer/go-colorful v1.4.0